| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import fs from 'fs' |
| import path from 'path' |
|
|
| import { program } from 'commander' |
| import chalk from 'chalk' |
| import yaml from 'js-yaml' |
|
|
| import { updateInternalLinks } from '@/links/lib/update-internal-links' |
| import frontmatter from '@/frame/lib/read-frontmatter' |
| import walkFiles from '@/workflows/walk-files' |
|
|
| program |
| .description('Update internal links in content files') |
| .option('--verbose', 'Enable additional logging') |
| .option('--debug', "Don't hide any errors") |
| .option('--dry-run', "Don't actually write changes to disk") |
| .option('--dont-set-autotitle', "Do NOT transform the link text to 'AUTOTITLE' (if applicable)") |
| .option('--dont-fix-href', 'Do NOT fix the link href value (if necessary)') |
| .option('--check', 'Exit and fail if it found something to fix') |
| .option('--aggregate-stats', 'Display aggregate numbers about all possible changes') |
| .option('--strict', "Throw an error (instead of a warning) if a link can't be processed") |
| .option('--exclude [paths...]', 'Specific files to exclude') |
| .arguments('[files-or-directories...]') |
| .parse(process.argv) |
|
|
| main(program.args, program.opts()) |
|
|
| type Options = { |
| verbose: boolean |
| debug: boolean |
| dryRun: boolean |
| dontSetAutotitle: boolean |
| dontFixHref: boolean |
| check: boolean |
| aggregateStats: boolean |
| strict: boolean |
| exclude: string[] |
| filesOrDirectories?: string[] |
| } |
| async function main(files: string[], opts: Options) { |
| const { debug } = opts |
|
|
| const excludeFilePaths = new Set(opts.exclude || []) |
|
|
| try { |
| if (opts.check && !opts.dryRun) { |
| throw new Error("Can't use --check without --dry-run") |
| } |
|
|
| const actualFiles = [] |
| if (!files.length) { |
| files.push('content', 'data') |
| } |
| for (const file of files) { |
| if ( |
| !( |
| file.startsWith('content') || |
| file.startsWith('data') || |
| file.startsWith('src/fixtures/fixtures') |
| ) |
| ) { |
| throw new Error(`${file} must be a content or data filepath`) |
| } |
| if (!fs.existsSync(file)) { |
| throw new Error(`${file} does not exist`) |
| } |
| if (fs.lstatSync(file).isDirectory()) { |
| actualFiles.push( |
| ...walkFiles(file, ['.md', '.yml']).filter((p) => { |
| return !excludeFilePaths.has(p) |
| }), |
| ) |
| } else if (!excludeFilePaths.has(file)) { |
| actualFiles.push(file) |
| } |
| } |
| if (!actualFiles.length) { |
| throw new Error(`No files found in ${files}`) |
| } |
|
|
| const { verbose } = opts |
|
|
| if (verbose) { |
| console.log(chalk.bold(`Updating internal links in ${actualFiles.length} found files...`)) |
| } |
|
|
| |
| const options = { |
| setAutotitle: !opts.dontSetAutotitle, |
| fixHref: !opts.dontFixHref, |
| verbose, |
| strict: !!opts.strict, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| const results = await updateInternalLinks(actualFiles, options) |
|
|
| let exitCheck = 0 |
| for (const { |
| file, |
| rawContent, |
| content, |
| newContent, |
| replacements, |
| data, |
| newData, |
| warnings, |
| } of results) { |
| const differentContent = content !== newContent |
| const differentData = !equalObject(data, newData) |
| if (differentContent || differentData) { |
| if (verbose || opts.check) { |
| if (opts.check) { |
| exitCheck++ |
| } |
| if (verbose) { |
| console.log( |
| opts.dryRun ? 'Would change...' : 'Will change...', |
| chalk.bold(file), |
| differentContent |
| ? chalk.dim(`${replacements.length} change${replacements.length !== 1 ? 's' : ''}`) |
| : '', |
| differentData ? chalk.dim('different data') : '', |
| ) |
| for (const { asMarkdown, newAsMarkdown, line, column } of replacements) { |
| console.log(' ', chalk.red(asMarkdown)) |
| console.log(' ', chalk.green(newAsMarkdown)) |
| console.log(' ', chalk.dim(`line ${line} column ${column}`)) |
| console.log('') |
| } |
| printObjectDifference(data, newData, rawContent) |
| } |
| } |
| if (!opts.dryRun) { |
| if (file.endsWith('.yml')) { |
| fs.writeFileSync(file, yaml.dump(newData), 'utf-8') |
| } else { |
| |
| |
| fs.writeFileSync(file, frontmatter.stringify(newContent || '', newData || {}), 'utf-8') |
| } |
| } |
| } |
| if (warnings.length) { |
| console.log('Warnings...', chalk.bold(file)) |
| for (const { warning, asMarkdown, line, column } of warnings) { |
| console.log(' ', chalk.yellow(asMarkdown)) |
| console.log(' ', chalk.dim(`line ${line} column ${column}, ${warning}`)) |
| console.log('') |
| } |
| } |
| } |
|
|
| if (opts.aggregateStats) { |
| const countFiles = results.length |
| const countChangedFiles = new Set(results.filter((result) => result.replacements.length > 0)) |
| .size |
| const countReplacements = results.reduce((prev, next) => prev + next.replacements.length, 0) |
| console.log('Number of files checked:'.padEnd(30), chalk.bold(countFiles.toLocaleString())) |
| console.log( |
| 'Number of files changed:'.padEnd(30), |
| chalk.bold(countChangedFiles.toLocaleString()), |
| ) |
| console.log( |
| 'Sum number of replacements:'.padEnd(30), |
| chalk.bold(countReplacements.toLocaleString()), |
| ) |
|
|
| const countWarnings = results.reduce((prev, next) => prev + next.warnings.length, 0) |
| const countWarningFiles = new Set(results.filter((result) => result.warnings.length > 0)).size |
| console.log( |
| 'Number of files with warnings:'.padEnd(30), |
| chalk.bold(countWarningFiles.toLocaleString()), |
| ) |
| console.log('Sum number of warnings:'.padEnd(30), chalk.bold(countWarnings.toLocaleString())) |
|
|
| if (countWarnings > 0) { |
| console.log(chalk.yellow('\nNote! Warnings can currently not be automatically fixed.')) |
| console.log('Manually edit heeded warnings and run the script again to update.') |
| } |
|
|
| if (countChangedFiles > 0) { |
| countByTree(results) |
| } |
| } |
|
|
| if (exitCheck) { |
| if (verbose) { |
| console.log(chalk.yellow(`More than one file would become different. Unsuccessful check.`)) |
| } |
| process.exit(exitCheck) |
| } else if (opts.check) { |
| console.log(chalk.green('No changes needed or necessary. 🌈')) |
| } |
| } catch (err: any) { |
| if (debug) { |
| throw err |
| } |
| console.error(chalk.red(err.toString())) |
| process.exit(1) |
| } |
| } |
|
|
| function printObjectDifference( |
| objFrom: Record<string, any>, |
| objTo: Record<string, any>, |
| rawContent: string, |
| parentKey = '', |
| ) { |
| |
| |
| for (const [key, value] of Object.entries(objFrom)) { |
| const combinedKey = `${parentKey}.${key}` |
| if (Array.isArray(value) && !equalArray(value, objTo[key])) { |
| const printedKeys = new Set() |
| for (let i = 0; i < value.length; i++) { |
| const entry = value[i] |
| |
| if (isObject(entry)) { |
| printObjectDifference(entry, objTo[key][i], rawContent, combinedKey) |
| } else { |
| if (entry !== objTo[key][i]) { |
| if (!printedKeys.has(combinedKey)) { |
| console.log(`In frontmatter key: ${chalk.bold(combinedKey)}`) |
| printedKeys.add(combinedKey) |
| } |
| console.log(chalk.red(`- ${entry}`)) |
| console.log(chalk.green(`+ ${objTo[key][i]}`)) |
| const needle = new RegExp(`- ${entry}\\b`) |
| const index = rawContent.split(/\n/g).findIndex((line) => needle.test(line)) |
| console.log(' ', chalk.dim(`line ${(index && index + 1) || 'unknown'}`)) |
| } |
| } |
| } |
| } else if (typeof value === 'object' && value !== null) { |
| printObjectDifference(value, objTo[key], rawContent, combinedKey) |
| } |
| } |
| } |
|
|
| |
| function equalObject(obj1: Record<string, any>, obj2: Record<string, any>) { |
| if (!equalSet(new Set(Object.keys(obj1)), new Set(Object.keys(obj2)))) { |
| return false |
| } |
| for (const [key, value] of Object.entries(obj1)) { |
| if (Array.isArray(value)) { |
| |
| if (value.length !== obj2[key].length) return false |
| let i = 0 |
| for (const each of value) { |
| if (isObject(each)) { |
| if (!equalObject(each, obj2[key][i])) { |
| return false |
| } |
| } else { |
| if (each !== obj2[key][i]) { |
| return false |
| } |
| } |
| i++ |
| } |
| } else if (isObject(value)) { |
| if (!equalObject(value, obj2[key])) { |
| return false |
| } |
| } else if (value !== obj2[key]) { |
| return false |
| } |
| } |
| return true |
| } |
|
|
| function isObject(thing: any) { |
| return typeof thing === 'object' && thing !== null && !Array.isArray(thing) |
| } |
|
|
| function equalSet(set1: Set<any>, set2: Set<any>) { |
| return set1.size === set2.size && [...set1].every((x) => set2.has(x)) |
| } |
|
|
| function equalArray(arr1: any[], arr2: any[]) { |
| return arr1.length === arr2.length && arr1.every((item, i) => item === arr2[i]) |
| } |
|
|
| function countByTree( |
| results: { |
| data: { |
| [key: string]: any |
| } |
| content: string |
| rawContent: string |
| newContent: string |
| replacements: any[] |
| warnings: any[] |
| newData: { |
| [key: string]: any |
| } |
| file: string |
| }[], |
| ) { |
| const files: Record<string, number> = {} |
| const changes: Record<string, number> = {} |
| for (const { file, replacements } of results) { |
| const split = path.dirname(file).split(path.sep) |
| while (split.length > 1) { |
| const parent = split.slice(1).join(path.sep) |
| files[parent] = (replacements.length > 0 ? 1 : 0) + (files[parent] || 0) |
| changes[parent] = replacements.length + (changes[parent] || 0) |
| split.pop() |
| } |
| } |
| const longest = Math.max( |
| ...Object.keys(changes).map((x: string) => Number(x.split(path.sep).at(-1)?.length)), |
| ) |
| const padding = longest + 10 |
| const col0 = 'TREE' |
| const col1 = 'FILES ' |
| console.log('\n') |
| console.log(`${col0.padEnd(padding)}${col1} CHANGES`) |
| for (const each of Object.keys(changes).sort()) { |
| if (!changes[each]) continue |
| const split = each.split(path.sep) |
| const last = split.at(-1) |
| const indentation = split.length - 1 |
| const indentationPad = indentation ? `${' '.repeat(indentation)} ↳ ` : '' |
| console.log( |
| `${indentationPad}${last?.padEnd(padding - indentationPad.length)} ${String( |
| files[each], |
| ).padEnd(col1.length)} ${changes[each]}`, |
| ) |
| } |
| } |
|
|