diff --git a/scripts/markdown/api/configuration.mjs b/scripts/markdown/api/configuration.mjs index 2cb566a7..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); @@ -76,30 +90,170 @@ 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 - * sub-options can be documented in place. Options offering several object - * shapes (for example `cache`) are left to 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, stack = new Set()) => { +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 + ); +}; + +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 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 => + `${propertyKey(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 'undefined'; +}; + +/** + * 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 = (segments, schema, definitions, extras = []) => { + 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)}${propertyKey(segment)}: ${exampleValue(schema, definitions)},` + : `${indent(index)}${propertyKey(segment)}: {` + ); + }); + + for (let index = segments.length - 2; index >= 0; index--) { + lines.push(`${indent(index)}},`); } - return null; + lines.push('};', '```'); + return lines; }; const descriptionOf = (schema, definitions) => { @@ -109,33 +263,94 @@ 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}\``, '']; +const renderOption = (path, schema, definitions, depth, exampleExtras = []) => { + const lines = [`${'#'.repeat(depth + 2)} \`${formatPath(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); + 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); + } + } - if (depth === 0) { + 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 < 2) { + lines.push('', ...renderExample(path, schema, definitions, exampleExtras)); lines.push(''); - 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, depth + 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, exampleExtras)); lines.push(''); } @@ -166,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(