From 34d514945462decdce101e595bdc4229cd166498 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 12 Jul 2026 19:43:08 -0700 Subject: [PATCH 1/6] feat: add configuration docs --- package.json | 2 +- scripts/markdown/api/configuration.mjs | 186 ++++++++++++++++++++ scripts/markdown/{api.mjs => api/index.mjs} | 13 +- scripts/markdown/api/utils.mjs | 11 ++ 4 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 scripts/markdown/api/configuration.mjs rename scripts/markdown/{api.mjs => api/index.mjs} (76%) create mode 100644 scripts/markdown/api/utils.mjs diff --git a/package.json b/package.json index e699ba93..4f43cf02 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "build:data:blog": "node scripts/data/blog.mjs", "build:data:sponsors": "node scripts/data/sponsors.mjs", "build:md": "npm-run-all build:md:*", - "build:md:api": "node scripts/markdown/api.mjs", + "build:md:api": "node scripts/markdown/api/index.mjs && node scripts/markdown/api/configuration.mjs", "build:md:readmes": "node scripts/markdown/readmes.mjs", "build:md:governance": "node scripts/markdown/governance.mjs", "build:html": "node scripts/html/index.mjs", diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs new file mode 100644 index 00000000..6d8c9c1c --- /dev/null +++ b/scripts/markdown/api/configuration.mjs @@ -0,0 +1,186 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path/posix'; +import { major } from 'semver'; +import { sources } from './utils.mjs'; + +const definitionName = ref => + ref?.startsWith('#/definitions/') ? ref.slice('#/definitions/'.length) : ''; + +const collapse = text => text?.replace(/\s+/g, ' ').trim(); + +const trimImports = text => + collapse(text.replace(/import\((?:"[^"]*"|'[^']*')\)\./g, '')); + +const literal = value => + typeof value === 'string' ? JSON.stringify(value) : String(value); + +const isStructural = schema => + schema.type === 'object' || Boolean(schema.properties); + +/** + * Render a schema as the type text used inside a doc-kit `{...}` annotation. + * Named definitions stay names (the type map links them to the generated API + * type pages), `tsType` schemas keep their TypeScript text verbatim, and + * anonymous objects collapse to `object`. + */ +const summarize = (schema, definitions, stack = new Set()) => { + if (!schema) return 'unknown'; + + const ref = definitionName(schema.$ref); + if (ref) { + const target = definitions[ref]; + // Objects keep their linkable name; unions, enums, and scalars read better + // inlined. Cycles (for example RuleSetCondition) fall back to the name. + if (!target || stack.has(ref) || isStructural(target)) return ref; + return summarize(target, definitions, new Set(stack).add(ref)); + } + + if (schema.tsType) return trimImports(schema.tsType); + + if (schema.enum) { + return [...new Set(schema.enum.map(literal))].join(' | '); + } + + if (schema.instanceof) return schema.instanceof; + + const alternatives = schema.anyOf ?? schema.oneOf; + if (alternatives) { + let parts = [ + ...new Set( + alternatives.map(alternative => + summarize(alternative, definitions, stack) + ) + ), + ]; + if (parts.includes('true') && parts.includes('false')) { + parts = parts + .map(part => (part === 'true' ? 'boolean' : part)) + .filter(part => part !== 'false'); + } + return parts.join(' | '); + } + + if (schema.type === 'array') { + const item = + schema.items && !Array.isArray(schema.items) + ? summarize(schema.items, definitions, stack) + : 'any'; + return item.includes(' ') ? `(${item})[]` : `${item}[]`; + } + + if (isStructural(schema) || schema.additionalProperties) return 'object'; + if (schema.type === 'integer') return 'number'; + if (Array.isArray(schema.type)) return schema.type.join(' | '); + if (schema.type) return schema.type; + + return 'unknown'; +}; + +/** + * Find the single object-with-properties schema an option resolves to, so its + * sub-options can be documented in place. Options offering several object + * shapes (for example `cache`) are left to their linked type pages. + */ +const expandableObject = (schema, definitions, stack = new Set()) => { + const ref = definitionName(schema.$ref); + if (ref) { + const target = definitions[ref]; + if (!target || stack.has(ref)) return null; + return expandableObject(target, definitions, new Set(stack).add(ref)); + } + + if (schema.properties) return schema; + + const alternatives = schema.anyOf ?? schema.oneOf; + if (alternatives) { + const objects = alternatives + .map(alternative => expandableObject(alternative, definitions, stack)) + .filter(Boolean); + return objects.length === 1 ? objects[0] : null; + } + + return null; +}; + +const descriptionOf = (schema, definitions) => { + if (schema.description) return collapse(schema.description); + + const ref = definitionName(schema.$ref); + return ref ? collapse(definitions[ref]?.description) : undefined; +}; + +const propertyBullet = (name, schema, definitions) => { + const description = descriptionOf(schema, definitions); + const type = summarize(schema, definitions); + return ` * \`${name}\` {${type}}${description ? ` - ${description}` : ''}`; +}; + +const renderOption = (path, schema, definitions, depth) => { + const lines = [`${'#'.repeat(depth + 2)} \`${path}\``, '']; + + const description = descriptionOf(schema, definitions); + if (description) lines.push(description, ''); + + lines.push(`* Type: {${summarize(schema, definitions)}}`); + + const objectSchema = expandableObject(schema, definitions); + const properties = Object.entries(objectSchema?.properties ?? {}); + + if (depth === 0) { + lines.push(''); + for (const [name, child] of properties) { + lines.push(...renderOption(`${path}.${name}`, child, definitions, 1)); + } + } else { + // Sub-option details nest under the type annotation. + for (const [name, child] of properties) { + lines.push(propertyBullet(name, child, definitions)); + } + lines.push(''); + } + + return lines; +}; + +const generate = async packageDir => { + const { version } = JSON.parse( + await readFile(join(packageDir, 'package.json'), 'utf8') + ); + const schema = JSON.parse( + await readFile(join(packageDir, 'schemas', 'WebpackOptions.json'), 'utf8') + ); + + const outputDir = join('pages', 'docs', 'api', `v${major(version)}.x`); + + const { definitions } = schema; + + const lines = [ + '---', + 'source: https://github.com/webpack/webpack/edit/main/schemas/WebpackOptions.json', + '---', + '', + '# Configuration Options', + '', + 'webpack is configured with an options object, usually exported from a `webpack.config.js` file. ' + + 'Every build validates that object against the options schema ' + + '([`schemas/WebpackOptions.json`](https://github.com/webpack/webpack/blob/main/schemas/WebpackOptions.json)), ' + + 'so unknown or malformed options fail with a descriptive error. ' + + 'This page is generated from that schema and lists every supported option; ' + + 'named types link to their full definitions in the API documentation.', + '', + ]; + + for (const [name, child] of Object.entries(schema.properties)) { + lines.push(...renderOption(name, child, definitions, 0)); + } + + await writeFile( + join(outputDir, 'configuration.md'), + lines.join('\n'), + 'utf8' + ); +}; + +for (const source of sources) { + await generate(source); +} diff --git a/scripts/markdown/api.mjs b/scripts/markdown/api/index.mjs similarity index 76% rename from scripts/markdown/api.mjs rename to scripts/markdown/api/index.mjs index b4a754c7..147b79ea 100644 --- a/scripts/markdown/api.mjs +++ b/scripts/markdown/api/index.mjs @@ -1,9 +1,8 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path/posix'; import { Application } from 'typedoc'; import { major } from 'semver'; - -const CACHE_DIR = join('.', '.cache', 'webpack'); +import { sources } from './utils.mjs'; const generate = async packageDir => { const { version } = JSON.parse( @@ -42,14 +41,6 @@ const generate = async packageDir => { await app.generateOutputs(project); }; -const [packageDir] = process.argv.slice(2); - -const sources = packageDir - ? [packageDir] - : (await readdir(CACHE_DIR, { withFileTypes: true })) - .filter(entry => entry.isDirectory()) - .map(entry => join(CACHE_DIR, entry.name)); - for (const source of sources) { await generate(source); } diff --git a/scripts/markdown/api/utils.mjs b/scripts/markdown/api/utils.mjs new file mode 100644 index 00000000..866b2a9b --- /dev/null +++ b/scripts/markdown/api/utils.mjs @@ -0,0 +1,11 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path/posix'; + +const [packageDir] = process.argv.slice(2); +const cacheDir = join('.', '.cache', 'webpack'); + +export const sources = packageDir + ? [packageDir] + : (await readdir(cacheDir, { withFileTypes: true })) + .filter(entry => entry.isDirectory()) + .map(entry => join(cacheDir, entry.name)); From 3c3e65d8969f2e0faba2e43206be5c95aea7a06e Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 12 Jul 2026 19:48:41 -0700 Subject: [PATCH 2/6] feat: add configuration docs --- scripts/markdown/api/configuration.mjs | 19 ++++++++----------- scripts/markdown/api/index.mjs | 9 +++------ scripts/markdown/api/utils.mjs | 7 ++++++- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs index 6d8c9c1c..d75c039f 100644 --- a/scripts/markdown/api/configuration.mjs +++ b/scripts/markdown/api/configuration.mjs @@ -1,7 +1,7 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { join } from 'node:path/posix'; import { major } from 'semver'; -import { sources } from './utils.mjs'; +import { sources, getPackageFile } from './utils.mjs'; const definitionName = ref => ref?.startsWith('#/definitions/') ? ref.slice('#/definitions/'.length) : ''; @@ -143,16 +143,13 @@ const renderOption = (path, schema, definitions, depth) => { }; const generate = async packageDir => { - const { version } = JSON.parse( - await readFile(join(packageDir, 'package.json'), 'utf8') + const { version } = await getPackageFile(packageDir); + const schema = await getPackageFile( + packageDir, + 'schemas/WebpackOptions.json' ); - const schema = JSON.parse( - await readFile(join(packageDir, 'schemas', 'WebpackOptions.json'), 'utf8') - ); - - const outputDir = join('pages', 'docs', 'api', `v${major(version)}.x`); - const { definitions } = schema; + const outputDir = join(outputDir, `v${major(version)}.x`); const lines = [ '---', @@ -171,7 +168,7 @@ const generate = async packageDir => { ]; for (const [name, child] of Object.entries(schema.properties)) { - lines.push(...renderOption(name, child, definitions, 0)); + lines.push(...renderOption(name, child, schema.definitions, 0)); } await writeFile( diff --git a/scripts/markdown/api/index.mjs b/scripts/markdown/api/index.mjs index 147b79ea..a4718a79 100644 --- a/scripts/markdown/api/index.mjs +++ b/scripts/markdown/api/index.mjs @@ -1,17 +1,14 @@ -import { readFile } from 'node:fs/promises'; import { join } from 'node:path/posix'; import { Application } from 'typedoc'; import { major } from 'semver'; -import { sources } from './utils.mjs'; +import { sources, outputDir, getPackageFile } from './utils.mjs'; const generate = async packageDir => { - const { version } = JSON.parse( - await readFile(join(packageDir, 'package.json'), 'utf8') - ); + const { version } = await getPackageFile(packageDir); const app = await Application.bootstrapWithPlugins({ entryPoints: [join(packageDir, 'types.d.ts')], - out: join('pages', 'docs', 'api', `v${major(version)}.x`), + out: join(outputDir, `v${major(version)}.x`), publicPath: `/docs/api/v${major(version)}.x/`, plugin: [ diff --git a/scripts/markdown/api/utils.mjs b/scripts/markdown/api/utils.mjs index 866b2a9b..83216f38 100644 --- a/scripts/markdown/api/utils.mjs +++ b/scripts/markdown/api/utils.mjs @@ -1,4 +1,4 @@ -import { readdir } from 'node:fs/promises'; +import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path/posix'; const [packageDir] = process.argv.slice(2); @@ -9,3 +9,8 @@ export const sources = packageDir : (await readdir(cacheDir, { withFileTypes: true })) .filter(entry => entry.isDirectory()) .map(entry => join(cacheDir, entry.name)); + +export const outputDir = join('.', 'docs', 'api'); + +export const getPackageFile = async (packageDir, file = 'package.json') => + JSON.parse(await readFile(join(packageDir, file), 'utf8')); From d02b2e290bcca7f25095293fba4e5a1dd094ff55 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 12 Jul 2026 19:56:16 -0700 Subject: [PATCH 3/6] fixup! --- scripts/markdown/api/configuration.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs index d75c039f..127f4b91 100644 --- a/scripts/markdown/api/configuration.mjs +++ b/scripts/markdown/api/configuration.mjs @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises'; import { join } from 'node:path/posix'; import { major } from 'semver'; -import { sources, getPackageFile } from './utils.mjs'; +import { sources, getPackageFile, outputDir } from './utils.mjs'; const definitionName = ref => ref?.startsWith('#/definitions/') ? ref.slice('#/definitions/'.length) : ''; @@ -149,8 +149,6 @@ const generate = async packageDir => { 'schemas/WebpackOptions.json' ); - const outputDir = join(outputDir, `v${major(version)}.x`); - const lines = [ '---', 'source: https://github.com/webpack/webpack/edit/main/schemas/WebpackOptions.json', @@ -172,7 +170,7 @@ const generate = async packageDir => { } await writeFile( - join(outputDir, 'configuration.md'), + join(outputDir, `v${major(version)}.x`, 'configuration.md'), lines.join('\n'), 'utf8' ); From d6da5c5ec17fd52d884b5521ee685641ce890ccf Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 12 Jul 2026 20:00:50 -0700 Subject: [PATCH 4/6] fixup! --- scripts/markdown/api/utils.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/markdown/api/utils.mjs b/scripts/markdown/api/utils.mjs index 83216f38..dd9ebbd6 100644 --- a/scripts/markdown/api/utils.mjs +++ b/scripts/markdown/api/utils.mjs @@ -10,7 +10,7 @@ export const sources = packageDir .filter(entry => entry.isDirectory()) .map(entry => join(cacheDir, entry.name)); -export const outputDir = join('.', 'docs', 'api'); +export const outputDir = join('pages', 'docs', 'api'); export const getPackageFile = async (packageDir, file = 'package.json') => JSON.parse(await readFile(join(packageDir, file), 'utf8')); From 1700307b5618079a79bf22b5cf3c468e927bb63d Mon Sep 17 00:00:00 2001 From: avivkeller Date: Mon, 13 Jul 2026 16:58:13 -0700 Subject: [PATCH 5/6] fixup! --- plugins/processor/site.mjs | 8 +++++++- scripts/markdown/api/configuration.mjs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/processor/site.mjs b/plugins/processor/site.mjs index a28d06fd..69aa3d16 100644 --- a/plugins/processor/site.mjs +++ b/plugins/processor/site.mjs @@ -64,7 +64,13 @@ export const sidebar = (router, basePath) => { return [ { groupName: SIDEBAR_GROUP_NAME, - items: [...categories.values()].filter(category => category.items.length), + items: [ + [...categories.values()].filter(category => category.items.length), + { + link: toPublicLink(`${basePath}/options.md`, basePath), + label: 'Options', + }, + ], }, ]; }; diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs index 127f4b91..2cb566a7 100644 --- a/scripts/markdown/api/configuration.mjs +++ b/scripts/markdown/api/configuration.mjs @@ -170,7 +170,7 @@ const generate = async packageDir => { } await writeFile( - join(outputDir, `v${major(version)}.x`, 'configuration.md'), + join(outputDir, `v${major(version)}.x`, 'options.md'), lines.join('\n'), 'utf8' ); From e1210dd6ac0ef95314f6cffc234e0a7edf3c0fdc Mon Sep 17 00:00:00 2001 From: avivkeller Date: Mon, 13 Jul 2026 18:58:12 -0700 Subject: [PATCH 6/6] fixup! --- plugins/processor/site.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/processor/site.mjs b/plugins/processor/site.mjs index 69aa3d16..4f8e1008 100644 --- a/plugins/processor/site.mjs +++ b/plugins/processor/site.mjs @@ -65,9 +65,9 @@ export const sidebar = (router, basePath) => { { groupName: SIDEBAR_GROUP_NAME, items: [ - [...categories.values()].filter(category => category.items.length), + ...[...categories.values()].filter(category => category.items.length), { - link: toPublicLink(`${basePath}/options.md`, basePath), + link: `${basePath}/options`, label: 'Options', }, ],