From bc2722d0d4e112ba8e569011c1e890d62240ceb5 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 21 Jul 2026 16:05:21 -0500 Subject: [PATCH 1/3] feat(api): enrich generated configuration page - add a webpack.config.js example to every option, derived from the schema (enum literals, required-object expansion, richest-shape preference for multi-shape options) - expand sub-options of multi-shape options (e.g. cache) using their richest object shape - document shared discriminators as the union of their variants (cache.type: "filesystem" | "memory") - mark deprecated options with a "Stability: 0 - Deprecated" banner --- scripts/markdown/api/configuration.mjs | 198 +++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 15 deletions(-) diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs index 2cb566a7..4117395d 100644 --- a/scripts/markdown/api/configuration.mjs +++ b/scripts/markdown/api/configuration.mjs @@ -76,30 +76,165 @@ const summarize = (schema, definitions, stack = new Set()) => { return 'unknown'; }; +/** Collect every object-with-properties shape an option can resolve to. */ +const expandableObjects = (schema, definitions, stack = new Set()) => { + const ref = definitionName(schema.$ref); + if (ref) { + const target = definitions[ref]; + if (!target || stack.has(ref)) return []; + return expandableObjects(target, definitions, new Set(stack).add(ref)); + } + + if (schema.properties) return [schema]; + + const alternatives = schema.anyOf ?? schema.oneOf; + if (alternatives) { + return alternatives.flatMap(alternative => + expandableObjects(alternative, definitions, stack) + ); + } + + return []; +}; + /** - * Find the single object-with-properties schema an option resolves to, so its + * Find the 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. + * shapes (for example `cache`) expand the richest one; the alternatives stay + * reachable through their linked type pages. + */ +const expandableObject = (schema, definitions) => { + const objects = expandableObjects(schema, definitions); + if (!objects.length) return null; + return objects.reduce((best, object) => + Object.keys(object.properties).length > Object.keys(best.properties).length + ? object + : best + ); +}; + +const resolveSchema = (schema, definitions, stack = new Set()) => { + const ref = definitionName(schema?.$ref); + if (!ref || !definitions[ref] || stack.has(ref)) return schema; + return resolveSchema(definitions[ref], definitions, new Set(stack).add(ref)); +}; + +/** + * Rank how illustrative an example built from this schema would be — lower is + * better. Objects with required properties and string enums carry real values, + * free strings still read naturally, and bare objects or arrays teach the + * least. */ -const expandableObject = (schema, definitions, stack = new Set()) => { +const concreteness = (schema, definitions, stack = new Set()) => { + if (!schema) return 7; + + const ref = definitionName(schema.$ref); + if (ref) { + if (!definitions[ref] || stack.has(ref)) return 7; + return concreteness(definitions[ref], definitions, new Set(stack).add(ref)); + } + + const alternatives = schema.anyOf ?? schema.oneOf; + if (alternatives) { + return Math.min( + ...alternatives.map(alternative => + concreteness(alternative, definitions, stack) + ) + ); + } + + if (isStructural(schema) && schema.required?.length) return 0; + if (schema.enum?.some(value => typeof value === 'string')) return 0; + if (schema.type === 'string') return 1; + if (schema.enum) return 2; + if (schema.type === 'boolean') return 3; + if (schema.type === 'number' || schema.type === 'integer') return 4; + if (schema.type === 'array') return 5; + return 6; +}; + +/** Tie-breaker between equally illustrative shapes: prefer the richer one. */ +const propertyCount = (schema, definitions) => + Object.keys(resolveSchema(schema, definitions)?.properties ?? {}).length; + +/** + * Derive a placeholder value for an option from its schema: real literals for + * enums, neutral placeholders per type otherwise. Multi-shape options use the + * alternative whose example is most illustrative (see `concreteness`). + */ +const exampleValue = (schema, definitions, stack = new Set()) => { + if (!schema) return 'undefined'; + 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 (!target || stack.has(ref)) return '{}'; + return exampleValue(target, definitions, new Set(stack).add(ref)); } - if (schema.properties) return schema; + if (schema.enum) { + return literal( + schema.enum.find(value => typeof value === 'string') ?? schema.enum[0] + ); + } + if (schema.instanceof === 'Function' || schema.tsType?.includes('=>')) { + return '() => {}'; + } 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; + const preferred = alternatives.reduce((best, alternative) => { + const rank = concreteness(alternative, definitions, stack); + const bestRank = concreteness(best, definitions, stack); + if (rank !== bestRank) return rank < bestRank ? alternative : best; + return propertyCount(alternative, definitions) > + propertyCount(best, definitions) + ? alternative + : best; + }); + return exampleValue(preferred, definitions, stack); + } + + if (schema.type === 'array') return '[]'; + if (isStructural(schema) || schema.additionalProperties) { + const entries = (schema.required ?? []) + .filter(name => schema.properties?.[name]) + .map( + name => + `${name}: ${exampleValue(schema.properties[name], definitions, stack)}` + ); + return entries.length ? `{ ${entries.join(', ')} }` : '{}'; } + if (schema.type === 'boolean') return 'true'; + if (schema.type === 'number' || schema.type === 'integer') return '0'; + if (schema.type === 'string') return "'...'"; - return null; + return 'undefined'; +}; + +/** + * Render a minimal config snippet placing the option at its nesting path. + */ +const renderExample = (path, schema, definitions) => { + const segments = path.split('.'); + const indent = depth => ' '.repeat(depth + 1); + const lines = ['```js', 'export default {']; + + segments.forEach((segment, index) => { + const last = index === segments.length - 1; + lines.push( + last + ? `${indent(index)}${segment}: ${exampleValue(schema, definitions)},` + : `${indent(index)}${segment}: {` + ); + }); + + for (let index = segments.length - 2; index >= 0; index--) { + lines.push(`${indent(index)}},`); + } + + lines.push('};', '```'); + return lines; }; const descriptionOf = (schema, definitions) => { @@ -109,25 +244,58 @@ const descriptionOf = (schema, definitions) => { return ref ? collapse(definitions[ref]?.description) : undefined; }; +const isDeprecated = (schema, definitions) => + Boolean( + schema.deprecated ?? definitions[definitionName(schema.$ref)]?.deprecated + ); + const propertyBullet = (name, schema, definitions) => { const description = descriptionOf(schema, definitions); const type = summarize(schema, definitions); - return ` * \`${name}\` {${type}}${description ? ` - ${description}` : ''}`; + const notes = [ + isDeprecated(schema, definitions) ? '**Deprecated.**' : '', + description ?? '', + ] + .filter(Boolean) + .join(' '); + return ` * \`${name}\` {${type}}${notes ? ` - ${notes}` : ''}`; }; const renderOption = (path, schema, definitions, depth) => { const lines = [`${'#'.repeat(depth + 2)} \`${path}\``, '']; + if (isDeprecated(schema, definitions)) { + lines.push('> Stability: 0 - Deprecated', ''); + } + 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 ?? {}); + const shapes = expandableObjects(schema, definitions); + + // A property shared by several shapes (a discriminator like `cache.type`) + // documents the union of its variants, with the expanded shape's one first. + const mergedChild = (name, child) => { + const variants = shapes + .map(shape => shape.properties?.[name]) + .filter(variant => variant && variant !== child); + if (!variants.length) return child; + return { + description: descriptionOf(child, definitions), + deprecated: isDeprecated(child, definitions), + anyOf: [child, ...variants], + }; + }; + + const properties = Object.entries(objectSchema?.properties ?? {}).map( + ([name, child]) => [name, mergedChild(name, child)] + ); if (depth === 0) { - lines.push(''); + lines.push('', ...renderExample(path, schema, definitions), ''); for (const [name, child] of properties) { lines.push(...renderOption(`${path}.${name}`, child, definitions, 1)); } @@ -136,7 +304,7 @@ const renderOption = (path, schema, definitions, depth) => { for (const [name, child] of properties) { lines.push(propertyBullet(name, child, definitions)); } - lines.push(''); + lines.push('', ...renderExample(path, schema, definitions), ''); } return lines; From 2bf0160035d54eda9e0cc76a4531879f29652b68 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 21 Jul 2026 16:15:19 -0500 Subject: [PATCH 2/3] feat(api): implement discriminator logic for multi-shape options in configuration --- scripts/markdown/api/configuration.mjs | 101 ++++++++++++++++--------- 1 file changed, 67 insertions(+), 34 deletions(-) diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs index 4117395d..d873797c 100644 --- a/scripts/markdown/api/configuration.mjs +++ b/scripts/markdown/api/configuration.mjs @@ -98,18 +98,19 @@ const expandableObjects = (schema, definitions, stack = new Set()) => { }; /** - * Find the 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`) expand the richest one; the alternatives stay - * reachable through their linked type pages. + * Property whose single-value enum distinguishes every shape of a multi-shape + * option — for example `cache`'s `type`: "memory" vs "filesystem". */ -const expandableObject = (schema, definitions) => { - const objects = expandableObjects(schema, definitions); - if (!objects.length) return null; - return objects.reduce((best, object) => - Object.keys(object.properties).length > Object.keys(best.properties).length - ? object - : best +const discriminatorOf = shapes => { + if (shapes.length < 2) return null; + return ( + Object.keys(shapes[0].properties).find(name => { + const values = shapes.map(shape => shape.properties[name]?.enum); + return ( + values.every(value => value?.length === 1) && + new Set(values.map(value => literal(value[0]))).size === shapes.length + ); + }) ?? null ); }; @@ -214,14 +215,19 @@ const exampleValue = (schema, definitions, stack = new Set()) => { /** * Render a minimal config snippet placing the option at its nesting path. + * `extras` are sibling entries required for the option to apply — typically + * the discriminator of its shape, like `type: "filesystem"`. */ -const renderExample = (path, schema, definitions) => { +const renderExample = (path, schema, definitions, extras = []) => { const segments = path.split('.'); const indent = depth => ' '.repeat(depth + 1); const lines = ['```js', 'export default {']; segments.forEach((segment, index) => { const last = index === segments.length - 1; + if (last) { + for (const extra of extras) lines.push(`${indent(index)}${extra},`); + } lines.push( last ? `${indent(index)}${segment}: ${exampleValue(schema, definitions)},` @@ -261,7 +267,7 @@ const propertyBullet = (name, schema, definitions) => { return ` * \`${name}\` {${type}}${notes ? ` - ${notes}` : ''}`; }; -const renderOption = (path, schema, definitions, depth) => { +const renderOption = (path, schema, definitions, depth, exampleExtras = []) => { const lines = [`${'#'.repeat(depth + 2)} \`${path}\``, '']; if (isDeprecated(schema, definitions)) { @@ -273,38 +279,65 @@ const renderOption = (path, schema, definitions, depth) => { lines.push(`* Type: {${summarize(schema, definitions)}}`); - const objectSchema = expandableObject(schema, definitions); const shapes = expandableObjects(schema, definitions); + const discriminator = discriminatorOf(shapes); + + // Union of every shape's properties. A property shared by several shapes (a + // discriminator like `cache.type`) documents the union of its variants; + // shape-specific ones are labelled with their discriminator value and their + // example carries it (for example `type: "filesystem"`). + const entries = new Map(); + for (const shape of shapes) { + for (const [name, child] of Object.entries(shape.properties)) { + if (!entries.has(name)) entries.set(name, { children: [], owners: [] }); + const entry = entries.get(name); + entry.children.push(child); + entry.owners.push(shape); + } + } - // A property shared by several shapes (a discriminator like `cache.type`) - // documents the union of its variants, with the expanded shape's one first. - const mergedChild = (name, child) => { - const variants = shapes - .map(shape => shape.properties?.[name]) - .filter(variant => variant && variant !== child); - if (!variants.length) return child; - return { - description: descriptionOf(child, definitions), - deprecated: isDeprecated(child, definitions), - anyOf: [child, ...variants], - }; - }; - - const properties = Object.entries(objectSchema?.properties ?? {}).map( - ([name, child]) => [name, mergedChild(name, child)] - ); + const properties = [...entries].map(([name, { children, owners }]) => { + const [child, ...variants] = children; + const ownValues = owners.map(owner => + literal(owner.properties[discriminator]?.enum?.[0]) + ); + const partial = + discriminator && name !== discriminator && owners.length < shapes.length; + + const note = partial + ? `Only used when \`${discriminator}\` is set to ${ownValues + .map(value => `\`${value}\``) + .join(' or ')}.` + : ''; + const merged = + variants.length || note + ? { + description: [descriptionOf(child, definitions), note] + .filter(Boolean) + .join(' '), + deprecated: isDeprecated(child, definitions), + anyOf: [child, ...variants], + } + : child; + const extras = partial ? [`${discriminator}: ${ownValues[0]}`] : []; + + return [name, merged, extras]; + }); if (depth === 0) { lines.push('', ...renderExample(path, schema, definitions), ''); - for (const [name, child] of properties) { - lines.push(...renderOption(`${path}.${name}`, child, definitions, 1)); + for (const [name, child, extras] of properties) { + lines.push( + ...renderOption(`${path}.${name}`, child, definitions, 1, extras) + ); } } else { // Sub-option details nest under the type annotation. for (const [name, child] of properties) { lines.push(propertyBullet(name, child, definitions)); } - lines.push('', ...renderExample(path, schema, definitions), ''); + lines.push('', ...renderExample(path, schema, definitions, exampleExtras)); + lines.push(''); } return lines; From 63123c0549359d29cd3778d10a6eca81a3a1949c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 21 Jul 2026 16:26:35 -0500 Subject: [PATCH 3/3] feat(api): enhance example rendering with property key quoting and path formatting --- scripts/markdown/api/configuration.mjs | 34 ++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs index d873797c..fa50c6d4 100644 --- a/scripts/markdown/api/configuration.mjs +++ b/scripts/markdown/api/configuration.mjs @@ -14,6 +14,20 @@ const trimImports = text => const literal = value => typeof value === 'string' ? JSON.stringify(value) : String(value); +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +/** Quote object keys that are not valid identifiers (e.g. `asset/inline`). */ +const propertyKey = name => (IDENTIFIER.test(name) ? name : `'${name}'`); + +/** Option path in JS access notation: `module.generator['asset/inline']`. */ +const formatPath = segments => + segments + .map((segment, index) => { + if (!IDENTIFIER.test(segment)) return `['${segment}']`; + return index ? `.${segment}` : segment; + }) + .join(''); + const isStructural = schema => schema.type === 'object' || Boolean(schema.properties); @@ -202,7 +216,7 @@ const exampleValue = (schema, definitions, stack = new Set()) => { .filter(name => schema.properties?.[name]) .map( name => - `${name}: ${exampleValue(schema.properties[name], definitions, stack)}` + `${propertyKey(name)}: ${exampleValue(schema.properties[name], definitions, stack)}` ); return entries.length ? `{ ${entries.join(', ')} }` : '{}'; } @@ -218,8 +232,7 @@ const exampleValue = (schema, definitions, stack = new Set()) => { * `extras` are sibling entries required for the option to apply — typically * the discriminator of its shape, like `type: "filesystem"`. */ -const renderExample = (path, schema, definitions, extras = []) => { - const segments = path.split('.'); +const renderExample = (segments, schema, definitions, extras = []) => { const indent = depth => ' '.repeat(depth + 1); const lines = ['```js', 'export default {']; @@ -230,8 +243,8 @@ const renderExample = (path, schema, definitions, extras = []) => { } lines.push( last - ? `${indent(index)}${segment}: ${exampleValue(schema, definitions)},` - : `${indent(index)}${segment}: {` + ? `${indent(index)}${propertyKey(segment)}: ${exampleValue(schema, definitions)},` + : `${indent(index)}${propertyKey(segment)}: {` ); }); @@ -268,7 +281,7 @@ const propertyBullet = (name, schema, definitions) => { }; const renderOption = (path, schema, definitions, depth, exampleExtras = []) => { - const lines = [`${'#'.repeat(depth + 2)} \`${path}\``, '']; + const lines = [`${'#'.repeat(depth + 2)} \`${formatPath(path)}\``, '']; if (isDeprecated(schema, definitions)) { lines.push('> Stability: 0 - Deprecated', ''); @@ -324,11 +337,12 @@ const renderOption = (path, schema, definitions, depth, exampleExtras = []) => { return [name, merged, extras]; }); - if (depth === 0) { - lines.push('', ...renderExample(path, schema, definitions), ''); + if (depth < 2) { + lines.push('', ...renderExample(path, schema, definitions, exampleExtras)); + lines.push(''); for (const [name, child, extras] of properties) { lines.push( - ...renderOption(`${path}.${name}`, child, definitions, 1, extras) + ...renderOption([...path, name], child, definitions, depth + 1, extras) ); } } else { @@ -367,7 +381,7 @@ const generate = async packageDir => { ]; for (const [name, child] of Object.entries(schema.properties)) { - lines.push(...renderOption(name, child, schema.definitions, 0)); + lines.push(...renderOption([name], child, schema.definitions, 0)); } await writeFile(