Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-dingos-detect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@node-core/doc-kit': patch
---

Automatically discover the first supported `doc-kit.config.*` file in the current working directory when `--config-file` is omitted.
34 changes: 34 additions & 0 deletions src/utils/__tests__/loaders.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, mock, afterEach } from 'node:test';

let fileContent = 'hello from file';
Expand Down Expand Up @@ -66,4 +69,35 @@ describe('importFromURL', () => {
assert.ok(typeof mod.loadFromURL === 'function');
assert.ok(typeof mod.importFromURL === 'function');
});

const typeScriptConfigCases = [
{
extension: 'ts',
source:
"const input: string = 'src';\nexport default { global: { input } };\n",
},
{
extension: 'cts',
source:
"const input: string = 'src';\nmodule.exports = { global: { input } };\n",
},
{
extension: 'mts',
source:
"const input: string = 'src';\nexport default { global: { input } };\n",
},
];

for (const { extension, source } of typeScriptConfigCases) {
it(`should import a type-strippable .${extension} config file`, async t => {
const directory = await mkdtemp(join(tmpdir(), 'doc-kit-config-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const configFile = join(directory, `doc-kit.config.${extension}`);
await writeFile(configFile, source);

const config = await importFromURL(configFile);

assert.deepStrictEqual(config, { global: { input: 'src' } });
});
}
});
97 changes: 93 additions & 4 deletions src/utils/configuration/__tests__/index.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import assert from 'node:assert';
import * as nodeFs from 'node:fs';
import { join } from 'node:path';
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 mockExistsSync = mock.fn(() => false);

const CONFIG_FILE_NAMES = [
'doc-kit.config.js',
'doc-kit.config.cjs',
'doc-kit.config.mjs',
'doc-kit.config.ts',
'doc-kit.config.cts',
'doc-kit.config.mts',
];

const createMockConfig = (overrides = {}) => ({
global: {},
Expand Down Expand Up @@ -37,6 +49,9 @@ mock.module('../../../parsers/markdown.mjs', {
mock.module('../../loaders.mjs', {
namedExports: { importFromURL: mockImportFromURL },
});
mock.module('node:fs', {
namedExports: { ...nodeFs, existsSync: mockExistsSync },
});

const {
assertRunnableOptions,
Expand All @@ -49,9 +64,14 @@ const {

// Helper to reset all mocks
const resetAllMocks = () => {
[mockParseChangelog, mockParseIndex, mockImportFromURL].forEach(m =>
m.mock.resetCalls()
);
[
mockParseChangelog,
mockParseIndex,
mockImportFromURL,
mockExistsSync,
].forEach(m => m.mock.resetCalls());
mockImportFromURL.mock.mockImplementation(async () => ({}));
mockExistsSync.mock.mockImplementation(() => false);
};

// Helper to count specific function calls
Expand Down Expand Up @@ -184,6 +204,71 @@ describe('config.mjs', () => {
assert.strictEqual(config.web.showSearchBox, true);
});

for (const [index, fileName] of CONFIG_FILE_NAMES.entries()) {
it(`should auto-detect ${fileName} in the current directory`, async () => {
const detectedConfigFile = join(process.cwd(), fileName);
const mockConfig = createMockConfig({
global: { input: 'auto-detected-src/' },
});
mockExistsSync.mock.mockImplementation(
filePath => filePath === detectedConfigFile
);
mockImportFromURL.mock.mockImplementationOnce(async () => mockConfig);

const config = await createRunConfiguration({});

assert.strictEqual(config.global.input, 'auto-detected-src/');
assert.deepStrictEqual(
mockExistsSync.mock.calls.map(call => call.arguments[0]),
CONFIG_FILE_NAMES.slice(0, index + 1).map(candidate =>
join(process.cwd(), candidate)
)
);
assert.strictEqual(mockImportFromURL.mock.calls.length, 1);
assert.strictEqual(
mockImportFromURL.mock.calls[0].arguments[0],
detectedConfigFile
);
});
}

it('should use the first matching config file by priority', async () => {
const existingConfigFiles = new Set([
join(process.cwd(), 'doc-kit.config.cjs'),
join(process.cwd(), 'doc-kit.config.mjs'),
join(process.cwd(), 'doc-kit.config.cts'),
]);
mockExistsSync.mock.mockImplementation(filePath =>
existingConfigFiles.has(filePath)
);

await createRunConfiguration({});

assert.strictEqual(mockImportFromURL.mock.calls.length, 1);
assert.strictEqual(
mockImportFromURL.mock.calls[0].arguments[0],
join(process.cwd(), 'doc-kit.config.cjs')
);
});

it('should prefer an explicit config file', async () => {
mockImportFromURL.mock.mockImplementationOnce(async () =>
createMockConfig({ global: { input: 'explicit-src/' } })
);

const config = await createRunConfiguration({
configFile: 'explicit-config.mjs',
});

assert.strictEqual(config.global.input, 'explicit-src/');
assert.strictEqual(mockExistsSync.mock.calls.length, 0);
assert.strictEqual(mockImportFromURL.mock.calls.length, 1);
assert.strictEqual(
mockImportFromURL.mock.calls[0].arguments[0],
'explicit-config.mjs'
);
});

it('should transform string values only once', async () => {
const changelogUrl = 'https://example.com/changelog.md';
const indexUrl = 'https://example.com/index.md';
Expand Down Expand Up @@ -223,14 +308,18 @@ describe('config.mjs', () => {
assert.strictEqual(config.chunkSize, 1);
});

it('should work without config file', async () => {
it('should use an empty config when no config file is present', async () => {
const config = await createRunConfiguration({
version: '20.0.0',
threads: 4,
});

assert.ok(config);
assert.strictEqual(config.threads, 4);
assert.deepStrictEqual(
mockExistsSync.mock.calls.map(call => call.arguments[0]),
CONFIG_FILE_NAMES.map(fileName => join(process.cwd(), fileName))
);
assert.strictEqual(mockImportFromURL.mock.calls.length, 0);
});

Expand Down
21 changes: 19 additions & 2 deletions src/utils/configuration/index.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { cpus } from 'node:os';
import { join } from 'node:path';
import { isMainThread } from 'node:worker_threads';

import { coerce } from 'semver';
Expand All @@ -12,6 +14,15 @@ import { leftHandAssign } from '../generators.mjs';
import { importFromURL } from '../loaders.mjs';
import { deepMerge, lazy } from '../misc.mjs';

const CONFIG_FILE_NAMES = [
'doc-kit.config.js',
'doc-kit.config.cjs',
'doc-kit.config.mjs',
'doc-kit.config.ts',
'doc-kit.config.cts',
'doc-kit.config.mts',
];

/**
* Get's the default configuration
*/
Expand Down Expand Up @@ -129,15 +140,21 @@ export const assertRunnableOptions = config => {
};

/**
* Creates a complete run configuration by merging config file, user options, and defaults.
* Creates a complete run configuration by merging an explicit or auto-detected
* config file, user options, and defaults.
* Processes and validates configuration values including version coercion, changelog parsing,
* and constraint enforcement for threads and chunk size.
*
* @param {import('../../../bin/commands/generate.mjs').CLIOptions} options - User-provided configuration options
* @returns {Promise<import('./types').Configuration>} The configuration
*/
export const createRunConfiguration = async options => {
const config = await loadConfigFile(options.configFile);
const configFile =
options.configFile ??
CONFIG_FILE_NAMES.map(fileName => join(process.cwd(), fileName)).find(
existsSync
);
const config = await loadConfigFile(configFile);
Comment thread
AysajanE marked this conversation as resolved.
config.target &&= enforceArray(config.target);

// Resolve user configuration first so dynamic defaults can use it
Expand Down