From f2ebf8b3bb9fdc33600aafd1b4cd7d87b9aa9652 Mon Sep 17 00:00:00 2001 From: avivkeller <me@aviv.sh> Date: Tue, 15 Apr 2025 17:50:27 -0400 Subject: [PATCH 1/7] feat(cli): split cli into sub commands --- README.md | 73 +++++-- bin/cli.mjs | 535 ++++++++++++++++++++++++++++++++++------------ eslint.config.mjs | 2 +- package-lock.json | 34 +++ package.json | 1 + 5 files changed, 490 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index 68b822f1..002742d5 100644 --- a/README.md +++ b/README.md @@ -23,30 +23,61 @@ ## Usage -Local invocation: +### `generate` + +Generate API documentation from Markdown files. ```sh -$ npx api-docs-tooling --help +npx api-docs-tooling generate [options] ``` +**Options:** + +- `-i, --input <patterns...>` Input file patterns (glob) +- `--ignore [patterns...]` Files to ignore +- `-o, --output <dir>` Output directory +- `-v, --version <semver>` Target Node.js version (default: latest) +- `-c, --changelog <url>` Changelog file or URL +- `--git-ref <url>` Git ref/commit URL +- `-t, --target [modes...]` Generator target(s): `json-simple`, `legacy-html`, etc. +- `--no-lint` Skip linting before generation + +### `lint` + +Run the linter on API documentation. + +```sh +npx api-docs-tooling lint [options] +``` + +**Options:** + +- `-i, --input <patterns...>` Input file patterns (glob) +- `--ignore [patterns...]` Files to ignore +- `--disable-rule [rules...]` Disable specific linting rules +- `--lint-dry-run` Run linter without applying changes +- `-r, --reporter <reporter>` Reporter format: `console`, `github`, etc. + +### `interactive` + +Launches a fully interactive CLI prompt to guide you through all available options. + +```sh +npx api-docs-tooling interactive +``` + +### `list` + +See available modules for each subsystem. + +```sh +npx api-docs-tooling list generators +npx api-docs-tooling list rules +npx api-docs-tooling list reporters +``` + +### `help` + ```sh -Usage: api-docs-tooling [options] - -CLI tool to generate API documentation of a Node.js project. - -Options: - -i, --input [patterns...] Specify input file patterns using glob syntax - --ignore [patterns...] Specify which input files to ignore using glob syntax - -o, --output <path> Specify the relative or absolute output directory - -v, --version <semver> Specify the target version of Node.js, semver compliant (default: "v22.11.0") - -c, --changelog <url> Specify the path (file: or https://) to the CHANGELOG.md file (default: - "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md") - -t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", - "man-page", "legacy-json", "legacy-json-all", "addon-verify", "api-links", "orama-db") - --disable-rule [rule...] Disable a specific linter rule (choices: "invalid-change-version", - "missing-change-version", "missing-introduced-in", default: []) - --lint-dry-run Run linter in dry-run mode (default: false) - --git-ref A git ref/commit URL pointing to Node.js - -r, --reporter [reporter] Specify the linter reporter (choices: "console", "github", default: "console") - -h, --help display help for command +npx api-docs-tooling help [command] ``` diff --git a/bin/cli.mjs b/bin/cli.mjs index c4248de9..a679d107 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -2,9 +2,10 @@ import { resolve } from 'node:path'; import process from 'node:process'; +import { spawnSync } from 'node:child_process'; import { cpus } from 'node:os'; -import { Command, Option } from 'commander'; +import { Argument, Command, Option } from 'commander'; import { coerce } from 'semver'; import { DOC_NODE_CHANGELOG_URL, DOC_NODE_VERSION } from '../src/constants.mjs'; @@ -17,145 +18,413 @@ import createMarkdownLoader from '../src/loaders/markdown.mjs'; import createMarkdownParser from '../src/parsers/markdown.mjs'; import createNodeReleases from '../src/releases.mjs'; -const availableGenerators = Object.keys(publicGenerators); +import { + intro, + outro, + select, + multiselect, + text, + confirm, + isCancel, + cancel, +} from '@clack/prompts'; -const program = new Command(); +// Derive available options dynamically from imported modules +const availableGenerators = Object.keys(publicGenerators); // e.g. ['html', 'json'] +const availableRules = Object.keys(rules); // Linter rule names +const availableReporters = Object.keys(reporters); // Reporter implementations +// Initialize Commander.js +const program = new Command(); program .name('api-docs-tooling') - .description('CLI tool to generate API documentation of a Node.js project.') - .addOption( - new Option( - '-i, --input [patterns...]', - 'Specify input file patterns using glob syntax' - ).makeOptionMandatory() - ) - .addOption( - new Option( - '--ignore [patterns...]', - 'Specify which input files to ignore using glob syntax' - ) - ) - .addOption( - new Option( - '-o, --output <path>', - 'Specify the relative or absolute output directory' - ) - ) - .addOption( - new Option( - '-v, --version <semver>', - 'Specify the target version of Node.js, semver compliant' - ).default(DOC_NODE_VERSION) - ) - .addOption( - new Option( - '-c, --changelog <url>', - 'Specify the path (file: or https://) to the CHANGELOG.md file' - ).default(DOC_NODE_CHANGELOG_URL) - ) - .addOption( - new Option( - '-t, --target [mode...]', - 'Set the processing target modes' - ).choices(availableGenerators) - ) - .addOption( - new Option('--disable-rule [rule...]', 'Disable a specific linter rule') - .choices(Object.keys(rules)) - .default([]) - ) - .addOption( - new Option('--lint-dry-run', 'Run linter in dry-run mode').default(false) - ) - .addOption( - new Option('--git-ref', 'A git ref/commit URL pointing to Node.js').default( - 'https://github.com/nodejs/node/tree/HEAD' - ) - ) - .addOption( - new Option('-r, --reporter [reporter]', 'Specify the linter reporter') - .choices(Object.keys(reporters)) - .default('console') - ) - .addOption( - new Option( - '-p, --threads <number>', - 'The maximum number of threads to use. Set to 1 to disable parallelism' - ).default(Math.max(1, cpus().length - 1)) - ) - .parse(process.argv); + .description('CLI tool to generate and lint Node.js API documentation'); + +// Instantiate loader and parser once to reuse +const loader = createMarkdownLoader(); +const parser = createMarkdownParser(); /** - * @typedef {keyof publicGenerators} Target A list of the available generator names. - * - * @typedef {Object} Options - * @property {Array<string>|string} input Specifies the glob/path for input files. - * @property {string} output Specifies the directory where output files will be saved. - * @property {Target[]} target Specifies the generator target mode. - * @property {string} version Specifies the target Node.js version. - * @property {string} changelog Specifies the path to the Node.js CHANGELOG.md file. - * @property {string[]} disableRule Specifies the linter rules to disable. - * @property {boolean} lintDryRun Specifies whether the linter should run in dry-run mode. - * @property {boolean} useGit Specifies whether the parser should execute optional git commands. (Should only be used within a git repo) - * @property {keyof reporters} reporter Specifies the linter reporter. - * - * @name ProgramOptions - * @type {Options} - * @description The return type for values sent to the program from the CLI. + * Load and parse markdown API docs. + * @param {string[]} input - Glob patterns for input files. + * @param {string[]} [ignore] - Glob patterns to ignore. + * @returns {Promise<ApiDocMetadataEntry[]>} - Parsed documentation objects. */ -const { - input, - ignore, - output, - target = [], - version, - changelog, - disableRule, - lintDryRun, - gitRef, - reporter, - threads, -} = program.opts(); - -const linter = createLinter(lintDryRun, disableRule); - -const { loadFiles } = createMarkdownLoader(); -const { parseApiDocs } = createMarkdownParser(); - -const apiDocFiles = await loadFiles(input, ignore); - -const parsedApiDocs = await parseApiDocs(apiDocFiles); - -const { runGenerators } = createGenerator(parsedApiDocs); - -// Retrieves Node.js release metadata from a given Node.js version and CHANGELOG.md file -const { getAllMajors } = createNodeReleases(changelog); - -// Runs the Linter on the parsed API docs -linter.lintAll(parsedApiDocs); - -if (target) { - await runGenerators({ - // A list of target modes for the API docs parser - generators: target, - // Resolved `input` to be used - input: input, - // Resolved `output` path to be used - output: output && resolve(output), - // Resolved SemVer of current Node.js version - version: coerce(version), - // A list of all Node.js major versions with LTS status - releases: await getAllMajors(), - // An URL containing a git ref URL pointing to the commit or ref that was used - // to generate the API docs. This is used to link to the source code of the - gitRef, - // How many threads should be used - threads, - }); +async function loadAndParse(input, ignore) { + const files = await loader.loadFiles(input, ignore); + return parser.parseApiDocs(files); +} + +/** + * Run the linter on parsed documentation. + * @param {ApiDocMetadataEntry[]} docs - Parsed documentation objects. + * @param {object} [opts] + * @param {string[]} [opts.disableRule] - List of rule names to disable. + * @param {boolean} [opts.lintDryRun] - If true, do not throw on errors. + * @param {string} [opts.reporter] - Reporter to use for output. + * @returns {boolean} - True if no errors, false otherwise. + */ +function runLint( + docs, + { disableRule = [], lintDryRun = false, reporter = 'console' } = {} +) { + const linter = createLinter(lintDryRun, disableRule); + linter.lintAll(docs); + linter.report(reporter); + return !linter.hasError(); +} + +/** + * Require value to have a length > 0 + * @param {string} value + * @returns {boolean} + */ +function requireValue(value) { + if (value.length === 0) return 'Value is required!'; +} + +/** + * Get the message for a prompt + * @param {{ message: string, required: boolean }} prompt + * @returns {string} + */ +function getMessage({ message, required, initialValue }) { + return required || initialValue ? message : `${message} (Optional)`; } -// Reports Lint Content -linter.report(reporter); +/** + * Centralized command definitions. + * Each command has a description and a set of options with: + * - flags: Commander.js flag definitions + * - desc: description for help output + * - prompt: metadata for interactive mode + */ +const commandDefinitions = { + generate: { + description: 'Generate API docs', + options: { + input: { + flags: ['-i, --input <patterns...>'], + desc: 'Input file patterns (glob)', + prompt: { + type: 'text', + message: 'Enter input glob patterns', + variadic: true, + required: true, + }, + }, + ignore: { + flags: ['--ignore [patterns...]'], + desc: 'Ignore patterns (comma-separated)', + prompt: { + type: 'text', + message: 'Enter ignore patterns', + variadic: true, + }, + }, + output: { + flags: ['-o, --output <dir>'], + desc: 'Output directory', + prompt: { type: 'text', message: 'Enter output directory' }, + }, + threads: { + flags: ['-p, --threads <number>'], + prompt: { + type: 'text', + message: 'How many threads to allow', + initialValue: String(Math.max(cpus().length, 1)), + }, + }, + version: { + flags: ['-v, --version <semver>'], + desc: 'Target Node.js version', + prompt: { + type: 'text', + message: 'Enter Node.js version', + initialValue: DOC_NODE_VERSION, + }, + }, + changelog: { + flags: ['-c, --changelog <url>'], + desc: 'Changelog URL or path', + prompt: { + type: 'text', + message: 'Enter changelog URL', + initialValue: DOC_NODE_CHANGELOG_URL, + }, + }, + gitRef: { + flags: ['--git-ref <url>'], + desc: 'Git ref/commit URL', + prompt: { + type: 'text', + message: 'Enter Git ref URL', + initialValue: 'https://github.com/nodejs/node/tree/HEAD', + }, + }, + target: { + flags: ['-t, --target [modes...]'], + desc: 'Target generator modes', + prompt: { + required: true, + type: 'multiselect', + message: 'Choose target generators', + options: availableGenerators.map(g => ({ + label: g, + value: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`, + })), + }, + }, + skipLint: { + flags: ['--no-lint'], + desc: 'Skip lint before generate', + prompt: { + type: 'confirm', + message: 'Skip lint before generate?', + initialValue: false, + }, + }, + }, + }, + lint: { + description: 'Run linter independently', + options: { + input: { + flags: ['-i, --input <patterns...>'], + desc: 'Input file patterns (glob)', + prompt: { + type: 'text', + message: 'Enter input glob patterns', + variadic: true, + required: true, + }, + }, + ignore: { + flags: ['--ignore [patterns...]'], + desc: 'Ignore patterns (comma-separated)', + prompt: { + type: 'text', + message: 'Enter ignore patterns', + variadic: true, + }, + }, + disableRule: { + flags: ['--disable-rule [rules...]'], + desc: 'Disable linter rules', + prompt: { + type: 'multiselect', + message: 'Choose rules to disable', + options: availableRules.map(r => ({ label: r, value: r })), + }, + }, + lintDryRun: { + flags: ['--lint-dry-run'], + desc: 'Dry run lint mode', + prompt: { + type: 'confirm', + message: 'Enable dry run mode?', + initialValue: false, + }, + }, + reporter: { + flags: ['-r, --reporter <reporter>'], + desc: 'Linter reporter to use', + prompt: { + type: 'select', + message: 'Choose a reporter', + options: availableReporters.map(r => ({ label: r, value: r })), + }, + }, + }, + }, +}; + +// Dynamically register commands based on definitions +Object.entries(commandDefinitions).forEach( + ([cmdName, { description, options }]) => { + // Create a new command in Commander + const cmd = program.command(cmdName).description(description); + + // Register each option + Object.values(options).forEach(({ flags, desc, prompt }) => { + const option = new Option(flags.join(', '), desc); + option.default(prompt.initialValue); + if (prompt.required) option.makeOptionMandatory(); + if (prompt.type === 'multiselect') + option.choices(prompt.options.map(({ label }) => label)); + cmd.addOption(option); + }); + + // Define the command's action handler + cmd.action(async opts => { + // Parse docs from markdown + const docs = await loadAndParse(opts.input, opts.ignore); + + if (cmdName === 'generate') { + // Pre-lint step (skip if requested) + if (!opts.skipLint && !runLint(docs)) { + console.error('Lint failed; aborting generation.'); + process.exit(1); + } + + // Generate API docs via configured generators + const { runGenerators } = createGenerator(docs); + const { getAllMajors } = createNodeReleases(opts.changelog); + await runGenerators({ + generators: opts.target, + input: opts.input, + output: opts.output && resolve(opts.output), + version: coerce(opts.version), + releases: await getAllMajors(), + gitRef: opts.gitRef, + threads: parseInt(opts.threads), + }); + } else { + // Lint-only mode + const success = runLint(docs, { + disableRule: opts.disableRule, + lintDryRun: opts.lintDryRun, + reporter: opts.reporter, + }); + process.exitCode = success ? 0 : 1; + } + }); + } +); + +// Add list subcommands to inspect available modules +program + .command('list') + .addArgument( + new Argument('<type>', 'Type to list').choices([ + 'generators', + 'rules', + 'reporters', + ]) + ) + .description('List available types') + .action(type => { + const list = + type === 'generators' + ? Object.entries(publicGenerators).map( + ([key, generator]) => + `${generator.name || key} (v${generator.version}) - ${generator.description}` + ) + : type === 'rules' + ? availableRules + : availableReporters; + + console.log(list.join('\n')); + }); + +// Interactive mode: guides the user through building a command +program + .command('interactive') + .description('Launch guided CLI wizard') + .action(async () => { + intro('Welcome to API Docs Tooling'); + + // Build action choices from definitions + const actionOptions = Object.entries(commandDefinitions).map( + ([name, def]) => ({ + label: def.description, + value: name, + }) + ); + + // Prompt user to choose a command + const action = await select({ + message: 'What would you like to do?', + options: actionOptions, + }); + + if (isCancel(action)) { + cancel('Cancelled.'); + process.exit(0); + } + + const { options } = commandDefinitions[action]; + const answers = {}; + + // Iterate through each option's prompt metadata + for (const [key, { prompt }] of Object.entries(options)) { + let response; + switch (prompt.type) { + case 'text': + response = await text({ + message: getMessage(prompt), + initialValue: prompt.initialValue || '', + validate: prompt.required ? requireValue : undefined, + }); + if (response) { + answers[key] = prompt.variadic ? response.split(',') : response; + } + break; + case 'confirm': + response = await confirm({ + message: getMessage(prompt), + initialValue: prompt.initialValue, + }); + answers[key] = response; + break; + case 'multiselect': + response = await multiselect({ + message: getMessage(prompt), + options: prompt.options, + required: !!prompt.required, + }); + answers[key] = response; + break; + case 'select': + response = await select({ + message: getMessage(prompt), + options: prompt.options, + }); + answers[key] = response; + break; + } + + if (isCancel(response)) { + cancel('Cancelled.'); + process.exit(0); + } + } + + // Build the final CLI command string + let cmdStr = `npx api-docs-tooling ${action}`; + for (const [key, { flags }] of Object.entries(options)) { + const val = answers[key]; + if (val == null || (Array.isArray(val) && val.length === 0)) continue; + const flag = flags[0].split(/[\s,]+/)[0]; + if (typeof val === 'boolean') { + if (val) cmdStr += ` ${flag}`; + } else if (Array.isArray(val)) { + cmdStr += ` ${flag} ${val.join(',')}`; + } else { + cmdStr += ` ${flag} ${val}`; + } + } + + // Display and optionally run the constructed command + console.log(`\nGenerated command:\n${cmdStr}\n`); + if (await confirm({ message: 'Run now?', initialValue: true })) { + const args = cmdStr.split(' ').slice(2); + spawnSync(process.execPath, [process.argv[1], ...args], { + stdio: 'inherit', + }); + } + + outro('Done!'); + }); + +// Help and version commands for user assistance +program + .command('help [cmd]') + .description('Show help for a command') + .action(cmdName => { + const target = program.commands.find(c => c.name() === cmdName) || program; + target.help(); + }); -process.exitCode = Number(linter.hasError()); +// Parse CLI arguments and execute +program.parse(process.argv); diff --git a/eslint.config.mjs b/eslint.config.mjs index 3eb1bc7a..70bc0027 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import globals from 'globals'; export default [ // @see https://eslint.org/docs/latest/use/configure/configuration-files#specifying-files-and-ignores { - files: ['src/**/*.mjs'], + files: ['src/**/*.mjs', 'bin/cli.mjs'], plugins: { jsdoc: jsdoc, }, diff --git a/package-lock.json b/package-lock.json index d9ff816c..d8b6e8b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "@node-core/api-docs-tooling", "dependencies": { "@actions/core": "^1.11.1", + "@clack/prompts": "^0.10.1", "@orama/orama": "^3.1.3", "@orama/plugin-data-persistence": "^3.1.3", "acorn": "^8.14.1", @@ -86,6 +87,27 @@ "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", "license": "MIT" }, + "node_modules/@clack/core": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.2.tgz", + "integrity": "sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.10.1.tgz", + "integrity": "sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==", + "license": "MIT", + "dependencies": { + "@clack/core": "0.4.2", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.49.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz", @@ -3285,6 +3307,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -3577,6 +3605,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slashes": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz", diff --git a/package.json b/package.json index 7affb1ba..fda68b77 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@actions/core": "^1.11.1", + "@clack/prompts": "^0.10.1", "@orama/orama": "^3.1.3", "@orama/plugin-data-persistence": "^3.1.3", "acorn": "^8.14.1", From 0255fb6d3e236365c0dfa6acea36f09928a6bfeb Mon Sep 17 00:00:00 2001 From: avivkeller <me@aviv.sh> Date: Wed, 16 Apr 2025 12:52:09 -0400 Subject: [PATCH 2/7] split into multiple files --- README.md | 14 +- bin/cli.mjs | 436 ++---------------- bin/commands/generate.mjs | 146 ++++++ bin/commands/index.mjs | 4 + bin/commands/interactive.mjs | 172 +++++++ bin/commands/lint.mjs | 106 +++++ bin/commands/list.mjs | 26 ++ bin/utils.mjs | 40 ++ eslint.config.mjs | 2 +- .../legacy-html/utils/buildContent.mjs | 10 +- 10 files changed, 538 insertions(+), 418 deletions(-) create mode 100644 bin/commands/generate.mjs create mode 100644 bin/commands/index.mjs create mode 100644 bin/commands/interactive.mjs create mode 100644 bin/commands/lint.mjs create mode 100644 bin/commands/list.mjs create mode 100644 bin/utils.mjs diff --git a/README.md b/README.md index 002742d5..b9979dea 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ ## Usage +### `help` + +```sh +npx api-docs-tooling help [command] +``` + ### `generate` Generate API documentation from Markdown files. @@ -55,7 +61,7 @@ npx api-docs-tooling lint [options] - `-i, --input <patterns...>` Input file patterns (glob) - `--ignore [patterns...]` Files to ignore - `--disable-rule [rules...]` Disable specific linting rules -- `--lint-dry-run` Run linter without applying changes +- `--dry-run` Run linter without applying changes - `-r, --reporter <reporter>` Reporter format: `console`, `github`, etc. ### `interactive` @@ -75,9 +81,3 @@ npx api-docs-tooling list generators npx api-docs-tooling list rules npx api-docs-tooling list reporters ``` - -### `help` - -```sh -npx api-docs-tooling help [command] -``` diff --git a/bin/cli.mjs b/bin/cli.mjs index a679d107..748abb77 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -1,430 +1,62 @@ #!/usr/bin/env node -import { resolve } from 'node:path'; import process from 'node:process'; -import { spawnSync } from 'node:child_process'; -import { cpus } from 'node:os'; - import { Argument, Command, Option } from 'commander'; -import { coerce } from 'semver'; -import { DOC_NODE_CHANGELOG_URL, DOC_NODE_VERSION } from '../src/constants.mjs'; -import createGenerator from '../src/generators.mjs'; -import { publicGenerators } from '../src/generators/index.mjs'; -import createLinter from '../src/linter/index.mjs'; -import reporters from '../src/linter/reporters/index.mjs'; -import rules from '../src/linter/rules/index.mjs'; -import createMarkdownLoader from '../src/loaders/markdown.mjs'; -import createMarkdownParser from '../src/parsers/markdown.mjs'; -import createNodeReleases from '../src/releases.mjs'; - -import { - intro, - outro, - select, - multiselect, - text, - confirm, - isCancel, - cancel, -} from '@clack/prompts'; - -// Derive available options dynamically from imported modules -const availableGenerators = Object.keys(publicGenerators); // e.g. ['html', 'json'] -const availableRules = Object.keys(rules); // Linter rule names -const availableReporters = Object.keys(reporters); // Reporter implementations +import interactive from './commands/interactive.mjs'; +import list, { types } from './commands/list.mjs'; +import commands from './commands/index.mjs'; -// Initialize Commander.js -const program = new Command(); -program +const program = new Command() .name('api-docs-tooling') .description('CLI tool to generate and lint Node.js API documentation'); -// Instantiate loader and parser once to reuse -const loader = createMarkdownLoader(); -const parser = createMarkdownParser(); - -/** - * Load and parse markdown API docs. - * @param {string[]} input - Glob patterns for input files. - * @param {string[]} [ignore] - Glob patterns to ignore. - * @returns {Promise<ApiDocMetadataEntry[]>} - Parsed documentation objects. - */ -async function loadAndParse(input, ignore) { - const files = await loader.loadFiles(input, ignore); - return parser.parseApiDocs(files); -} - -/** - * Run the linter on parsed documentation. - * @param {ApiDocMetadataEntry[]} docs - Parsed documentation objects. - * @param {object} [opts] - * @param {string[]} [opts.disableRule] - List of rule names to disable. - * @param {boolean} [opts.lintDryRun] - If true, do not throw on errors. - * @param {string} [opts.reporter] - Reporter to use for output. - * @returns {boolean} - True if no errors, false otherwise. - */ -function runLint( - docs, - { disableRule = [], lintDryRun = false, reporter = 'console' } = {} -) { - const linter = createLinter(lintDryRun, disableRule); - linter.lintAll(docs); - linter.report(reporter); - return !linter.hasError(); -} - -/** - * Require value to have a length > 0 - * @param {string} value - * @returns {boolean} - */ -function requireValue(value) { - if (value.length === 0) return 'Value is required!'; -} - -/** - * Get the message for a prompt - * @param {{ message: string, required: boolean }} prompt - * @returns {string} - */ -function getMessage({ message, required, initialValue }) { - return required || initialValue ? message : `${message} (Optional)`; -} - -/** - * Centralized command definitions. - * Each command has a description and a set of options with: - * - flags: Commander.js flag definitions - * - desc: description for help output - * - prompt: metadata for interactive mode - */ -const commandDefinitions = { - generate: { - description: 'Generate API docs', - options: { - input: { - flags: ['-i, --input <patterns...>'], - desc: 'Input file patterns (glob)', - prompt: { - type: 'text', - message: 'Enter input glob patterns', - variadic: true, - required: true, - }, - }, - ignore: { - flags: ['--ignore [patterns...]'], - desc: 'Ignore patterns (comma-separated)', - prompt: { - type: 'text', - message: 'Enter ignore patterns', - variadic: true, - }, - }, - output: { - flags: ['-o, --output <dir>'], - desc: 'Output directory', - prompt: { type: 'text', message: 'Enter output directory' }, - }, - threads: { - flags: ['-p, --threads <number>'], - prompt: { - type: 'text', - message: 'How many threads to allow', - initialValue: String(Math.max(cpus().length, 1)), - }, - }, - version: { - flags: ['-v, --version <semver>'], - desc: 'Target Node.js version', - prompt: { - type: 'text', - message: 'Enter Node.js version', - initialValue: DOC_NODE_VERSION, - }, - }, - changelog: { - flags: ['-c, --changelog <url>'], - desc: 'Changelog URL or path', - prompt: { - type: 'text', - message: 'Enter changelog URL', - initialValue: DOC_NODE_CHANGELOG_URL, - }, - }, - gitRef: { - flags: ['--git-ref <url>'], - desc: 'Git ref/commit URL', - prompt: { - type: 'text', - message: 'Enter Git ref URL', - initialValue: 'https://github.com/nodejs/node/tree/HEAD', - }, - }, - target: { - flags: ['-t, --target [modes...]'], - desc: 'Target generator modes', - prompt: { - required: true, - type: 'multiselect', - message: 'Choose target generators', - options: availableGenerators.map(g => ({ - label: g, - value: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`, - })), - }, - }, - skipLint: { - flags: ['--no-lint'], - desc: 'Skip lint before generate', - prompt: { - type: 'confirm', - message: 'Skip lint before generate?', - initialValue: false, - }, - }, - }, - }, - lint: { - description: 'Run linter independently', - options: { - input: { - flags: ['-i, --input <patterns...>'], - desc: 'Input file patterns (glob)', - prompt: { - type: 'text', - message: 'Enter input glob patterns', - variadic: true, - required: true, - }, - }, - ignore: { - flags: ['--ignore [patterns...]'], - desc: 'Ignore patterns (comma-separated)', - prompt: { - type: 'text', - message: 'Enter ignore patterns', - variadic: true, - }, - }, - disableRule: { - flags: ['--disable-rule [rules...]'], - desc: 'Disable linter rules', - prompt: { - type: 'multiselect', - message: 'Choose rules to disable', - options: availableRules.map(r => ({ label: r, value: r })), - }, - }, - lintDryRun: { - flags: ['--lint-dry-run'], - desc: 'Dry run lint mode', - prompt: { - type: 'confirm', - message: 'Enable dry run mode?', - initialValue: false, - }, - }, - reporter: { - flags: ['-r, --reporter <reporter>'], - desc: 'Linter reporter to use', - prompt: { - type: 'select', - message: 'Choose a reporter', - options: availableReporters.map(r => ({ label: r, value: r })), - }, - }, - }, - }, -}; +// Registering generate and lint commands +commands.forEach(({ name, description, options, action }) => { + const cmd = program.command(name).description(description); -// Dynamically register commands based on definitions -Object.entries(commandDefinitions).forEach( - ([cmdName, { description, options }]) => { - // Create a new command in Commander - const cmd = program.command(cmdName).description(description); - - // Register each option - Object.values(options).forEach(({ flags, desc, prompt }) => { - const option = new Option(flags.join(', '), desc); - option.default(prompt.initialValue); - if (prompt.required) option.makeOptionMandatory(); - if (prompt.type === 'multiselect') - option.choices(prompt.options.map(({ label }) => label)); - cmd.addOption(option); - }); - - // Define the command's action handler - cmd.action(async opts => { - // Parse docs from markdown - const docs = await loadAndParse(opts.input, opts.ignore); - - if (cmdName === 'generate') { - // Pre-lint step (skip if requested) - if (!opts.skipLint && !runLint(docs)) { - console.error('Lint failed; aborting generation.'); - process.exit(1); - } + // Add options to the command + Object.values(options).forEach(({ flags, desc, prompt }) => { + const option = new Option(flags.join(', '), desc).default( + prompt.initialValue + ); - // Generate API docs via configured generators - const { runGenerators } = createGenerator(docs); - const { getAllMajors } = createNodeReleases(opts.changelog); - await runGenerators({ - generators: opts.target, - input: opts.input, - output: opts.output && resolve(opts.output), - version: coerce(opts.version), - releases: await getAllMajors(), - gitRef: opts.gitRef, - threads: parseInt(opts.threads), - }); - } else { - // Lint-only mode - const success = runLint(docs, { - disableRule: opts.disableRule, - lintDryRun: opts.lintDryRun, - reporter: opts.reporter, - }); - process.exitCode = success ? 0 : 1; - } - }); - } -); + if (prompt.required) { + option.makeOptionMandatory(); + } -// Add list subcommands to inspect available modules -program - .command('list') - .addArgument( - new Argument('<type>', 'Type to list').choices([ - 'generators', - 'rules', - 'reporters', - ]) - ) - .description('List available types') - .action(type => { - const list = - type === 'generators' - ? Object.entries(publicGenerators).map( - ([key, generator]) => - `${generator.name || key} (v${generator.version}) - ${generator.description}` - ) - : type === 'rules' - ? availableRules - : availableReporters; + if (prompt.type === 'multiselect') { + option.choices(prompt.options.map(({ value }) => value)); + } - console.log(list.join('\n')); + cmd.addOption(option); }); -// Interactive mode: guides the user through building a command + // Set the action for the command + cmd.action(action); +}); + +// Register the interactive command program .command('interactive') .description('Launch guided CLI wizard') - .action(async () => { - intro('Welcome to API Docs Tooling'); - - // Build action choices from definitions - const actionOptions = Object.entries(commandDefinitions).map( - ([name, def]) => ({ - label: def.description, - value: name, - }) - ); + .action(interactive); - // Prompt user to choose a command - const action = await select({ - message: 'What would you like to do?', - options: actionOptions, - }); - - if (isCancel(action)) { - cancel('Cancelled.'); - process.exit(0); - } - - const { options } = commandDefinitions[action]; - const answers = {}; - - // Iterate through each option's prompt metadata - for (const [key, { prompt }] of Object.entries(options)) { - let response; - switch (prompt.type) { - case 'text': - response = await text({ - message: getMessage(prompt), - initialValue: prompt.initialValue || '', - validate: prompt.required ? requireValue : undefined, - }); - if (response) { - answers[key] = prompt.variadic ? response.split(',') : response; - } - break; - case 'confirm': - response = await confirm({ - message: getMessage(prompt), - initialValue: prompt.initialValue, - }); - answers[key] = response; - break; - case 'multiselect': - response = await multiselect({ - message: getMessage(prompt), - options: prompt.options, - required: !!prompt.required, - }); - answers[key] = response; - break; - case 'select': - response = await select({ - message: getMessage(prompt), - options: prompt.options, - }); - answers[key] = response; - break; - } - - if (isCancel(response)) { - cancel('Cancelled.'); - process.exit(0); - } - } - - // Build the final CLI command string - let cmdStr = `npx api-docs-tooling ${action}`; - for (const [key, { flags }] of Object.entries(options)) { - const val = answers[key]; - if (val == null || (Array.isArray(val) && val.length === 0)) continue; - const flag = flags[0].split(/[\s,]+/)[0]; - if (typeof val === 'boolean') { - if (val) cmdStr += ` ${flag}`; - } else if (Array.isArray(val)) { - cmdStr += ` ${flag} ${val.join(',')}`; - } else { - cmdStr += ` ${flag} ${val}`; - } - } - - // Display and optionally run the constructed command - console.log(`\nGenerated command:\n${cmdStr}\n`); - if (await confirm({ message: 'Run now?', initialValue: true })) { - const args = cmdStr.split(' ').slice(2); - spawnSync(process.execPath, [process.argv[1], ...args], { - stdio: 'inherit', - }); - } - - outro('Done!'); - }); +// Register the list command +program + .command('list') + .addArgument(new Argument('<types>', 'The type to list').choices(types)) + .description('List the given type') + .action(list); -// Help and version commands for user assistance +// Register the help command program .command('help [cmd]') .description('Show help for a command') .action(cmdName => { - const target = program.commands.find(c => c.name() === cmdName) || program; + const target = program.commands.find(c => c.name() === cmdName) ?? program; target.help(); }); -// Parse CLI arguments and execute +// Parse and execute command-line arguments program.parse(process.argv); diff --git a/bin/commands/generate.mjs b/bin/commands/generate.mjs new file mode 100644 index 00000000..6911b7df --- /dev/null +++ b/bin/commands/generate.mjs @@ -0,0 +1,146 @@ +import { cpus } from 'node:os'; +import { resolve } from 'node:path'; +import process from 'node:process'; + +import { coerce } from 'semver'; + +import { + DOC_NODE_CHANGELOG_URL, + DOC_NODE_VERSION, +} from '../../src/constants.mjs'; +import createGenerator from '../../src/generators.mjs'; +import { publicGenerators } from '../../src/generators/index.mjs'; +import createNodeReleases from '../../src/releases.mjs'; +import { loadAndParse } from '../utils.mjs'; +import { runLint } from './lint.mjs'; + +const availableGenerators = Object.keys(publicGenerators); + +/** + * @typedef {Object} Options + * @property {Array<string>|string} input - Specifies the glob/path for input files. + * @property {Array<string>|string} [ignore] - Specifies the glob/path for ignoring files. + * @property {Array<keyof publicGenerators>} target - Specifies the generator target mode. + * @property {string} version - Specifies the target Node.js version. + * @property {string} changelog - Specifies the path to the Node.js CHANGELOG.md file. + * @property {string} [gitRef] - Git ref/commit URL. + * @property {number} [threads] - Number of threads to allow. + * @property {boolean} [skipLint] - Skip lint before generate. + */ + +/** + * @type {import('../utils.mjs').Command} + */ +export default { + description: 'Generate API docs', + name: 'generate', + options: { + input: { + flags: ['-i', '--input <patterns...>'], + desc: 'Input file patterns (glob)', + prompt: { + type: 'text', + message: 'Enter input glob patterns', + variadic: true, + required: true, + }, + }, + ignore: { + flags: ['--ignore [patterns...]'], + desc: 'Ignore patterns (comma-separated)', + prompt: { + type: 'text', + message: 'Enter ignore patterns', + variadic: true, + }, + }, + output: { + flags: ['-o', '--output <dir>'], + desc: 'Output directory', + prompt: { type: 'text', message: 'Enter output directory' }, + }, + threads: { + flags: ['-p', '--threads <number>'], + prompt: { + type: 'text', + message: 'How many threads to allow', + initialValue: String(Math.max(cpus().length, 1)), + }, + }, + version: { + flags: ['-v', '--version <semver>'], + desc: 'Target Node.js version', + prompt: { + type: 'text', + message: 'Enter Node.js version', + initialValue: DOC_NODE_VERSION, + }, + }, + changelog: { + flags: ['-c', '--changelog <url>'], + desc: 'Changelog URL or path', + prompt: { + type: 'text', + message: 'Enter changelog URL', + initialValue: DOC_NODE_CHANGELOG_URL, + }, + }, + gitRef: { + flags: ['--git-ref <url>'], + desc: 'Git ref/commit URL', + prompt: { + type: 'text', + message: 'Enter Git ref URL', + initialValue: 'https://github.com/nodejs/node/tree/HEAD', + }, + }, + target: { + flags: ['-t', '--target [modes...]'], + desc: 'Target generator modes', + prompt: { + required: true, + type: 'multiselect', + message: 'Choose target generators', + options: availableGenerators.map(g => ({ + value: g, + label: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`, + })), + }, + }, + skipLint: { + flags: ['--no-lint'], + desc: 'Skip lint before generate', + prompt: { + type: 'confirm', + message: 'Skip lint before generate?', + initialValue: false, + }, + }, + }, + /** + * Handles the action for generating API docs + * @param {Options} opts - The options to generate API docs. + * @returns {Promise<void>} + */ + async action(opts) { + const docs = await loadAndParse(opts.input, opts.ignore); + + if (!opts.skipLint && !runLint(docs)) { + console.error('Lint failed; aborting generation.'); + process.exit(1); + } + + const { runGenerators } = createGenerator(docs); + const { getAllMajors } = createNodeReleases(opts.changelog); + + await runGenerators({ + generators: opts.target, + input: opts.input, + output: opts.output && resolve(opts.output), + version: coerce(opts.version), + releases: await getAllMajors(), + gitRef: opts.gitRef, + threads: parseInt(opts.threads, 10), + }); + }, +}; diff --git a/bin/commands/index.mjs b/bin/commands/index.mjs new file mode 100644 index 00000000..05a10c93 --- /dev/null +++ b/bin/commands/index.mjs @@ -0,0 +1,4 @@ +import generate from './generate.mjs'; +import lint from './lint.mjs'; + +export default [generate, lint]; diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs new file mode 100644 index 00000000..0cdf0884 --- /dev/null +++ b/bin/commands/interactive.mjs @@ -0,0 +1,172 @@ +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; + +import { + intro, + outro, + select, + multiselect, + text, + confirm, + isCancel, + cancel, +} from '@clack/prompts'; + +import commands from './index.mjs'; + +/** + * Validates that a string is not empty. + * @param {string} value The input string to validate. + * @returns {string|undefined} A validation message or undefined if valid. + */ +function requireValue(value) { + if (value.length === 0) { + return 'Value is required!'; + } +} + +/** + * Retrieves the prompt message based on whether the field is required or has an initial value. + * @param {Object} prompt The prompt definition. + * @param {string} prompt.message The message to display. + * @param {boolean} prompt.required Whether the input is required. + * @param {string} [prompt.initialValue] The initial value of the input field. + * @returns {string} The message to display in the prompt. + */ +function getMessage({ message, required, initialValue }) { + return required || initialValue ? message : `${message} (Optional)`; +} + +/** + * Escapes shell argument to ensure it's safe for inclusion in shell commands. + * @param {string} arg The argument to escape. + * @returns {string} The escaped argument. + */ +function escapeShellArg(arg) { + // Return the argument as is if it's alphanumeric or contains safe characters + if (/^[a-zA-Z0-9_/-]+$/.test(arg)) { + return arg; + } + // Escape single quotes in the argument + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +/** + * Main interactive function for the API Docs Tooling command line interface. + * Guides the user through a series of prompts, validates inputs, and generates a command to run. + * @returns {Promise<void>} Resolves once the command is generated and executed. + */ +export default async function interactive() { + // Step 1: Introduction to the tool + intro('Welcome to API Docs Tooling'); + + // Step 2: Choose the action based on available command definitions + const actionOptions = commands.map(({ description }, i) => ({ + label: description, + value: i, + })); + + const selectedAction = await select({ + message: 'What would you like to do?', + options: actionOptions, + }); + + if (isCancel(selectedAction)) { + cancel('Cancelled.'); + process.exit(0); + } + + // Retrieve the options for the selected action + const { options, name } = commands[selectedAction]; + const answers = {}; // Store answers from user prompts + + // Step 3: Collect input for each option + for (const [key, { prompt }] of Object.entries(options)) { + let response; + const promptMessage = getMessage(prompt); + + switch (prompt.type) { + case 'text': + response = await text({ + message: promptMessage, + initialValue: prompt.initialValue || '', + validate: prompt.required ? requireValue : undefined, + }); + if (response) { + // Store response; split into an array if variadic + answers[key] = prompt.variadic + ? response.split(',').map(s => s.trim()) + : response; + } + break; + + case 'confirm': + response = await confirm({ + message: promptMessage, + initialValue: prompt.initialValue, + }); + answers[key] = response; + break; + + case 'multiselect': + response = await multiselect({ + message: promptMessage, + options: prompt.options, + required: !!prompt.required, + }); + answers[key] = response; + break; + + case 'select': + response = await select({ + message: promptMessage, + options: prompt.options, + }); + answers[key] = response; + break; + } + + // Handle cancellation + if (isCancel(response)) { + cancel('Cancelled.'); + process.exit(0); + } + } + + // Step 4: Build the final command by escaping values + const cmdParts = ['npx', 'api-docs-tooling', name]; + const executionArgs = [name]; + + for (const [key, { flags }] of Object.entries(options)) { + const value = answers[key]; + if (value == null || (Array.isArray(value) && value.length === 0)) continue; // Skip empty values + + const flag = flags[0].split(/[\s,]+/)[0]; // Use the first flag + + // Handle different value types (boolean, array, string) + if (typeof value === 'boolean') { + if (value) cmdParts.push(flag); + } else if (Array.isArray(value)) { + for (const item of value) { + cmdParts.push(flag, escapeShellArg(item)); + executionArgs.push(flag, item); + } + } else { + cmdParts.push(flag, escapeShellArg(value)); + executionArgs.push(flag, value); + } + } + + const finalCommand = cmdParts.join(' '); + + console.log(`\nGenerated command:\n${finalCommand}\n`); + + // Step 5: Confirm and execute the generated command + if (await confirm({ message: 'Run now?', initialValue: true })) { + spawnSync(process.execPath, [process.argv[1], ...executionArgs], { + stdio: 'inherit', + }); + } + + outro('Done!'); +} diff --git a/bin/commands/lint.mjs b/bin/commands/lint.mjs new file mode 100644 index 00000000..a0abafe2 --- /dev/null +++ b/bin/commands/lint.mjs @@ -0,0 +1,106 @@ +import process from 'node:process'; + +import createLinter from '../../src/linter/index.mjs'; +import reporters from '../../src/linter/reporters/index.mjs'; +import rules from '../../src/linter/rules/index.mjs'; +import { loadAndParse } from '../utils.mjs'; + +const availableRules = Object.keys(rules); +const availableReporters = Object.keys(reporters); + +/** + * @typedef {Object} LinterOptions + * @property {Array<string>|string} input - Glob/path for input files. + * @property {Array<string>|string} [ignore] - Glob/path for ignoring files. + * @property {string[]} [disableRule] - Linter rules to disable. + * @property {boolean} [dryRun] - Dry-run mode. + * @property {keyof reporters} reporter - Reporter for linter output. + */ + +/** + * Run the linter on parsed documentation. + * @param {ApiDocMetadataEntry[]} docs - Parsed documentation objects. + * @param {LinterOptions} options - Linter configuration options. + * @returns {boolean} - True if no errors, false otherwise. + */ +export function runLint( + docs, + { disableRule = [], dryRun = false, reporter = 'console' } = {} +) { + const linter = createLinter(dryRun, disableRule); + linter.lintAll(docs); + linter.report(reporter); + return !linter.hasError(); +} + +/** + * @type {import('../utils.mjs').Command} + */ +export default { + name: 'lint', + description: 'Run linter independently', + options: { + input: { + flags: ['-i', '--input <patterns...>'], + desc: 'Input file patterns (glob)', + prompt: { + type: 'text', + message: 'Enter input glob patterns', + variadic: true, + required: true, + }, + }, + ignore: { + flags: ['--ignore [patterns...]'], + desc: 'Ignore patterns (comma-separated)', + prompt: { + type: 'text', + message: 'Enter ignore patterns', + variadic: true, + }, + }, + disableRule: { + flags: ['--disable-rule [rules...]'], + desc: 'Disable linter rules', + prompt: { + type: 'multiselect', + message: 'Choose rules to disable', + options: availableRules.map(r => ({ label: r, value: r })), + }, + }, + dryRun: { + flags: ['--dry-run'], + desc: 'Dry run mode', + prompt: { + type: 'confirm', + message: 'Enable dry run mode?', + initialValue: false, + }, + }, + reporter: { + flags: ['-r', '--reporter <reporter>'], + desc: 'Linter reporter to use', + prompt: { + type: 'select', + message: 'Choose a reporter', + options: availableReporters.map(r => ({ label: r, value: r })), + }, + }, + }, + + /** + * Action for running the linter + * @param {LinterOptions} opts - Linter options. + * @returns {Promise<void>} + */ + async action(opts) { + try { + const docs = await loadAndParse(opts.input, opts.ignore); + const success = runLint(docs, opts); + process.exitCode = success ? 0 : 1; + } catch (error) { + console.error('Error running the linter:', error); + process.exitCode = 1; + } + }, +}; diff --git a/bin/commands/list.mjs b/bin/commands/list.mjs new file mode 100644 index 00000000..2bb898a8 --- /dev/null +++ b/bin/commands/list.mjs @@ -0,0 +1,26 @@ +import { publicGenerators } from '../../src/generators/index.mjs'; +import reporters from '../../src/linter/reporters/index.mjs'; +import rules from '../../src/linter/rules/index.mjs'; + +const availableRules = Object.keys(rules); +const availableReporters = Object.keys(reporters); + +/** + * + * @param type + */ +export default function list(type) { + const list = + type === 'generators' + ? Object.entries(publicGenerators).map( + ([key, generator]) => + `${generator.name || key} (v${generator.version}) - ${generator.description}` + ) + : type === 'rules' + ? availableRules + : availableReporters; + + console.log(list.join('\n')); +} + +export const types = ['generators', 'rules', 'reporters']; diff --git a/bin/utils.mjs b/bin/utils.mjs new file mode 100644 index 00000000..45ceff2d --- /dev/null +++ b/bin/utils.mjs @@ -0,0 +1,40 @@ +import createMarkdownLoader from '../src/loaders/markdown.mjs'; +import createMarkdownParser from '../src/parsers/markdown.mjs'; + +// Instantiate loader and parser once to reuse +const loader = createMarkdownLoader(); +const parser = createMarkdownParser(); + +/** + * Load and parse markdown API docs. + * @param {string[]} input - Glob patterns for input files. + * @param {string[]} [ignore] - Glob patterns to ignore. + * @returns {Promise<ApiDocMetadataEntry[]>} - Parsed documentation objects. + */ +export async function loadAndParse(input, ignore) { + const files = await loader.loadFiles(input, ignore); + return parser.parseApiDocs(files); +} + +/** + * Represents a command-line option for the linter CLI. + * @typedef {Object} Option + * @property {string[]} flags - Command-line flags, e.g., ['-i, --input <patterns...>']. + * @property {string} desc - Description of the option. + * @property {Object} [prompt] - Optional prompt configuration. + * @property {'text'|'confirm'|'select'|'multiselect'} prompt.type - Type of the prompt. + * @property {string} prompt.message - Message displayed in the prompt. + * @property {boolean} [prompt.variadic] - Indicates if the prompt accepts multiple values. + * @property {boolean} [prompt.required] - Whether the prompt is required. + * @property {boolean} [prompt.initialValue] - Default value for confirm prompts. + * @property {{label: string, value: string}[]} [prompt.options] - Options for select/multiselect prompts. + */ + +/** + * Represents a command-line subcommand + * @typedef {Object} Command + * @property {{ [key: string]: Option }} options + * @property {string} name + * @property {string} description + * @property {Function} action + */ diff --git a/eslint.config.mjs b/eslint.config.mjs index 70bc0027..7d008d7a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import globals from 'globals'; export default [ // @see https://eslint.org/docs/latest/use/configure/configuration-files#specifying-files-and-ignores { - files: ['src/**/*.mjs', 'bin/cli.mjs'], + files: ['src/**/*.mjs', 'bin/**/*.mjs'], plugins: { jsdoc: jsdoc, }, diff --git a/src/generators/legacy-html/utils/buildContent.mjs b/src/generators/legacy-html/utils/buildContent.mjs index 2becb171..15d7b57a 100644 --- a/src/generators/legacy-html/utils/buildContent.mjs +++ b/src/generators/legacy-html/utils/buildContent.mjs @@ -109,10 +109,7 @@ const buildMetadataElement = node => { : node.added_in; // Creates the added in element with the added in version - const addedinElement = createElement('span', [ - 'Added in: ', - addedIn, - ]); + const addedinElement = createElement('span', ['Added in: ', addedIn]); // Appends the added in element to the metadata element metadataElement.children.push(addedinElement); @@ -141,10 +138,7 @@ const buildMetadataElement = node => { : node.removed_in; // Creates the removed in element with the removed in version - const removedInElement = createElement('span', [ - 'Removed in: ', - removedIn, - ]); + const removedInElement = createElement('span', ['Removed in: ', removedIn]); // Appends the removed in element to the metadata element metadataElement.children.push(removedInElement); From d480352836685d019d8fc233112aecc174f7feef Mon Sep 17 00:00:00 2001 From: avivkeller <me@aviv.sh> Date: Wed, 16 Apr 2025 12:55:30 -0400 Subject: [PATCH 3/7] add jsdoc --- bin/commands/list.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/commands/list.mjs b/bin/commands/list.mjs index 2bb898a8..50bfb810 100644 --- a/bin/commands/list.mjs +++ b/bin/commands/list.mjs @@ -5,9 +5,12 @@ import rules from '../../src/linter/rules/index.mjs'; const availableRules = Object.keys(rules); const availableReporters = Object.keys(reporters); +export const types = ['generators', 'rules', 'reporters']; + /** + * Lists available generators, rules, or reporters based on the given type. * - * @param type + * @param {'generators' | 'rules' | 'reporters'} type - The type of items to list. */ export default function list(type) { const list = @@ -22,5 +25,3 @@ export default function list(type) { console.log(list.join('\n')); } - -export const types = ['generators', 'rules', 'reporters']; From fbb3009ac86dc5687cfea835f3698a4aa7242dfb Mon Sep 17 00:00:00 2001 From: avivkeller <me@aviv.sh> Date: Wed, 16 Apr 2025 12:59:38 -0400 Subject: [PATCH 4/7] include boolean flags in final exec --- bin/commands/interactive.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs index 0cdf0884..840e2f2d 100644 --- a/bin/commands/interactive.mjs +++ b/bin/commands/interactive.mjs @@ -145,7 +145,10 @@ export default async function interactive() { // Handle different value types (boolean, array, string) if (typeof value === 'boolean') { - if (value) cmdParts.push(flag); + if (value) { + cmdParts.push(flag); + executionArgs.push(flag); + } } else if (Array.isArray(value)) { for (const item of value) { cmdParts.push(flag, escapeShellArg(item)); From 91d26b2e9dbcc3b9c626a7faa7ee15d345b3d2f2 Mon Sep 17 00:00:00 2001 From: Aviv Keller <me@aviv.sh> Date: Thu, 17 Apr 2025 09:08:34 -0400 Subject: [PATCH 5/7] suggestion from review Co-authored-by: flakey5 <73616808+flakey5@users.noreply.github.com> --- bin/commands/interactive.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs index 840e2f2d..de5b24e5 100644 --- a/bin/commands/interactive.mjs +++ b/bin/commands/interactive.mjs @@ -139,7 +139,10 @@ export default async function interactive() { for (const [key, { flags }] of Object.entries(options)) { const value = answers[key]; - if (value == null || (Array.isArray(value) && value.length === 0)) continue; // Skip empty values + // Skip empty values + if (value == null || (Array.isArray(value) && value.length === 0)) { + continue; + } const flag = flags[0].split(/[\s,]+/)[0]; // Use the first flag From 4d1a9dea4c49a2a0cdae4eae411c8c50636cb207 Mon Sep 17 00:00:00 2001 From: avivkeller <me@aviv.sh> Date: Thu, 17 Apr 2025 15:43:46 -0400 Subject: [PATCH 6/7] code review --- README.md | 65 +++++++++-------------------------------------------- bin/cli.mjs | 9 -------- 2 files changed, 11 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index b9979dea..1b7124bd 100644 --- a/README.md +++ b/README.md @@ -23,61 +23,18 @@ ## Usage -### `help` - -```sh -npx api-docs-tooling help [command] -``` - -### `generate` - -Generate API documentation from Markdown files. - -```sh -npx api-docs-tooling generate [options] -``` - -**Options:** - -- `-i, --input <patterns...>` Input file patterns (glob) -- `--ignore [patterns...]` Files to ignore -- `-o, --output <dir>` Output directory -- `-v, --version <semver>` Target Node.js version (default: latest) -- `-c, --changelog <url>` Changelog file or URL -- `--git-ref <url>` Git ref/commit URL -- `-t, --target [modes...]` Generator target(s): `json-simple`, `legacy-html`, etc. -- `--no-lint` Skip linting before generation - -### `lint` - -Run the linter on API documentation. - -```sh -npx api-docs-tooling lint [options] ``` +Usage: api-docs-tooling [options] [command] -**Options:** - -- `-i, --input <patterns...>` Input file patterns (glob) -- `--ignore [patterns...]` Files to ignore -- `--disable-rule [rules...]` Disable specific linting rules -- `--dry-run` Run linter without applying changes -- `-r, --reporter <reporter>` Reporter format: `console`, `github`, etc. +CLI tool to generate and lint Node.js API documentation -### `interactive` +Options: + -h, --help display help for command -Launches a fully interactive CLI prompt to guide you through all available options. - -```sh -npx api-docs-tooling interactive -``` - -### `list` - -See available modules for each subsystem. - -```sh -npx api-docs-tooling list generators -npx api-docs-tooling list rules -npx api-docs-tooling list reporters -``` +Commands: + generate [options] Generate API docs + lint [options] Run linter independently + interactive Launch guided CLI wizard + list <types> List the given type + help [command] display help for command +``` \ No newline at end of file diff --git a/bin/cli.mjs b/bin/cli.mjs index 748abb77..c5019de9 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -49,14 +49,5 @@ program .description('List the given type') .action(list); -// Register the help command -program - .command('help [cmd]') - .description('Show help for a command') - .action(cmdName => { - const target = program.commands.find(c => c.name() === cmdName) ?? program; - target.help(); - }); - // Parse and execute command-line arguments program.parse(process.argv); From 780f9e48086101d6bc00e58c16cc5cc352efde8c Mon Sep 17 00:00:00 2001 From: avivkeller <me@aviv.sh> Date: Thu, 17 Apr 2025 18:04:05 -0400 Subject: [PATCH 7/7] show basic help --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b7124bd..49b46857 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ ## Usage +Local invocation: + +```sh +$ npx api-docs-tooling --help +``` + ``` Usage: api-docs-tooling [options] [command] @@ -37,4 +43,65 @@ Commands: interactive Launch guided CLI wizard list <types> List the given type help [command] display help for command -``` \ No newline at end of file +``` + +### `generate` + +``` +Usage: api-docs-tooling generate [options] + +Generate API docs + +Options: + -i, --input <patterns...> Input file patterns (glob) + --ignore [patterns...] Ignore patterns (comma-separated) + -o, --output <dir> Output directory + -p, --threads <number> (default: "12") + -v, --version <semver> Target Node.js version (default: "v22.14.0") + -c, --changelog <url> Changelog URL or path (default: "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md") + --git-ref <url> Git ref/commit URL (default: "https://github.com/nodejs/node/tree/HEAD") + -t, --target [modes...] Target generator modes (choices: "json-simple", "legacy-html", "legacy-html-all", "man-page", "legacy-json", "legacy-json-all", "addon-verify", "api-links", "orama-db") + --no-lint Skip lint before generate + -h, --help display help for command +``` + +### `lint` + +``` +Usage: api-docs-tooling lint [options] + +Run linter independently + +Options: + -i, --input <patterns...> Input file patterns (glob) + --ignore [patterns...] Ignore patterns (comma-separated) + --disable-rule [rules...] Disable linter rules (choices: "duplicate-stability-nodes", "invalid-change-version", "missing-introduced-in") + --dry-run Dry run mode (default: false) + -r, --reporter <reporter> Linter reporter to use + -h, --help display help for command +``` + +### `interactive` + +``` +Usage: api-docs-tooling interactive [options] + +Launch guided CLI wizard + +Options: + -h, --help display help for command +``` + +### `list` + +``` +Usage: api-docs-tooling list [options] <types> + +List the given type + +Arguments: + types The type to list (choices: "generators", "rules", "reporters") + +Options: + -h, --help display help for command +```