From 733beed687791388026d4fd61bff2c48cee9bcf1 Mon Sep 17 00:00:00 2001 From: Vasily Martynov Date: Tue, 11 Aug 2026 12:08:27 +1200 Subject: [PATCH] Add support for examples in Open API plugin Close #199 --- packages/plugins/openapi/src/index.ts | 311 ++++++++++++++++++----- packages/plugins/openapi/src/openapi.css | 126 +++++++++ 2 files changed, 377 insertions(+), 60 deletions(-) diff --git a/packages/plugins/openapi/src/index.ts b/packages/plugins/openapi/src/index.ts index 77d5138e..8d45ba2f 100644 --- a/packages/plugins/openapi/src/index.ts +++ b/packages/plugins/openapi/src/index.ts @@ -25,7 +25,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const plugin: PluginDescriptor = { name: 'openapi', version: '0.9.1', - capabilities: ['markdown', 'assets'] + capabilities: ['markdown', 'assets'], }; // --------------------------------------------------------------------------- @@ -42,11 +42,21 @@ interface OASchema { required?: string[]; $ref?: string; example?: unknown; + examples?: unknown[]; default?: unknown; nullable?: boolean; oneOf?: OASchema[]; anyOf?: OASchema[]; allOf?: OASchema[]; + additionalProperties?: boolean | OASchema; + discriminator?: { propertyName?: string }; +} + +interface OAExample { + summary?: string; + description?: string; + value?: unknown; + externalValue?: string; } interface OAParameter { @@ -56,11 +66,13 @@ interface OAParameter { required?: boolean; schema?: OASchema; example?: unknown; + examples?: Record; } interface OAMediaType { schema?: OASchema; example?: unknown; + examples?: Record; } interface OARequestBody { @@ -115,15 +127,11 @@ const METHOD_COLORS: Record = { patch: '#8b5cf6', delete: '#ef4444', head: '#6b7280', - options: '#6b7280' + options: '#6b7280', }; function esc(str: string): string { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function describe(str: string | undefined, options: any): string { @@ -149,40 +157,182 @@ function resolveSchema(schema: OASchema | undefined, spec: OASpec, _depth = 0): return schema; } +/** Expand a schema by resolving $ref and flattening allOf branches into a single + * schema (merged properties/required). Used wherever the actual field set of a + * schema is needed (table rendering), as opposed to typeLabel's display string. */ +function expandSchema(schema: OASchema | undefined, spec: OASpec, depth = 0): OASchema { + if (!schema || depth > 8) return schema || {}; + const resolved = resolveSchema(schema, spec); + if (!resolved.allOf || resolved.allOf.length === 0) return resolved; + + const merged: OASchema = { properties: {}, required: [] }; + for (const branch of resolved.allOf) { + const expanded = expandSchema(branch, spec, depth + 1); + if (expanded.properties) Object.assign(merged.properties!, expanded.properties); + if (expanded.required) merged.required = [...merged.required!, ...expanded.required]; + } + // Own properties declared alongside allOf (a common inheritance pattern) win last. + if (resolved.properties) Object.assign(merged.properties!, resolved.properties); + if (resolved.required) merged.required = [...merged.required!, ...resolved.required]; + merged.description = resolved.description; + merged.example = resolved.example; + merged.examples = resolved.examples; + merged.default = resolved.default; + merged.additionalProperties = resolved.additionalProperties; + return merged; +} + /** Render a schema as a compact type string */ function typeLabel(schema: OASchema | undefined, spec: OASpec): string { if (!schema) return 'any'; + if (schema.$ref) return schema.$ref.split('/').pop() || 'object'; const resolved = resolveSchema(schema, spec); - if (resolved.$ref) return resolved.$ref.split('/').pop() || 'object'; + if (resolved.allOf && resolved.allOf.length > 0) return resolved.allOf.map((s) => typeLabel(s, spec)).join(' & '); if (resolved.type === 'array') return `array[${typeLabel(resolved.items, spec)}]`; - if (resolved.oneOf) return resolved.oneOf.map(s => typeLabel(s, spec)).join(' | '); - if (resolved.anyOf) return resolved.anyOf.map(s => typeLabel(s, spec)).join(' | '); - if (resolved.enum) return resolved.enum.map(v => `"${v}"`).join(' | '); - return [resolved.type, resolved.format].filter(Boolean).join(':') || 'any'; + if ( + resolved.type === 'object' && + resolved.additionalProperties && + typeof resolved.additionalProperties === 'object' + ) { + return `map[string, ${typeLabel(resolved.additionalProperties, spec)}]`; + } + if (resolved.oneOf && resolved.oneOf.length > 0) return resolved.oneOf.map((s) => typeLabel(s, spec)).join(' | '); + if (resolved.anyOf && resolved.anyOf.length > 0) return resolved.anyOf.map((s) => typeLabel(s, spec)).join(' | '); + if (resolved.enum) return resolved.enum.map((v) => `"${v}"`).join(' | '); + const inferredType = resolved.type || (resolved.properties || resolved.additionalProperties ? 'object' : undefined); + const base = [inferredType, resolved.format].filter(Boolean).join(':') || 'any'; + return resolved.nullable ? `${base} | null` : base; } -/** Render schema properties as an HTML table */ -function renderSchemaTable(schema: OASchema | undefined, spec: OASpec, options: any): string { - if (!schema) return ''; - const resolved = resolveSchema(schema, spec); +/** Format a raw example value (string or JSON) as a code block */ +function formatExampleValue(value: unknown): string { + if (value === undefined) return ''; + const str = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + return `
${esc(str)}
`; +} + +/** Format a field-level example value inline, without the boxed code block + * used for full-body examples — keeps schema table rows compact. */ +function formatInlineExampleValue(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return esc(value); + if (typeof value === 'number') return String(value); + return formatExampleValue(value); +} + +/** Render the example(s) for a media type or schema: named `examples` map takes + * priority over a single `example`, falling back to the schema's own example. */ +function renderExamples( + media: OAMediaType | undefined, + schema: OASchema | undefined, + spec: OASpec, + options: any, +): string { + let body = ''; + if (media?.examples && Object.keys(media.examples).length > 0) { + const entries = Object.entries(media.examples); + body = entries + .map( + ([name, example]) => `
+ ${esc(example.summary || name)} + ${example.description ? `

${describe(example.description, options)}

` : ''} + ${ + example.value !== undefined + ? formatExampleValue(example.value) + : example.externalValue + ? `

${esc(example.externalValue)}

` + : '' + } +
`, + ) + .join(''); + } else if (media?.example !== undefined) { + body = formatExampleValue(media.example); + } else { + const resolvedSchema = schema ? expandSchema(schema, spec) : undefined; + const schemaExample = resolvedSchema?.example ?? resolvedSchema?.examples?.[0]; + if (schemaExample !== undefined) body = formatExampleValue(schemaExample); + } + if (!body) return ''; + return `
Example
${body}
`; +} + +/** Render a nested object/array-of-object schema inline as a collapsible sub-table */ +function renderNestedSchema(schema: OASchema | undefined, spec: OASpec, options: any, depth: number): string { + if (!schema || depth > 6) return ''; + const resolved = expandSchema(schema, spec); + const target = resolved.type === 'array' ? resolved.items : schema; + if (!target) return ''; + const targetResolved = expandSchema(target, spec); + const hasFields = + (targetResolved.properties && Object.keys(targetResolved.properties).length > 0) || + (targetResolved.oneOf?.length ?? 0) > 0 || + (targetResolved.anyOf?.length ?? 0) > 0; + if (!hasFields) return ''; + const inner = renderSchemaTable(target, spec, options, depth + 1); + if (!inner) return ''; + return `
Show fields${inner}
`; +} + +/** Render schema properties as an HTML table, expanding allOf/oneOf/anyOf compositions, + * nested object/array fields and per-field examples. */ +function renderSchemaTable(schema: OASchema | undefined, spec: OASpec, options: any, depth = 0): string { + if (!schema || depth > 6) return ''; + const resolved = expandSchema(schema, spec); + let html = ''; + const props = resolved.properties; - if (!props || Object.keys(props).length === 0) return ''; - - const required = new Set(resolved.required || []); - const rows = Object.entries(props).map(([name, prop]) => { - const r = resolveSchema(prop, spec); - return ` - ${esc(name)}${required.has(name) ? ' *' : ''} - ${esc(typeLabel(prop, spec))} - ${describe(r.description, options)} - ${r.default !== undefined ? `${esc(String(r.default))}` : ''} - `; - }).join(''); - - return ` - - ${rows} -
FieldTypeDescriptionDefault
`; + if (props && Object.keys(props).length > 0) { + const required = new Set(resolved.required || []); + const rows = Object.entries(props) + .map(([name, prop]) => { + const r = expandSchema(prop, spec); + const nested = renderNestedSchema(prop, spec, options, depth); + const example = r.example ?? r.examples?.[0]; + return ` + ${esc(name)}${required.has(name) ? ' *' : ''} + ${esc(typeLabel(prop, spec))}${nested} + ${describe(r.description, options)} + ${formatInlineExampleValue(example)} + `; + }) + .join(''); + + html += ` + + ${rows} +
FieldTypeDescriptionExample
`; + } + + if (resolved.additionalProperties && typeof resolved.additionalProperties === 'object') { + html += `

Additional properties: ${esc(typeLabel(resolved.additionalProperties, spec))}

`; + } + + // oneOf/anyOf represent alternative schemas, not composed ones, so render them + // from the un-flattened schema alongside (rather than instead of) the table above. + const raw = resolveSchema(schema, spec); + const variantGroups: [string, OASchema[] | undefined][] = [ + ['One of', raw.oneOf], + ['Any of', raw.anyOf], + ]; + for (const [label, variants] of variantGroups) { + if (variants && variants.length > 0) { + const discriminator = raw.discriminator?.propertyName + ? `

Discriminator: ${esc(raw.discriminator.propertyName)}

` + : ''; + const items = variants + .map( + (v) => `
+ ${esc(typeLabel(v, spec))} + ${renderSchemaTable(v, spec, options, depth + 1)} +
`, + ) + .join(''); + html += `

${esc(label)}:

${discriminator}${items}
`; + } + } + + return html; } /** Render a single operation */ @@ -194,17 +344,26 @@ function renderOperation(method: string, path_: string, op: OAOperation, spec: O // Parameters let paramsHtml = ''; if (!summaryOnly && op.parameters && op.parameters.length > 0) { - const rows = op.parameters.map(p => { - return ` + const rows = op.parameters + .map((p) => { + const paramSchema = p.schema ? expandSchema(p.schema, spec) : undefined; + const example = + p.example ?? + (p.examples ? Object.values(p.examples)[0]?.value : undefined) ?? + paramSchema?.example ?? + paramSchema?.examples?.[0]; + return ` ${esc(p.name)}${p.required ? ' *' : ''} ${esc(p.in)} ${esc(typeLabel(p.schema, spec))} ${describe(p.description, options)} + ${formatInlineExampleValue(example)} `; - }).join(''); + }) + .join(''); paramsHtml = `
Parameters
- + ${rows}
NameInTypeDescription
NameInTypeDescriptionExample
`; } @@ -217,27 +376,53 @@ function renderOperation(method: string, path_: string, op: OAOperation, spec: O for (const [contentType, media] of entries) { requestHtml += `

${esc(contentType)}

`; requestHtml += renderSchemaTable(media.schema, spec, options); + requestHtml += renderExamples(media, media.schema, spec, options); } } - // Responses + // Responses — each status code is rendered as its own summary row immediately + // followed by its body/example row, so examples stay attached to their own + // response (in file order) instead of being grouped after all responses. let responsesHtml = ''; if (!summaryOnly && op.responses) { const statusCodes = Object.entries(op.responses); - const rows = statusCodes.map(([code, resp]) => { - const cls = code.startsWith('2') ? 'oa-status-ok' : code.startsWith('4') ? 'oa-status-err' : 'oa-status-other'; - let schemaInfo = ''; - if (resp.content) { - const firstMedia = Object.values(resp.content)[0]; - if (firstMedia?.schema) schemaInfo = `
${esc(typeLabel(firstMedia.schema, spec))}`; - } - return ` + const rows = statusCodes + .map(([code, resp]) => { + const cls = code.startsWith('2') ? 'oa-status-ok' : /^[45]/.test(code) ? 'oa-status-err' : 'oa-status-other'; + let schemaInfo = ''; + if (resp.content) { + const firstMedia = Object.values(resp.content)[0]; + if (firstMedia?.schema) + schemaInfo = `
${esc(typeLabel(firstMedia.schema, spec))}`; + } + const summaryRow = ` ${esc(code)} ${describe(resp.description, options)}${schemaInfo} `; - }).join(''); + + const sections = resp.content + ? Object.entries(resp.content) + .map(([contentType, media]) => { + const schemaHtml = renderSchemaTable(media.schema, spec, options); + const examplesHtml = renderExamples(media, media.schema, spec, options); + if (!schemaHtml && !examplesHtml) return ''; + return `

${esc(contentType)}

${schemaHtml}${examplesHtml}`; + }) + .join('') + : ''; + + const detailRow = sections + ? `
+ ${esc(code)} body + ${sections} +
` + : ''; + + return summaryRow + detailRow; + }) + .join(''); responsesHtml = `
Responses
- +
${rows}
StatusDescription
`; @@ -276,7 +461,9 @@ function parseSpec(specPath: string): OASpec { const yaml = require('js-yaml'); return yaml.load(raw) as OASpec; } catch { - throw new Error(`OpenAPI plugin: YAML spec at "${specPath}" requires js-yaml to be installed.\nRun: npm install js-yaml`); + throw new Error( + `OpenAPI plugin: YAML spec at "${specPath}" requires js-yaml to be installed.\nRun: npm install js-yaml`, + ); } } @@ -306,7 +493,9 @@ function renderSpec(specPath: string, rootDir: string, options: any): string { let html = `
`; if (options?.info !== false && info.title) { - const downloadLink = options?.download ? `JSON / YAML` : ''; + const downloadLink = options?.download + ? `JSON / YAML` + : ''; html += `

${esc(info.title)}

@@ -347,11 +536,11 @@ function renderSpec(specPath: string, rootDir: string, options: any): string { * ``` */ export function markdownSetup(md: any, options: any): void { - const srcDir: string = options?.config?.src - ? path.resolve(process.cwd(), options.config.src) - : process.cwd(); + const srcDir: string = options?.config?.src ? path.resolve(process.cwd(), options.config.src) : process.cwd(); - const originalFence = md.renderer.rules.fence || ((tokens: any[], idx: number, opts: any, _env: any, self: any) => self.renderToken(tokens, idx, opts)); + const originalFence = + md.renderer.rules.fence || + ((tokens: any[], idx: number, opts: any, _env: any, self: any) => self.renderToken(tokens, idx, opts)); md.renderer.rules.fence = (tokens: any[], idx: number, opts: any, env: any, self: any) => { const token = tokens[idx]; @@ -378,10 +567,12 @@ export function getAssets(_options?: any): any[] { // Only inject if our bundled CSS exists if (!fs.existsSync(cssPath)) return []; - return [{ - src: cssPath, - dest: 'assets/css/docmd-openapi.css', - type: 'css', - location: 'head' - }]; + return [ + { + src: cssPath, + dest: 'assets/css/docmd-openapi.css', + type: 'css', + location: 'head', + }, + ]; } diff --git a/packages/plugins/openapi/src/openapi.css b/packages/plugins/openapi/src/openapi.css index 0f5950b0..a9dd3081 100644 --- a/packages/plugins/openapi/src/openapi.css +++ b/packages/plugins/openapi/src/openapi.css @@ -166,6 +166,20 @@ color: var(--text-muted, #4b5563); } +/* Add .oa-no-hover to a .oa-schema-table to disable the theme's global + tr:hover highlight — useful when the table is reference data, not + interactive rows, and the highlight is just visual noise. */ +.oa-schema-table.oa-no-hover tr:hover td { + background-color: inherit; +} + +/* Add .oa-hover to a nested .oa-schema-table (rendered inside a td of a + .oa-no-hover table) to re-enable the highlight for that inner table only. + Same specificity as the rule above, so it must stay declared after it. */ +.oa-schema-table.oa-hover tr:hover td { + background-color: var(--sidebar-link-active-bg); +} + .oa-required { color: #ef4444; margin-left: 0.25rem; @@ -196,6 +210,118 @@ .oa-status-err { background: #fee2e2; color: #991b1b; } .oa-status-other { background: #f3f4f6; color: #4b5563; } +.oa-response-detail-row td { + padding: 0; + border-bottom: 1px solid var(--border-color, #eee); +} + +.oa-response-detail { + margin: 0.5rem 1rem 1rem; + border: 1px solid var(--border-color, #eee); + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.oa-response-detail summary { + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + color: var(--text-muted, #4b5563); +} + +.oa-nested-schema { + margin-top: 0.25rem; + padding-left: 1rem; +} + +.oa-nested-schema summary { + cursor: pointer; + font-size: 0.75rem; + color: var(--link-color, #3b82f6); +} + +/* Indentation lives on the .oa-nested-schema wrapper (via padding-left) rather + than the table itself, so nested tables keep full-width cells/padding at any + depth instead of shrinking further with each level of nesting. */ +.oa-nested-schema table { + margin: 0.5rem 0; + width: 100%; +} + +.oa-additional-props { + padding: 0 1rem; + margin: 0.5rem 0 0; + font-size: 0.85rem; + color: var(--text-muted, #4b5563); +} + +.oa-variants { + padding: 0 1rem; + margin: 0.5rem 0; +} + +.oa-variants-label { + margin: 0 0 0.25rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted, #6b7280); +} + +.oa-discriminator { + margin: 0 0 0.5rem; + font-size: 0.8rem; + color: var(--text-muted, #6b7280); +} + +.oa-variant { + border: 1px solid var(--border-color, #eee); + border-radius: 6px; + padding: 0.4rem 0.6rem; + margin-bottom: 0.4rem; +} + +.oa-variant summary { + cursor: pointer; +} + +.oa-examples-title { + margin: 1.5rem 1rem 0.5rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted, #6b7280); + font-weight: 700; +} + +.oa-examples { + padding: 0 1rem; +} + +.oa-example { + margin-bottom: 0.5rem; +} + +.oa-example summary { + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; +} + +.oa-example-description { + margin: 0.25rem 0; + color: var(--text-muted, #4b5563); + font-size: 0.85rem; +} + +.oa-example-value { + background: var(--code-bg, #f9fafb); + border: 1px solid var(--border-color, #eee); + border-radius: 6px; + padding: 0.75rem; + overflow-x: auto; + font-size: 0.85rem; +} + /* Dark Mode Overrides */ :root[data-theme=dark] .oa-type { color: #22d3ee;