193 lines
6.4 KiB
JavaScript
Executable File
193 lines
6.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
// [start-readme]
|
|
//
|
|
// Run this script to update content's internal links.
|
|
// It can correct the title part or the URL part or both.
|
|
//
|
|
// Best way to understand how to use it is to run it with `--help`.
|
|
//
|
|
// [end-readme]
|
|
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
|
|
import { program } from 'commander'
|
|
import chalk from 'chalk'
|
|
|
|
import { updateInternalLinks } from '../lib/update-internal-links.js'
|
|
import frontmatter from '../lib/read-frontmatter.js'
|
|
import walkFiles from './helpers/walk-files.js'
|
|
|
|
program
|
|
.description('Update internal links in content files')
|
|
.option('-v, --verbose', 'Verbose outputs')
|
|
.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())
|
|
|
|
async function main(files, opts) {
|
|
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('tests/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}`)
|
|
}
|
|
|
|
// The updateInternalLinks doesn't use "negatives" for certain options
|
|
const options = {
|
|
setAutotitle: !opts.dontSetAutotitle,
|
|
fixHref: !opts.dontFixHref,
|
|
verbose: !!opts.verbose,
|
|
strict: !!opts.strict,
|
|
}
|
|
|
|
// Remember, updateInternalLinks() doesn't actually change the files
|
|
// on disk. That's the responsibility of the caller, i.e. this CLI script.
|
|
// The reason why is that updateInternalLinks() can then see if ALL
|
|
// improvements are going to work. For example, if you tried run
|
|
// it across 10 links and the 7th one had a corrupt broken link that
|
|
// can't be corrected, it needs to fail there and then instead of
|
|
// leaving 6 of the 10 files changed.
|
|
const results = await updateInternalLinks(actualFiles, options)
|
|
|
|
let exitCheck = 0
|
|
for (const { file, content, newContent, replacements, data } of results) {
|
|
if (content !== newContent) {
|
|
if (opts.verbose || opts.check) {
|
|
if (opts.check) {
|
|
exitCheck++
|
|
}
|
|
if (opts.verbose) {
|
|
console.log(
|
|
opts.dryRun ? 'Would change...' : 'Will change...',
|
|
chalk.bold(file),
|
|
chalk.dim(`${replacements.length} change${replacements.length !== 1 ? 's' : ''}`)
|
|
)
|
|
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('')
|
|
}
|
|
}
|
|
}
|
|
if (!opts.dryRun) {
|
|
// Remember the `content` and `newContent` is the "meat" of the
|
|
// Markdown page. To save it you need the frontmatter data too.
|
|
fs.writeFileSync(
|
|
file,
|
|
frontmatter.stringify(newContent, data, { lineWidth: 10000 }),
|
|
'utf-8'
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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())
|
|
)
|
|
|
|
countByTree(results)
|
|
}
|
|
|
|
if (exitCheck) {
|
|
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) {
|
|
if (debug) {
|
|
throw err
|
|
}
|
|
console.error(chalk.red(err.toString()))
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
function countByTree(results) {
|
|
const files = {}
|
|
const changes = {}
|
|
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) => 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]}`
|
|
)
|
|
}
|
|
}
|