Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion plugins/processor/site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
];
};
181 changes: 181 additions & 0 deletions scripts/markdown/api/configuration.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
18 changes: 3 additions & 15 deletions scripts/markdown/api.mjs → scripts/markdown/api/index.mjs
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down Expand Up @@ -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);
}
16 changes: 16 additions & 0 deletions scripts/markdown/api/utils.mjs
Original file line number Diff line number Diff line change
@@ -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'));
Loading