diff --git a/package.json b/package.json index 6c6376c15d..250be69b5f 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "release-banner": "tsx src/ghes-releases/scripts/release-banner.ts", "repo-sync": "./src/workflows/local-repo-sync.sh", "reusables": "tsx src/content-render/scripts/reusables-cli.ts", + "resolve-liquid": "tsx src/content-render/scripts/resolve-liquid.ts", "rendered-content-link-checker": "tsx src/links/scripts/rendered-content-link-checker.ts", "rendered-content-link-checker-cli": "tsx src/links/scripts/rendered-content-link-checker-cli.ts", "rest-dev": "tsx src/rest/scripts/update-files.ts", diff --git a/src/content-render/scripts/resolve-liquid.ts b/src/content-render/scripts/resolve-liquid.ts new file mode 100644 index 0000000000..b76905842b --- /dev/null +++ b/src/content-render/scripts/resolve-liquid.ts @@ -0,0 +1,822 @@ +/* + * @purpose Writer tool + * @description Resolve and unresolve Liquid data references in content files + */ +// Usage: npm run resolve-liquid -- resolve --paths content/pull-requests/about.md +// Usage: npm run resolve-liquid -- restore --paths content/pull-requests/about.md + +import { Command } from 'commander' +import fs from 'fs' +import path from 'path' +import yaml from 'js-yaml' +import chalk from 'chalk' + +// Type definitions +interface ResolveOptions { + paths: string[] + verbose?: boolean + markers?: boolean + dryRun?: boolean + reusablesOnly?: boolean + variablesOnly?: boolean + recursive?: boolean +} + +interface LiquidReference { + original: string + type: 'reusable' | 'variable' + path: string + startIndex: number + endIndex: number +} + +// Constants +const ROOT = process.env.ROOT || '.' +const DATA_ROOT = path.resolve(path.join(ROOT, 'data')) +const REUSABLES_ROOT = path.join(DATA_ROOT, 'reusables') +const VARIABLES_ROOT = path.join(DATA_ROOT, 'variables') + +// Regex pattern to match resolved content blocks +const RESOLVED_PATTERN = + /(.+?)/gs + +/** + * Get the file path for a data reference + */ +function getDataFilePath(type: 'reusable' | 'variable', dataPath: string): string { + if (type === 'reusable') { + return path.join(REUSABLES_ROOT, `${dataPath.replace(/\./g, '/')}.md`) + } else { + const fileName = dataPath.split('.')[0] + return path.join(VARIABLES_ROOT, `${fileName}.yml`) + } +} + +const program = new Command() + +program + .name('resolve-liquid') + .description('Tools to resolve and unresolve Liquid data references in content files') + +program + .command('resolve') + .description('Resolve {% data reusables %} and {% data variables %} statements to their content') + .option('--paths ', 'Content file paths to process', []) + .option('-v, --verbose', 'Verbose output', false) + .option('--no-markers', 'Skip HTML comment markers (output cannot be restored to Liquid)', true) + .option('--reusables-only', 'Process only reusables (skip variables)', false) + .option('--variables-only', 'Process only variables (skip reusables)', false) + .option('-r, --recursive', 'Keep resolving until no references remain (max 10 iterations)', false) + .action((options: ResolveOptions) => resolveReferences(options)) + +program + .command('restore') + .description('Restore original Liquid statements from HTML comment markers') + .option('--paths ', 'Content file paths to process', []) + .option('-v, --verbose', 'Verbose output', false) + .option('--reusables-only', 'Process only reusables (skip variables)', false) + .option('--variables-only', 'Process only variables (skip reusables)', false) + .action((options: ResolveOptions) => restoreReferences(options)) + +program.parse() + +/** + * Get allowed types based on command options + */ +function getAllowedTypes(options: ResolveOptions): Array<'reusable' | 'variable'> { + if (options.reusablesOnly && options.variablesOnly) { + console.log( + chalk.yellow( + 'Warning: Both --reusables-only and --variables-only specified. Processing both types.', + ), + ) + return ['reusable', 'variable'] + } + + if (options.reusablesOnly) { + return ['reusable'] + } + + if (options.variablesOnly) { + return ['variable'] + } + + // Default: process both types + return ['reusable', 'variable'] +} + +/** + * Resolve Liquid data references in content files + */ +async function resolveReferences(options: ResolveOptions): Promise { + const { paths, verbose, markers, recursive } = options + // markers will be true by default, false when --no-markers is used + const withMarkers = markers !== false + const allowedTypes = getAllowedTypes(options) + const maxIterations = 10 // Safety limit for recursive resolution + + if (paths.length === 0) { + console.error(chalk.red('Error: No paths provided. Use --paths option.')) + process.exit(1) + } + + for (const filePath of paths) { + try { + let iteration = 0 + let hasRemainingRefs = true + + while (hasRemainingRefs && iteration < maxIterations) { + iteration++ + + if (verbose && recursive && iteration > 1) { + console.log(chalk.blue(`Processing (iteration ${iteration}): ${filePath}`)) + } else if (verbose) { + console.log(chalk.blue(`Processing: ${filePath}`)) + if (allowedTypes.length < 2) { + console.log(chalk.dim(` Only processing: ${allowedTypes.join(', ')}`)) + } + } + + if (!fs.existsSync(filePath)) { + console.error(chalk.red(`Error: File not found: ${filePath}`)) + break + } + + const content = fs.readFileSync(filePath, 'utf-8') + const resolvedContent = await resolveFileContent( + content, + filePath, + verbose, + withMarkers, + allowedTypes, + ) + + if (resolvedContent !== content) { + fs.writeFileSync(filePath, resolvedContent, 'utf-8') + if (iteration === 1 || !recursive) { + console.log(chalk.green(`✓ Resolved references in: ${filePath}`)) + } + } else { + if (verbose && iteration === 1) { + console.log(chalk.gray(` No references found in: ${filePath}`)) + } + } + + // Check for remaining references + const remainingRefs = findLiquidReferences(resolvedContent, allowedTypes) + hasRemainingRefs = remainingRefs.length > 0 + + if (!recursive) { + // Non-recursive mode: show remaining references and break + if (hasRemainingRefs) { + console.log( + chalk.yellow( + `👉 FYI: ${remainingRefs.length} Liquid reference(s) remain in ${filePath}`, + ), + ) + console.log( + chalk.yellow( + ' These come from reusables/variables that contain references to other reusables/variables', + ), + ) + console.log( + chalk.yellow(' Run the resolve command again to resolve them, or use --recursive'), + ) + if (verbose) { + for (const ref of remainingRefs) { + console.log(chalk.dim(` ${ref.original}`)) + } + } else { + console.log(chalk.dim(' Use --verbose to see the specific references')) + } + } + break + } + + if (hasRemainingRefs && iteration >= maxIterations) { + console.log( + chalk.yellow(`âš ī¸ Reached maximum iterations (${maxIterations}) for ${filePath}`), + ) + console.log( + chalk.yellow( + ` ${remainingRefs.length} reference(s) still remain - there may be circular references`, + ), + ) + if (verbose) { + for (const ref of remainingRefs) { + console.log(chalk.dim(` ${ref.original}`)) + } + } + } else if (!hasRemainingRefs && iteration > 1) { + console.log( + chalk.green( + `✓ Fully resolved all references in: ${filePath} (${iteration} iterations)`, + ), + ) + } + } + } catch (error: any) { + console.error(chalk.red(`Error processing ${filePath}: ${error.message}`)) + } + } +} + +/** + * Restore content by restoring original Liquid statements from HTML comments + */ +async function restoreReferences(options: ResolveOptions): Promise { + const { paths, verbose } = options + const allowedTypes = getAllowedTypes(options) + + if (paths.length === 0) { + console.error(chalk.red('Error: No paths provided. Use --paths option.')) + process.exit(1) + } + + for (const filePath of paths) { + try { + if (verbose) { + console.log(chalk.blue(`Restoring: ${filePath}`)) + if (allowedTypes.length < 2) { + console.log(chalk.dim(` Only processing: ${allowedTypes.join(', ')}`)) + } + } + + if (!fs.existsSync(filePath)) { + console.error(chalk.red(`Error: File not found: ${filePath}`)) + continue + } + + const content = fs.readFileSync(filePath, 'utf-8') + + // Check for content edits before restoring + const hasEdits = await detectContentEdits(content, verbose, allowedTypes) + if (hasEdits) { + console.log( + chalk.blue( + `â„šī¸ Info: ${filePath} contains resolved references that will be preserved by updating data files`, + ), + ) + if (!verbose) { + console.log(chalk.dim(' Use --verbose to see details of the edits')) + } + + // Update data files with the edited content before restoring + const updatedDataFiles = updateDataFiles(filePath, verbose, false, allowedTypes) + + // Automatically restore any updated data files back to liquid tags + if (updatedDataFiles.length > 0) { + console.log(chalk.blue(' Restoring updated data files back to liquid tags...')) + for (const dataFile of updatedDataFiles) { + try { + const dataContent = fs.readFileSync(dataFile, 'utf-8') + const restoredDataContent = restoreFileContent(dataContent, verbose, allowedTypes) + if (restoredDataContent !== dataContent) { + fs.writeFileSync(dataFile, restoredDataContent, 'utf-8') + if (verbose) { + console.log(chalk.green(` Restored: ${dataFile}`)) + } + } + } catch (error) { + if (verbose) { + console.log(chalk.yellow(` Could not restore ${dataFile}: ${error}`)) + } + } + } + } + } + + // Always restore the main file content regardless of edits + const restoredContent = restoreFileContent(content, verbose, allowedTypes) + + if (restoredContent !== content) { + fs.writeFileSync(filePath, restoredContent, 'utf-8') + console.log(chalk.green(`✓ Restored references in: ${filePath}`)) + } else { + console.log(chalk.gray(`No resolved references found in: ${filePath}`)) + } + } catch (error: any) { + console.error(chalk.red(`Error restoring ${filePath}: ${error.message}`)) + } + } +} + +/** + * Resolve all Liquid data references in file content + */ +async function resolveFileContent( + content: string, + filePath: string, + verbose?: boolean, + withMarkers?: boolean, + allowedTypes?: Array<'reusable' | 'variable'>, +): Promise { + const references = findLiquidReferences(content, allowedTypes) + + if (references.length === 0) { + return content + } + + let resolvedContent = content + let offset = 0 + + for (const ref of references) { + try { + const resolvedValue = await resolveLiquidReference(ref, verbose) + + if (resolvedValue !== null) { + const originalText = ref.original + let replacement: string + + if (withMarkers) { + const commentStart = `` + const commentEnd = `` + replacement = `${commentStart}${resolvedValue}${commentEnd}` + } else { + replacement = resolvedValue + } + + const startPos = ref.startIndex + offset + const endPos = ref.endIndex + offset + + resolvedContent = + resolvedContent.substring(0, startPos) + replacement + resolvedContent.substring(endPos) + + offset += replacement.length - originalText.length + + if (verbose) { + console.log(chalk.green(` Resolved: ${ref.type}s.${ref.path}`)) + } + } else { + if (verbose) { + console.log(chalk.yellow(` Warning: Could not resolve ${ref.type}s.${ref.path}`)) + } + } + } catch (error: any) { + if (verbose) { + console.log(chalk.red(` Error resolving ${ref.type}s.${ref.path}: ${error.message}`)) + } + } + } + + // Note: Remaining reference detection is now handled in resolveReferences function for recursive mode + + return resolvedContent +} + +/** + * Detect if resolved content has been edited by comparing with original data + */ +async function detectContentEdits( + content: string, + verbose?: boolean, + allowedTypes?: Array<'reusable' | 'variable'>, +): Promise { + let hasEdits = false + + let match + while ((match = RESOLVED_PATTERN.exec(content)) !== null) { + const [, type, dataPath, resolvedContent] = match + const refType = type as 'reusable' | 'variable' + + // Only check if this type is allowed + if (!allowedTypes || allowedTypes.includes(refType)) { + try { + // Load the original content from data files + const originalContent = loadDataValue(refType, dataPath.trim()) + + if (originalContent !== null) { + // Compare against the original content directly, not re-resolved + // This avoids nested resolution issues that cause false positives + const currentContent = resolvedContent.trim() + + if (currentContent !== originalContent.trim()) { + hasEdits = true + if (verbose) { + console.log(chalk.yellow(` Content has been edited: ${type}s.${dataPath}`)) + console.log( + chalk.dim(' Original:'), + originalContent.substring(0, 50) + (originalContent.length > 50 ? '...' : ''), + ) + console.log( + chalk.dim(' Current: '), + currentContent.substring(0, 50) + (currentContent.length > 50 ? '...' : ''), + ) + } + } + } + } catch (error) { + if (verbose) { + console.log(chalk.yellow(` Could not verify content for ${type}s.${dataPath}: ${error}`)) + } + } + } + } + + return hasEdits +} + +/** + * Load data value from file system (helper for edit detection) + */ +function loadDataValue(type: 'reusable' | 'variable', dataPath: string): string | null { + try { + const targetPath = getDataFilePath(type, dataPath) + + if (!fs.existsSync(targetPath)) { + return null + } + + if (type === 'reusable') { + const content = fs.readFileSync(targetPath, 'utf8') + // Remove any frontmatter if present (same as resolveReusable) + const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') + return contentWithoutFrontmatter.trim() + } else { + const yamlContent = fs.readFileSync(targetPath, 'utf8') + const data = yaml.load(yamlContent) as any + + // Navigate to the nested property + const pathParts = dataPath.split('.') + let current = data + for (let i = 1; i < pathParts.length; i++) { + if (current && typeof current === 'object' && pathParts[i] in current) { + current = current[pathParts[i]] + } else { + return null + } + } + + return typeof current === 'string' ? current.trim() : String(current).trim() + } + } catch { + // Silently return null for any errors + } + return null +} + +/** + * Restore content by restoring original Liquid statements + */ +function restoreFileContent( + content: string, + verbose?: boolean, + allowedTypes?: Array<'reusable' | 'variable'>, +): string { + return content.replace(RESOLVED_PATTERN, (match, type, dataPath) => { + const refType = type as 'reusable' | 'variable' + + // Only restore if this type is allowed + if (!allowedTypes || allowedTypes.includes(refType)) { + const originalLiquid = `{% data ${type}s.${dataPath} %}` + + if (verbose) { + console.log(chalk.green(` Restored: ${type}s.${dataPath}`)) + } + + return originalLiquid + } + + // Return unchanged if type is not allowed + return match + }) +} + +/** + * Update data files with content from resolved blocks + * Returns array of file paths that were updated + */ +function updateDataFiles( + filePath: string, + verbose?: boolean, + dryRun?: boolean, + allowedTypes?: Array<'reusable' | 'variable'>, +): string[] { + const content = fs.readFileSync(filePath, 'utf8') + const updates = extractDataUpdates(content, allowedTypes) + + if (updates.length === 0) { + if (verbose) { + console.log(chalk.yellow(' No content changes found')) + } + return [] + } + + // Group updates by file path + const updatesByFile = new Map() + for (const update of updates) { + const key = `${update.type}:${update.path}` + if (!updatesByFile.has(key)) { + updatesByFile.set(key, []) + } + updatesByFile.get(key)!.push(update.newContent) + } + + const updatedFiles: string[] = [] + + // Apply updates to each data file + for (const [key, contents] of updatesByFile) { + const [type, dataPath] = key.split(':') + const targetFilePath = applyDataUpdates( + type as 'reusable' | 'variable', + dataPath, + contents, + verbose, + dryRun, + ) + if (targetFilePath) { + updatedFiles.push(targetFilePath) + } + } + + return updatedFiles +} + +/** + * Extract data updates from resolved content blocks + */ +function extractDataUpdates( + content: string, + allowedTypes?: Array<'reusable' | 'variable'>, +): Array<{ type: 'reusable' | 'variable'; path: string; newContent: string }> { + const updates: Array<{ type: 'reusable' | 'variable'; path: string; newContent: string }> = [] + + let match + while ((match = RESOLVED_PATTERN.exec(content)) !== null) { + const [, type, dataPath, resolvedContent] = match + const refType = type as 'reusable' | 'variable' + + // Only include if this type is allowed + if (!allowedTypes || allowedTypes.includes(refType)) { + // Check if this content was actually changed before including it + try { + const originalContent = loadDataValue(refType, dataPath.trim()) + if (originalContent !== null && resolvedContent.trim() !== originalContent.trim()) { + // Only add to updates if content was actually changed + updates.push({ + type: refType, + path: dataPath.trim(), + newContent: resolvedContent.trim(), + }) + } + } catch { + // If we can't verify, assume it was changed to be safe + updates.push({ + type: refType, + path: dataPath.trim(), + newContent: resolvedContent.trim(), + }) + } + } + } + + return updates +} + +/** + * Apply updates to a specific data file + * Returns the file path if file was updated, null otherwise + */ +function applyDataUpdates( + type: 'reusable' | 'variable', + dataPath: string, + contents: string[], + verbose?: boolean, + dryRun?: boolean, +): string | null { + const targetPath = getDataFilePath(type, dataPath) + + // Check if file exists + if (!fs.existsSync(targetPath)) { + if (verbose) { + console.log(chalk.red(` Error: Data file not found: ${targetPath}`)) + } + return null + } + + if (dryRun) { + if (verbose) { + console.log(chalk.blue(` Would update: ${targetPath}`)) + if (contents.length > 1) { + console.log( + chalk.yellow( + ` Warning: Multiple content blocks found for ${dataPath}, would use first one`, + ), + ) + } + console.log( + chalk.dim( + ` New content: ${contents[0].substring(0, 100)}${contents[0].length > 100 ? '...' : ''}`, + ), + ) + } else { + console.log(chalk.green(` Updated: ${targetPath}`)) + } + return targetPath // Return path even in dry run + } + + try { + if (type === 'reusable') { + // For reusables, replace entire file content + if (contents.length > 1) { + console.log( + chalk.yellow(` Warning: Multiple content blocks found for ${dataPath}, using first one`), + ) + } + + // Preserve original file's newline behavior + const originalContent = fs.readFileSync(targetPath, 'utf8') + const hasTrailingNewline = originalContent.endsWith('\n') + const newContent = + hasTrailingNewline && !contents[0].endsWith('\n') ? `${contents[0]}\n` : contents[0] + + fs.writeFileSync(targetPath, newContent) + if (verbose) { + console.log(chalk.green(` Updated: ${targetPath}`)) + } + } else { + // For variables, update YAML structure + const yamlContent = fs.readFileSync(targetPath, 'utf8') + const data = yaml.load(yamlContent) as any + + // Navigate to the nested property + const pathParts = dataPath.split('.') + const propertyPath = pathParts.slice(1) // Skip the file name + + let current = data + for (let i = 0; i < propertyPath.length - 1; i++) { + if (!current[propertyPath[i]]) { + current[propertyPath[i]] = {} + } + current = current[propertyPath[i]] + } + + // Update the final property + const finalKey = propertyPath[propertyPath.length - 1] + if (contents.length > 1) { + console.log( + chalk.yellow(` Warning: Multiple content blocks found for ${dataPath}, using first one`), + ) + } + current[finalKey] = contents[0] + + // Preserve original file's newline behavior for YAML + const hasTrailingNewline = yamlContent.endsWith('\n') + const yamlOutput = yaml.dump(data) + const finalYaml = + hasTrailingNewline && !yamlOutput.endsWith('\n') ? `${yamlOutput}\n` : yamlOutput + + // Write back to file + fs.writeFileSync(targetPath, finalYaml) + if (verbose) { + console.log(chalk.green(` Updated: ${targetPath}`)) + } + } + return targetPath + } catch (error: any) { + if (verbose) { + console.log(chalk.red(` Error updating ${targetPath}: ${error.message}`)) + } + return null + } +} + +/** + * Find all Liquid data references in content + */ +function findLiquidReferences( + content: string, + allowedTypes?: Array<'reusable' | 'variable'>, +): LiquidReference[] { + const references: LiquidReference[] = [] + const types = allowedTypes || ['reusable', 'variable'] + + // Pattern to match {% data reusables.path %} and {% data variables.path %} + const liquidPattern = /{%\s*data\s+(reusables|variables)\.([^%]+)\s*%}/g + + let match + while ((match = liquidPattern.exec(content)) !== null) { + const [original, type, dataPath] = match + const refType = type.slice(0, -1) as 'reusable' | 'variable' // Remove 's' from end + + // Only include if this type is allowed + if (types.includes(refType)) { + references.push({ + original, + type: refType, + path: dataPath.trim(), + startIndex: match.index, + endIndex: match.index + original.length, + }) + } + } + + return references +} + +/** + * Resolve a single Liquid data reference to its content + */ +async function resolveLiquidReference( + ref: LiquidReference, + verbose?: boolean, +): Promise { + try { + if (ref.type === 'reusable') { + return await resolveReusable(ref.path, verbose) + } else if (ref.type === 'variable') { + return await resolveVariable(ref.path, verbose) + } + } catch (error: any) { + if (verbose) { + console.log(chalk.red(` Error resolving ${ref.type}: ${error.message}`)) + } + } + + return null +} + +/** + * Resolve a reusable reference by reading the markdown file + */ +async function resolveReusable(reusablePath: string, verbose?: boolean): Promise { + const filePath = getDataFilePath('reusable', reusablePath) + + if (!fs.existsSync(filePath)) { + if (verbose) { + console.log(chalk.yellow(` Reusable not found: ${reusablePath}`)) + } + return null + } + + try { + const content = fs.readFileSync(filePath, 'utf-8') + // Remove any frontmatter if present + const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') + return contentWithoutFrontmatter.trim() + } catch (error: any) { + if (verbose) { + console.log(chalk.yellow(` Error reading reusable ${reusablePath}: ${error.message}`)) + } + return null + } +} + +/** + * Resolve a variable reference by reading from YAML files + */ +async function resolveVariable(variablePath: string, verbose?: boolean): Promise { + const pathParts = variablePath.split('.') + + if (pathParts.length < 2) { + if (verbose) { + console.log(chalk.yellow(` Invalid variable path: ${variablePath}`)) + } + return null + } + + const filePath = getDataFilePath('variable', variablePath) + + if (!fs.existsSync(filePath)) { + if (verbose) { + console.log(chalk.yellow(` Variable file not found: ${filePath}`)) + } + return null + } + + try { + const yamlContent = fs.readFileSync(filePath, 'utf-8') + const data = yaml.load(yamlContent) as Record + + // Navigate through the key path to find the value + const [, ...keyPath] = pathParts // Skip filename, get remaining path + let value: any = data + for (const key of keyPath) { + if (value && typeof value === 'object' && key in value) { + value = value[key] + } else { + if (verbose) { + console.log(chalk.yellow(` Variable key not found: ${variablePath}`)) + } + return null + } + } + + // Convert value to string + if (typeof value === 'string') { + return value + } else if (value !== null && value !== undefined) { + return String(value) + } else { + if (verbose) { + console.log(chalk.yellow(` Variable value is null/undefined: ${variablePath}`)) + } + return null + } + } catch (error: any) { + if (verbose) { + console.log(chalk.yellow(` Error parsing variable ${variablePath}: ${error.message}`)) + } + return null + } +} diff --git a/src/content-render/tests/resolve-liquid.ts b/src/content-render/tests/resolve-liquid.ts new file mode 100644 index 0000000000..70e23a627a --- /dev/null +++ b/src/content-render/tests/resolve-liquid.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs/promises' +import path from 'path' +import { execSync } from 'child_process' + +const rootDir = path.join(__dirname, '../../..') +const testContentDir = path.join(rootDir, 'content/test-integration') + +describe('resolve-liquid script integration tests', () => { + vi.setConfig({ testTimeout: 60 * 1000 }) + + beforeEach(async () => { + // Create test directory + await fs.mkdir(testContentDir, { recursive: true }) + }) + + afterEach(async () => { + // Clean up test files + await fs.rm(testContentDir, { recursive: true, force: true }) + }) + + // Helper function to run script commands + async function runResolveScript(args: string): Promise<{ output: string; exitCode: number }> { + let output = '' + let exitCode = 0 + + try { + output = execSync(`tsx src/content-render/scripts/resolve-liquid.ts ${args}`, { + encoding: 'utf8', + cwd: rootDir, + stdio: 'pipe', + timeout: 30000, + }) + } catch (error: any) { + output = error.stdout + error.stderr + exitCode = error.status || 1 + } + + return { output, exitCode } + } + + test('resolve command should complete successfully with basic content', async () => { + // Create a test file with liquid reference + const testFile = path.join(testContentDir, 'basic-test.md') + const testContent = `--- +title: Test +--- + +This uses {% data variables.product.prodname_dotcom %} in content. +` + + await fs.writeFile(testFile, testContent) + + const { output, exitCode } = await runResolveScript(`resolve --paths "${testFile}"`) + + // Should complete without error + expect(exitCode, `Script failed with output: ${output}`).toBe(0) + expect(output.length).toBeGreaterThan(0) + + // Check that the file was modified + const resolvedContent = await fs.readFile(testFile, 'utf8') + expect(resolvedContent).not.toBe(testContent) + expect(resolvedContent).toContain('GitHub') // Should resolve to actual fixture value + }) + + test('restore command should complete successfully', async () => { + const testFile = path.join(testContentDir, 'restore-test.md') + const originalContent = `--- + title: Test + --- + +This uses {% data variables.product.prodname_dotcom %} in content. +` + + await fs.writeFile(testFile, originalContent) + + // First resolve + await runResolveScript(`resolve --paths "${testFile}"`) + + // Then restore + const { output, exitCode } = await runResolveScript(`restore --paths "${testFile}"`) + + expect(exitCode, `Restore script failed with output: ${output}`).toBe(0) + expect(output.length).toBeGreaterThan(0) + + // Should be back to original liquid tags + const restoredContent = await fs.readFile(testFile, 'utf8') + expect(restoredContent).toContain('{% data variables.product.prodname_dotcom %}') + expect(restoredContent).not.toContain('GitHub') + }) + + test('help command should display usage information', async () => { + const { output, exitCode } = await runResolveScript('resolve --help') + + expect(exitCode, `Help command failed with output: ${output}`).toBe(0) + expect(output).toMatch(/resolve|usage|help|command/i) + }) +})