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/plugins/processor/site.mjs b/plugins/processor/site.mjs index a28d06fd..4f8e1008 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: `${basePath}/options`, + label: 'Options', + }, + ], }, ]; }; diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs new file mode 100644 index 00000000..2cb566a7 --- /dev/null +++ b/scripts/markdown/api/configuration.mjs @@ -0,0 +1,181 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path/posix'; +import { major } from 'semver'; +import { sources, getPackageFile, outputDir } 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 } = await getPackageFile(packageDir); + const schema = await getPackageFile( + packageDir, + 'schemas/WebpackOptions.json' + ); + + 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, schema.definitions, 0)); + } + + await writeFile( + join(outputDir, `v${major(version)}.x`, 'options.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 65% rename from scripts/markdown/api.mjs rename to scripts/markdown/api/index.mjs index b4a754c7..a4718a79 100644 --- a/scripts/markdown/api.mjs +++ b/scripts/markdown/api/index.mjs @@ -1,18 +1,14 @@ -import { readdir, 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, 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: [ @@ -42,14 +38,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..dd9ebbd6 --- /dev/null +++ b/scripts/markdown/api/utils.mjs @@ -0,0 +1,16 @@ +import { readdir, readFile } 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)); + +export const outputDir = join('pages', 'docs', 'api'); + +export const getPackageFile = async (packageDir, file = 'package.json') => + JSON.parse(await readFile(join(packageDir, file), 'utf8'));