From 4d7db68afa0ec154d2674eb6f94af35b28a1851e Mon Sep 17 00:00:00 2001 From: Inuka Wijerathna Date: Sun, 5 Jul 2026 22:48:16 +0530 Subject: [PATCH] cli: add progress bar for doc generation Adds a cli-progress-based progress bar (src/utils/progress-bar.mjs) that tracks how many of the requested generators have completed while `generate` runs. Rendering is skipped when stdout isn't a TTY so CI logs and piped output stay clean. runGenerators() now accepts an optional onGeneratorComplete callback, invoked once per requested target as its result becomes ready, which the generate command wires up to the progress bar. Refs: #58 --- bin/commands/generate.mjs | 11 ++- package-lock.json | 113 +++++++++++++--------- package.json | 1 + src/__tests__/generators.test.mjs | 13 +++ src/generators.mjs | 13 ++- src/utils/__tests__/progress-bar.test.mjs | 80 +++++++++++++++ src/utils/progress-bar.mjs | 77 +++++++++++++++ 7 files changed, 260 insertions(+), 48 deletions(-) create mode 100644 src/utils/__tests__/progress-bar.test.mjs create mode 100644 src/utils/progress-bar.mjs diff --git a/bin/commands/generate.mjs b/bin/commands/generate.mjs index 30d9a378..e291cdad 100644 --- a/bin/commands/generate.mjs +++ b/bin/commands/generate.mjs @@ -6,6 +6,7 @@ import { assertRunnableOptions, setConfig, } from '../../src/utils/configuration/index.mjs'; +import createProgressBar from '../../src/utils/progress-bar.mjs'; import { errorWrap } from '../utils.mjs'; const { runGenerators } = createGenerator(); @@ -67,6 +68,14 @@ export default new Command('generate') const config = await setConfig(opts); assertRunnableOptions(config); - await runGenerators(config); + const progressBar = createProgressBar(); + + progressBar.start(config.target.length); + + await runGenerators(config, { + onGeneratorComplete: progressBar.increment, + }); + + progressBar.stop(); }) ); diff --git a/package-lock.json b/package-lock.json index cabffecc..f650f67b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@rollup/plugin-virtual": "^3.0.2", "@swc/html-wasm": "^1.15.43", "acorn": "^8.17.0", + "cli-progress": "^3.12.0", "commander": "^14.0.3", "dedent": "^1.7.2", "estree-util-to-js": "^2.0.0", @@ -133,27 +134,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -948,7 +928,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1160,7 +1139,6 @@ "resolved": "https://registry.npmjs.org/@orama/cuid2/-/cuid2-2.2.3.tgz", "integrity": "sha512-Lcak3chblMejdlSHgYU2lS2cdOhDpU6vkfIJH4m+YKvqQyLqs1bB8+w6NT1MG5bO12NUK2GFc34Mn2xshMIQ1g==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "^1.1.5" } @@ -1178,8 +1156,7 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/@orama/oramacore-events-parser/-/oramacore-events-parser-0.0.5.tgz", "integrity": "sha512-yAuSwog+HQBAXgZ60TNKEwu04y81/09mpbYBCmz1RCxnr4ObNY2JnPZI7HmALbjAhLJ8t5p+wc2JHRK93ubO4w==", - "license": "AGPL-3.0", - "peer": true + "license": "AGPL-3.0" }, "node_modules/@orama/stopwords": { "version": "3.1.18", @@ -2615,9 +2592,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2634,9 +2608,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2653,9 +2624,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2672,9 +2640,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2691,9 +2656,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2710,9 +2672,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3309,6 +3268,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3456,6 +3416,7 @@ "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", @@ -3855,6 +3816,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4325,6 +4287,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-progress/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-truncate": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", @@ -4997,6 +5015,7 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -8713,6 +8732,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8802,6 +8822,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8944,6 +8965,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -9027,6 +9049,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9596,8 +9619,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.8.5", @@ -10583,6 +10605,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, diff --git a/package.json b/package.json index 516d0657..f4d304f5 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@rollup/plugin-virtual": "^3.0.2", "@swc/html-wasm": "^1.15.43", "acorn": "^8.17.0", + "cli-progress": "^3.12.0", "commander": "^14.0.3", "dedent": "^1.7.2", "estree-util-to-js": "^2.0.0", diff --git a/src/__tests__/generators.test.mjs b/src/__tests__/generators.test.mjs index bf503b4f..7dfe4431 100644 --- a/src/__tests__/generators.test.mjs +++ b/src/__tests__/generators.test.mjs @@ -122,6 +122,19 @@ describe('createGenerator orchestration', () => { ]); }); + it('calls onGeneratorComplete once per requested target, not per dependency', async () => { + const { runGenerators } = createGenerator(); + + const completed = []; + + await runGenerators( + { target: ['gen-a', 'gen-b'], threads: 1 }, + { onGeneratorComplete: name => completed.push(name) } + ); + + assert.deepStrictEqual(completed.sort(), ['gen-a', 'gen-b']); + }); + it('does not re-run a target that is also another target dependency', async () => { const { runGenerators } = createGenerator(); diff --git a/src/generators.mjs b/src/generators.mjs index 4f87ec29..38308064 100644 --- a/src/generators.mjs +++ b/src/generators.mjs @@ -90,9 +90,12 @@ const createGenerator = () => { * Runs all requested generators with their dependencies. * * @param {import('./utils/configuration/types').Configuration} options - Runtime options + * @param {Object} [callbacks] - Optional lifecycle callbacks + * @param {(name: string) => void} [callbacks.onGeneratorComplete] - Called once per + * requested generator (not its dependencies) as soon as its result is ready * @returns {Promise} Results of all requested generators */ - const runGenerators = async configuration => { + const runGenerators = async (configuration, { onGeneratorComplete } = {}) => { const { target: generators, threads } = configuration; generatorsLogger.debug(`Starting pipeline`, { @@ -118,7 +121,13 @@ const createGenerator = () => { // Start all collections in parallel (don't await sequentially). Consuming // through the shared path lets the final read also trigger eviction. const results = await Promise.all( - generators.map(name => cache.consume(name)) + generators.map(name => + cache.consume(name).then(result => { + onGeneratorComplete?.(name); + + return result; + }) + ) ); await pool.destroy(); diff --git a/src/utils/__tests__/progress-bar.test.mjs b/src/utils/__tests__/progress-bar.test.mjs new file mode 100644 index 00000000..f1c6bb20 --- /dev/null +++ b/src/utils/__tests__/progress-bar.test.mjs @@ -0,0 +1,80 @@ +'use strict'; + +import assert from 'node:assert/strict'; +import { describe, it, mock, beforeEach, afterEach } from 'node:test'; + +const singleBarInstances = []; + +mock.module('cli-progress', { + defaultExport: { + Presets: { shades_classic: {} }, + SingleBar: mock.fn(function SingleBar(options) { + const instance = { + options, + start: mock.fn(), + increment: mock.fn(), + stop: mock.fn(), + }; + + singleBarInstances.push(instance); + + return instance; + }), + }, +}); + +const createProgressBar = (await import('../progress-bar.mjs')).default; + +describe('createProgressBar', () => { + let originalIsTTY; + + beforeEach(() => { + originalIsTTY = process.stdout.isTTY; + singleBarInstances.length = 0; + }); + + afterEach(() => { + process.stdout.isTTY = originalIsTTY; + }); + + it('does not construct a bar and no-ops when stdout is not a TTY', () => { + process.stdout.isTTY = false; + + const progressBar = createProgressBar(); + + assert.doesNotThrow(() => { + progressBar.start(3); + progressBar.increment('ast'); + progressBar.stop(); + }); + + assert.strictEqual(singleBarInstances.length, 0); + }); + + it('drives a real SingleBar instance when stdout is a TTY', () => { + process.stdout.isTTY = true; + + const progressBar = createProgressBar(); + + assert.strictEqual(singleBarInstances.length, 1); + + const [bar] = singleBarInstances; + + progressBar.start(3); + assert.strictEqual(bar.start.mock.callCount(), 1); + assert.deepStrictEqual(bar.start.mock.calls[0].arguments, [ + 3, + 0, + { generator: 'N/A' }, + ]); + + progressBar.increment('ast'); + assert.strictEqual(bar.increment.mock.callCount(), 1); + assert.deepStrictEqual(bar.increment.mock.calls[0].arguments, [ + { generator: 'ast' }, + ]); + + progressBar.stop(); + assert.strictEqual(bar.stop.mock.callCount(), 1); + }); +}); diff --git a/src/utils/progress-bar.mjs b/src/utils/progress-bar.mjs new file mode 100644 index 00000000..ad2b16c9 --- /dev/null +++ b/src/utils/progress-bar.mjs @@ -0,0 +1,77 @@ +'use strict'; + +import cliProgress from 'cli-progress'; + +/** + * @typedef {Object} ProgressBar + * @property {(total: number) => void} start - Starts the bar for a known number of steps. + * @property {(generatorName: string) => void} increment - Advances the bar by one step. + * @property {() => void} stop - Stops and clears the bar. + */ + +/** + * Creates a progress bar for tracking generator pipeline progress in the CLI. + * + * Rendering is skipped when stdout is not a TTY (CI logs, piped output, tests) + * so non-interactive output never receives raw escape codes or bar frames. + * + * @returns {ProgressBar} + */ +const createProgressBar = () => { + if (!process.stdout.isTTY) { + /** + * No-op progress bar used when rendering would produce unusable output. + * + * @type {ProgressBar} + */ + const noopProgressBar = { + /** No-op start. */ + start() {}, + /** No-op increment. */ + increment() {}, + /** No-op stop. */ + stop() {}, + }; + + return noopProgressBar; + } + + const bar = new cliProgress.SingleBar( + { + format: + 'Generating docs |{bar}| {percentage}% | {value}/{total} generators | {generator}', + hideCursor: true, + clearOnComplete: true, + }, + cliProgress.Presets.shades_classic + ); + + /** + * Starts the bar for a known number of steps. + * + * @param {number} total - Total number of generators to be run. + */ + const start = total => { + bar.start(total, 0, { generator: 'N/A' }); + }; + + /** + * Advances the bar by one step. + * + * @param {string} generatorName - Name of the generator that just completed. + */ + const increment = generatorName => { + bar.increment({ generator: generatorName }); + }; + + /** + * Stops and clears the bar. + */ + const stop = () => { + bar.stop(); + }; + + return { start, increment, stop }; +}; + +export default createProgressBar;