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;