markdown-code-example-inserter
Version:
Syncs code examples with markdown documentation.
204 lines (194 loc) • 6.95 kB
text/typescript
import {extractRelevantArgs, replaceWithWindowsPathIfNeeded} from '@augment-vir/node';
import {glob} from 'glob';
import {existsSync} from 'node:fs';
import {relative, resolve} from 'node:path';
import {createOrderedLogging} from '../augments/console.js';
import {MarkdownCodeExampleInserterError} from '../errors/markdown-code-example-inserter.error.js';
import {OutOfDateInsertedCodeError} from '../errors/out-of-date-inserted-code.error.js';
import {isCodeUpdated, writeAllExamples} from '../example-inserter/example-inserter.js';
/**
* Flag for setting a specific index file.
*
* @category Internals
*/
export const forceIndexTrigger = '--index';
const ignoreTrigger = '--ignore';
const silentTrigger = '--silent';
const checkOnlyTrigger = '--check';
/**
* All args for the CLI. This is automatically generated inside of {@link runCli} via
* {@link parseArgs}.
*
* @category Internals
*/
export type CliArgs = {
forceIndex: string | undefined;
silent: boolean;
checkOnly: boolean;
files: string[];
};
/**
* Parse all CLI args out of a raw CLI arg string.
*
* @category Internals
*/
export async function parseArgs(
rawArgs: ReadonlyArray<string>,
filePath: string,
): Promise<CliArgs> {
const args = extractRelevantArgs({
rawArgs,
binName: 'md-code',
fileName: filePath || 'cli.js',
});
let forceIndex: string | undefined = undefined;
let silent = false;
const inputFiles: string[] = [];
const globs: string[] = [];
const ignoreList: string[] = [];
let checkOnly = false;
let lastArgWasForceIndexTrigger = false;
let lastArgWasIgnoreTrigger = false;
args.forEach((arg) => {
if (arg === forceIndexTrigger && forceIndex != undefined) {
throw new MarkdownCodeExampleInserterError('Cannot have multiple index paths');
} else if (arg === forceIndexTrigger) {
lastArgWasForceIndexTrigger = true;
} else if (lastArgWasForceIndexTrigger) {
forceIndex = replaceWithWindowsPathIfNeeded(arg);
lastArgWasForceIndexTrigger = false;
} else if (arg === ignoreTrigger) {
lastArgWasIgnoreTrigger = true;
} else if (arg === checkOnlyTrigger && checkOnly) {
throw new MarkdownCodeExampleInserterError(
`${checkOnlyTrigger} accidentally duplicated in your inputs`,
);
} else if (arg === checkOnlyTrigger) {
checkOnly = true;
} else if (lastArgWasIgnoreTrigger) {
ignoreList.push(arg);
lastArgWasIgnoreTrigger = false;
} else if (arg === silentTrigger) {
silent = true;
} else if (existsSync(arg)) {
inputFiles.push(relative(process.cwd(), arg));
} else {
globs.push(arg);
}
});
await Promise.all(
globs.map(async (globString) => {
const paths = await glob(globString, {
ignore: [
...ignoreList,
'**/node_modules/**',
],
nodir: true,
follow: true,
nocase: true,
});
if (paths.length) {
inputFiles.push(
...paths.map((path) => {
return relative(process.cwd(), path);
}),
);
}
}),
);
const uniqueFiles = Array.from(new Set(inputFiles)).sort();
return {
forceIndex,
silent,
checkOnly,
files: uniqueFiles,
};
}
/**
* Run the `md-code` CLI.
*
* @category Main
*/
export async function runCli({
cwd = process.cwd(),
rawArgs,
cliFilePath = 'cli.js',
}: Readonly<{
rawArgs: ReadonlyArray<string>;
cwd?: string;
/** The file that is executing the CLI script. Used to determine CLI argument positioning. */
cliFilePath?: string;
}>) {
const args = await parseArgs(rawArgs, cliFilePath);
if (!args.files.length) {
throw new MarkdownCodeExampleInserterError('No markdown files given to insert code into.');
}
if (!args.silent) {
if (args.checkOnly) {
console.info(`Checking that code in markdown is up to date:`);
} else {
console.info(`Inserting code into markdown:`);
}
}
const errors: Error[] = [];
const orderedLog = createOrderedLogging();
await Promise.all(
args.files.map(async (relativeFilePath, index) => {
try {
if (args.checkOnly) {
const upToDate = await isCodeUpdated(
resolve(relativeFilePath),
cwd,
args.forceIndex,
);
if (upToDate) {
if (!args.silent) {
orderedLog(index, console.info, ` ${relativeFilePath}: up to date`);
}
} else {
if (!args.silent) {
orderedLog(
index,
console.error,
` ${relativeFilePath}: NOT up to date`,
);
}
errors.push(
new OutOfDateInsertedCodeError(
`${relativeFilePath} is not update to date.`,
),
);
}
} else {
if (!args.silent) {
orderedLog(index, console.info, ` ${relativeFilePath}`);
}
await writeAllExamples(resolve(relativeFilePath), cwd, args.forceIndex);
}
} catch (error) {
const errorWrapper = new MarkdownCodeExampleInserterError(
`Errored on ${relativeFilePath}: ${String(error)}`,
);
console.error(errorWrapper.message);
errors.push(errorWrapper);
}
}),
);
if (errors.length) {
if (
/** Weird necessary as cast to prevent TypeScript's over-exuberant type guarding. */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
(errors as Error[]).every(
(error): error is OutOfDateInsertedCodeError =>
error instanceof OutOfDateInsertedCodeError,
)
) {
throw new OutOfDateInsertedCodeError(
'Code in Markdown file(s) is out of date. Run without --check to update.',
);
} else {
errors.forEach((error) => console.error(error));
throw new MarkdownCodeExampleInserterError(`Code insertion into Markdown failed.`);
}
}
}