Script to resolve Liquid data references in a content file (#58831)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
822
src/content-render/scripts/resolve-liquid.ts
Normal file
822
src/content-render/scripts/resolve-liquid.ts
Normal file
@@ -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 =
|
||||
/<!-- begin resolved (reusable|variable)s\.([^>]+) -->(.+?)<!-- end resolved \1s\.\2 -->/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 <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 <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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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 = `<!-- begin resolved ${ref.type}s.${ref.path} -->`
|
||||
const commentEnd = `<!-- end resolved ${ref.type}s.${ref.path} -->`
|
||||
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<boolean> {
|
||||
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<string, string[]>()
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<string, any>
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
98
src/content-render/tests/resolve-liquid.ts
Normal file
98
src/content-render/tests/resolve-liquid.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user