From 464549bbb4d717e7f6642c1f580055ed48c5d143 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Fri, 24 Jul 2026 20:36:21 -0700 Subject: [PATCH] feat(config): cosmiconfig --- .changeset/curvy-items-smile.md | 5 + README.md | 8 +- bin/commands/generate.mjs | 1 + docs/configuration.md | 35 +++- package-lock.json | 159 +++++++++++++++++- package.json | 1 + .../configuration/__tests__/index.test.mjs | 75 +++++---- src/utils/configuration/index.mjs | 21 ++- 8 files changed, 252 insertions(+), 53 deletions(-) create mode 100644 .changeset/curvy-items-smile.md diff --git a/.changeset/curvy-items-smile.md b/.changeset/curvy-items-smile.md new file mode 100644 index 00000000..69111d7f --- /dev/null +++ b/.changeset/curvy-items-smile.md @@ -0,0 +1,5 @@ +--- +'@node-core/doc-kit': minor +--- + +Discover and load configuration files with `cosmiconfig`. diff --git a/README.md b/README.md index 367f29fd..7855c142 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,11 @@ Commands: ### `generate` -You must provide either `--target` (one or more generators to run) or -`--config-file` (which supplies the targets). Running `generate` without either -exits with an error pointing you to the help output. +You must provide an input and at least one target through command-line options +or a configuration file. Configuration is discovered automatically using +`cosmiconfig`, or you can select a file explicitly with `--config-file`. +Running `generate` without the required values exits with an error pointing you +to the help output. ``` Usage: @node-core/doc-kit generate [options] diff --git a/bin/commands/generate.mjs b/bin/commands/generate.mjs index 30d9a378..60947204 100644 --- a/bin/commands/generate.mjs +++ b/bin/commands/generate.mjs @@ -12,6 +12,7 @@ const { runGenerators } = createGenerator(); /** * @typedef {Object} CLIOptions + * @property {string} configFile * @property {string[]} input * @property {string[]} target * @property {string[]} ignore diff --git a/docs/configuration.md b/docs/configuration.md index 9e0e46c5..8364b11d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,16 +1,36 @@ # Configuration -`doc-kit`'s CLI supports a `--config-file` option, allowing for custom configuration files to be passed. -These configuration files must be loadable via a `import()` call, so usually JSON or JavaScript files with default exports. +`doc-kit` uses [`cosmiconfig`](https://github.com/cosmiconfig/cosmiconfig) to +discover and load configuration. Run the CLI from your project directory and it +will automatically look for a `doc-kit` property in `package.json`, rc files +such as `.doc-kitrc.yml`, and module files such as `doc-kit.config.mjs`. -By convention, this file is usually named `doc-kit.config.mjs` or `doc-kit.config.json`. +Use `--config-file ` to load a specific file instead of searching. ## Configuration File Format Configuration files can be either: -- **JavaScript/ESM** (`.mjs`, `.js` with `"type": "module"`) +- **JavaScript** (`.js`, `.mjs`, `.cjs`) +- **TypeScript** (`.ts`, when `typescript` is installed in the project) - **JSON** (`.json`) +- **YAML** (`.yaml`, `.yml`, or an extensionless rc file) + +JavaScript and TypeScript configuration files export the configuration object. +JSON and YAML files contain the object directly. A `package.json` configuration +uses the `doc-kit` property: + +```json +{ + "doc-kit": { + "target": ["json"], + "global": { + "input": "doc/api/*.md", + "output": "out" + } + } +} +``` ### Basic Example @@ -94,10 +114,11 @@ export default { ## Configuration Merging -Configurations are merged in the following order (earlier sources take precedence): +Configurations are merged in the following order (higher sources take +precedence): -1. **Config file** (`--config-file`) -2. **CLI options** (command-line arguments) +1. **CLI options** (command-line arguments) +2. **Configuration file** (discovered or selected with `--config-file`) 3. **Default values** (built-in defaults) ## CLI Options Mapping diff --git a/package-lock.json b/package-lock.json index d1d59bb8..9c6b50da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@swc/wasm": "^1.15.46", "acorn": "^8.17.0", "commander": "^15.0.0", + "cosmiconfig": "^9.0.2", "dedent": "^1.7.2", "estree-util-to-js": "^2.0.0", "estree-util-visit": "^2.0.0", @@ -113,6 +114,29 @@ "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", "license": "MIT" }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -3628,7 +3652,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -3960,6 +3983,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -4193,6 +4225,32 @@ "dev": true, "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4488,6 +4546,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4501,6 +4568,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -6031,6 +6107,31 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6104,6 +6205,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -6616,11 +6723,16 @@ "node": ">=8" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, "funding": [ { "type": "github", @@ -6656,6 +6768,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6728,6 +6846,12 @@ "inBundle": true, "license": "MIT" }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/lint-staged": { "version": "17.0.8", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", @@ -8410,6 +8534,18 @@ "quansync": "^0.2.7" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -8445,6 +8581,24 @@ "parse-statements": "1.0.11" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-statements": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", @@ -8522,7 +8676,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { diff --git a/package.json b/package.json index a29c39e2..663ff2aa 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@swc/wasm": "^1.15.46", "acorn": "^8.17.0", "commander": "^15.0.0", + "cosmiconfig": "^9.0.2", "dedent": "^1.7.2", "estree-util-to-js": "^2.0.0", "estree-util-visit": "^2.0.0", diff --git a/src/utils/configuration/__tests__/index.test.mjs b/src/utils/configuration/__tests__/index.test.mjs index 3de2dc08..b32d5b19 100644 --- a/src/utils/configuration/__tests__/index.test.mjs +++ b/src/utils/configuration/__tests__/index.test.mjs @@ -4,7 +4,12 @@ import { describe, it, mock, beforeEach } from 'node:test'; // Mock dependencies const mockParseChangelog = mock.fn(async changelog => [changelog]); const mockParseIndex = mock.fn(async index => [index]); -const mockImportFromURL = mock.fn(async () => ({})); +const mockConfigSearch = mock.fn(async () => null); +const mockConfigLoad = mock.fn(async () => null); +const mockCosmiconfig = mock.fn(() => ({ + search: mockConfigSearch, + load: mockConfigLoad, +})); const createMockConfig = (overrides = {}) => ({ global: {}, @@ -34,8 +39,8 @@ mock.module('../../../parsers/markdown.mjs', { }, }); -mock.module('../../loaders.mjs', { - namedExports: { importFromURL: mockImportFromURL }, +mock.module('cosmiconfig', { + namedExports: { cosmiconfig: mockCosmiconfig }, }); const { @@ -49,9 +54,12 @@ const { // Helper to reset all mocks const resetAllMocks = () => { - [mockParseChangelog, mockParseIndex, mockImportFromURL].forEach(m => - m.mock.resetCalls() - ); + [ + mockParseChangelog, + mockParseIndex, + mockConfigSearch, + mockConfigLoad, + ].forEach(m => m.mock.resetCalls()); }; // Helper to count specific function calls @@ -64,24 +72,20 @@ describe('config.mjs', () => { describe('loadConfigFile', () => { it('should load config from file path', async () => { const mockConfig = { custom: 'config' }; - mockImportFromURL.mock.mockImplementationOnce(async () => mockConfig); + mockConfigLoad.mock.mockImplementationOnce(async filePath => ({ + config: mockConfig, + filepath: filePath, + })); const result = await loadConfigFile('path/to/config.mjs'); assert.deepStrictEqual(result, mockConfig); - assert.strictEqual(mockImportFromURL.mock.calls.length, 1); + assert.strictEqual(mockConfigLoad.mock.calls.length, 1); assert.strictEqual( - mockImportFromURL.mock.calls[0].arguments[0], + mockConfigLoad.mock.calls[0].arguments[0], 'path/to/config.mjs' ); - }); - - it('should return empty object for falsy paths', async () => { - for (const falsyValue of ['', null, undefined, 0, false]) { - const result = await loadConfigFile(falsyValue); - assert.deepStrictEqual(result, {}); - } - assert.strictEqual(mockImportFromURL.mock.calls.length, 0); + assert.strictEqual(mockConfigSearch.mock.calls.length, 0); }); }); @@ -156,8 +160,8 @@ describe('config.mjs', () => { describe('createRunConfiguration', () => { it('should let defined CLI options override the config file', async () => { - mockImportFromURL.mock.mockImplementationOnce(async () => - createMockConfig({ + mockConfigLoad.mock.mockImplementationOnce(async () => ({ + config: createMockConfig({ global: { input: 'custom-src/', output: 'config-dist/', @@ -165,8 +169,9 @@ describe('config.mjs', () => { }, target: ['html'], threads: 1, - }) - ); + }), + filepath: 'config.mjs', + })); const config = await createRunConfiguration({ configFile: 'config.mjs', @@ -188,15 +193,16 @@ describe('config.mjs', () => { const changelogUrl = 'https://example.com/changelog.md'; const indexUrl = 'https://example.com/index.md'; - mockImportFromURL.mock.mockImplementationOnce(async () => - createMockConfig({ + mockConfigLoad.mock.mockImplementationOnce(async () => ({ + config: createMockConfig({ global: { version: '20.0.0', changelog: changelogUrl, index: indexUrl, }, - }) - ); + }), + filepath: 'config.mjs', + })); resetAllMocks(); // Clear calls from getDefaultConfig await createRunConfiguration({ configFile: 'config.mjs' }); @@ -231,16 +237,18 @@ describe('config.mjs', () => { assert.ok(config); assert.strictEqual(config.threads, 4); - assert.strictEqual(mockImportFromURL.mock.calls.length, 0); + assert.strictEqual(mockConfigLoad.mock.calls.length, 0); + assert.strictEqual(mockConfigSearch.mock.calls.length, 1); }); it('should handle generator-specific overrides', async () => { - mockImportFromURL.mock.mockImplementationOnce(async () => - createMockConfig({ + mockConfigLoad.mock.mockImplementationOnce(async () => ({ + config: createMockConfig({ global: { version: '20.0.0' }, json: { minify: false, version: '18.0.0' }, - }) - ); + }), + filepath: 'config.mjs', + })); const config = await createRunConfiguration({ configFile: 'config.mjs', @@ -280,9 +288,10 @@ describe('config.mjs', () => { for (const { name, value, mockFn, configKey } of testCases) { it(`should transform ${name} only for strings`, async () => { - mockImportFromURL.mock.mockImplementationOnce(async () => - createMockConfig({ global: { [configKey]: value } }) - ); + mockConfigLoad.mock.mockImplementationOnce(async () => ({ + config: createMockConfig({ global: { [configKey]: value } }), + filepath: 'config.mjs', + })); resetAllMocks(); await createRunConfiguration({ configFile: 'config.mjs' }); diff --git a/src/utils/configuration/index.mjs b/src/utils/configuration/index.mjs index e04c0bba..61441bfa 100644 --- a/src/utils/configuration/index.mjs +++ b/src/utils/configuration/index.mjs @@ -1,6 +1,7 @@ import { cpus } from 'node:os'; import { isMainThread } from 'node:worker_threads'; +import { cosmiconfig } from 'cosmiconfig'; import { coerce } from 'semver'; import { CHANGELOG_URL, populate } from './templates.mjs'; @@ -9,9 +10,10 @@ import logger from '../../logger/index.mjs'; import { parseChangelog, parseIndex } from '../../parsers/markdown.mjs'; import { enforceArray } from '../array.mjs'; import { leftHandAssign } from '../generators.mjs'; -import { importFromURL } from '../loaders.mjs'; import { deepMerge, lazy } from '../misc.mjs'; +const configExplorer = cosmiconfig('doc-kit'); + /** * Get's the default configuration */ @@ -52,13 +54,18 @@ export const getDefaultConfig = lazy(config => ); /** - * Loads a configuration file from a URL or file path. + * Loads an explicit configuration file or searches for one using cosmiconfig. * - * @param {string} filePath - The URL or file path to the configuration file - * @returns {Promise>} The imported configuration object, or an empty object if no path provided + * @param {string} [filePath] - The path to an explicit configuration file + * @returns {Promise>} The loaded configuration object, or an empty object if none is found */ -export const loadConfigFile = filePath => - filePath ? importFromURL(filePath) : {}; +export const loadConfigFile = async filePath => { + const result = filePath + ? await configExplorer.load(filePath) + : await configExplorer.search(); + + return result?.config ?? {}; +}; /** * Transforms configuration values that need async processing or coercion. @@ -122,7 +129,7 @@ export const assertRunnableOptions = config => { if (!config.target || !config.global?.input) { throw new Error( 'Both a `target` and an `input` must be provided, either via ' + - '`--target`/`--input` or a `--config-file`. ' + + '`--target`/`--input` or a configuration file. ' + 'Run `doc-kit generate --help` for usage.' ); }