diff --git a/_extensions/quarto-openapi/_extension.yml b/_extensions/quarto-openapi/_extension.yml index c2c20a9..24f88cf 100644 --- a/_extensions/quarto-openapi/_extension.yml +++ b/_extensions/quarto-openapi/_extension.yml @@ -1,7 +1,7 @@ title: quarto-openapi author: Posit Software, PBC -version: 0.1.2 -quarto-required: ">=1.5.0" +version: 0.2.0 +quarto-required: ">=1.6.0" contributes: metadata: project: diff --git a/_extensions/quarto-openapi/openapi-to-markdown.ts b/_extensions/quarto-openapi/openapi-to-markdown.ts index 83227d3..89a4ff7 100644 --- a/_extensions/quarto-openapi/openapi-to-markdown.ts +++ b/_extensions/quarto-openapi/openapi-to-markdown.ts @@ -12,8 +12,8 @@ * output: "api/index.qmd" */ -import { parse as parseYaml, stringify as stringifyYaml } from "https://deno.land/std@0.224.0/yaml/mod.ts"; -import { join, dirname, extname } from "https://deno.land/std@0.224.0/path/mod.ts"; +import { parse as parseYaml, stringify as stringifyYaml } from "stdlib/yaml"; +import { join, dirname, extname } from "stdlib/path"; import type { OpenAPISpec } from "./lib/types.ts"; import { groupByResource, renderSection } from "./lib/sections.ts"; diff --git a/example/_extensions/quarto-openapi/_extension.yml b/example/_extensions/quarto-openapi/_extension.yml index d8039d3..2b1f652 100644 --- a/example/_extensions/quarto-openapi/_extension.yml +++ b/example/_extensions/quarto-openapi/_extension.yml @@ -1,9 +1,13 @@ title: quarto-openapi author: Posit Software, PBC -version: 0.1.0 -quarto-required: ">=1.5.0" +version: 0.2.0 +quarto-required: ">=1.6.0" contributes: metadata: project: pre-render: - _extensions/quarto-openapi/openapi-to-markdown.ts + format: + html: + css: + - quarto-openapi-styles.css diff --git a/example/_extensions/quarto-openapi/lib/markdown.ts b/example/_extensions/quarto-openapi/lib/markdown.ts index c88fa50..8b34df5 100644 --- a/example/_extensions/quarto-openapi/lib/markdown.ts +++ b/example/_extensions/quarto-openapi/lib/markdown.ts @@ -55,6 +55,7 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] { } }; + lines.push("::: {.quarto-openapi-table}"); lines.push(separator("-")); emitRow(headerLines); lines.push(separator("=")); @@ -64,6 +65,8 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] { lines.push(separator("-")); } + lines.push(":::"); + return lines; } diff --git a/example/_extensions/quarto-openapi/lib/schema.ts b/example/_extensions/quarto-openapi/lib/schema.ts index fff383f..118f2ff 100644 --- a/example/_extensions/quarto-openapi/lib/schema.ts +++ b/example/_extensions/quarto-openapi/lib/schema.ts @@ -158,7 +158,9 @@ function flattenProperties( for (const [name, prop] of Object.entries(properties)) { const resolved = isReference(prop) ? resolve(spec, prop) : prop; const displayName = prefix ? `${prefix}.${name}` : name; - const indent = " ".repeat(depth); + const nameCell = depth > 0 + ? `[\`${displayName}\`]{.schema-nest-${depth}}` + : `\`${displayName}\``; if ( (resolved.type === "object" || resolved.properties) && @@ -167,7 +169,7 @@ function flattenProperties( ) { // Nested object: emit a row for the object itself, then recurse rows.push({ - name: `${indent}\`${displayName}\``, + name: nameCell, type: "`object`", description: resolved.description || "", }); @@ -183,7 +185,7 @@ function flattenProperties( } else if (resolved.allOf) { const merged = mergeAllOf(spec, resolved.allOf); rows.push({ - name: `${indent}\`${displayName}\``, + name: nameCell, type: "`object`", description: resolved.description || merged.description || "", }); @@ -205,7 +207,7 @@ function flattenProperties( if (items.type === "object" || items.properties || items.allOf) { rows.push({ - name: `${indent}\`${displayName}\``, + name: nameCell, type: "`[object]`", description: buildDescription(resolved, requiredSet.has(name)), }); @@ -224,14 +226,14 @@ function flattenProperties( } else { const itemType = items.type || "unknown"; rows.push({ - name: `${indent}\`${displayName}\``, + name: nameCell, type: `\`[${itemType}]\``, description: buildDescription(resolved, requiredSet.has(name)), }); } } else { rows.push({ - name: `${indent}\`${displayName}\``, + name: nameCell, type: `\`${formatTypeString(resolved)}\``, description: buildDescription(resolved, requiredSet.has(name)), }); diff --git a/example/_extensions/quarto-openapi/lib/sections.ts b/example/_extensions/quarto-openapi/lib/sections.ts index d6d13dd..143bd15 100644 --- a/example/_extensions/quarto-openapi/lib/sections.ts +++ b/example/_extensions/quarto-openapi/lib/sections.ts @@ -123,10 +123,28 @@ export function groupByResource(spec: OpenAPISpec): Section[] { } } - // Preserve insertion order (first-seen tag order). + // Determine section order: use spec.tags when present, otherwise first-seen. + const orderedNames: string[] = []; + if (spec.tags && spec.tags.length > 0) { + for (const tag of spec.tags) { + if (sectionMap.has(tag.name)) { + orderedNames.push(tag.name); + } + } + // Append any tags not listed in spec.tags (first-seen order). + for (const name of sectionMap.keys()) { + if (!orderedNames.includes(name)) { + orderedNames.push(name); + } + } + } else { + orderedNames.push(...sectionMap.keys()); + } + // Sort endpoints within each section by path then method. const sections: Section[] = []; - for (const [name, endpoints] of sectionMap) { + for (const name of orderedNames) { + const endpoints = sectionMap.get(name)!; endpoints.sort((a, b) => { const pathCmp = a.path.localeCompare(b.path); if (pathCmp !== 0) return pathCmp; @@ -155,6 +173,50 @@ export function renderSection(spec: OpenAPISpec, section: Section): string[] { return lines; } +/** + * Shift markdown headings in a description so the highest heading + * becomes h4 (one level below the endpoint h3). + * Headings inside fenced code blocks are left untouched. + */ +function shiftHeadings(description: string): string { + const lines = description.split("\n"); + const headingRe = /^(#{1,6})\s/; + + // First pass: find minimum heading level outside code fences. + let minLevel = 7; + let inFence = false; + for (const line of lines) { + if (/^```/.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + const m = headingRe.exec(line); + if (m) minLevel = Math.min(minLevel, m[1].length); + } + + if (minLevel >= 7) return description; // no headings found + + const shift = 4 - minLevel; + if (shift <= 0) return description; + + // Second pass: shift headings outside code fences. + inFence = false; + const result = lines.map((line) => { + if (/^```/.test(line)) { + inFence = !inFence; + return line; + } + if (inFence) return line; + return line.replace(headingRe, (_full, hashes: string) => { + const newLevel = Math.min(hashes.length + shift, 6); + return "#".repeat(newLevel) + " "; + }); + }); + + return result.join("\n"); +} + function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] { const { method, path, operation } = endpoint; const title = operation.summary || `${methodBadge(method)} ${path}`; @@ -178,9 +240,9 @@ function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] { lines.push(""); } - // Description + // Description — shift headings so they nest below the endpoint h3 if (operation.description) { - lines.push(operation.description); + lines.push(shiftHeadings(operation.description)); lines.push(""); } diff --git a/example/_extensions/quarto-openapi/openapi-to-markdown.ts b/example/_extensions/quarto-openapi/openapi-to-markdown.ts index 9bcde27..89a4ff7 100644 --- a/example/_extensions/quarto-openapi/openapi-to-markdown.ts +++ b/example/_extensions/quarto-openapi/openapi-to-markdown.ts @@ -12,8 +12,8 @@ * output: "api/index.qmd" */ -import { parse as parseYaml, stringify as stringifyYaml } from "https://deno.land/std@0.224.0/yaml/mod.ts"; -import { join, dirname, extname } from "https://deno.land/std@0.224.0/path/mod.ts"; +import { parse as parseYaml, stringify as stringifyYaml } from "stdlib/yaml"; +import { join, dirname, extname } from "stdlib/path"; import type { OpenAPISpec } from "./lib/types.ts"; import { groupByResource, renderSection } from "./lib/sections.ts"; @@ -103,6 +103,8 @@ async function main() { // YAML frontmatter — use a proper serializer to avoid injection via title const frontmatter = stringifyYaml({ title: spec.info.title, + "page-layout": "full", + "toc-location": "left", toc: true, "toc-depth": 3, "toc-expand": 1, diff --git a/example/_extensions/quarto-openapi/quarto-openapi-styles.css b/example/_extensions/quarto-openapi/quarto-openapi-styles.css new file mode 100644 index 0000000..ee9e97e --- /dev/null +++ b/example/_extensions/quarto-openapi/quarto-openapi-styles.css @@ -0,0 +1,28 @@ +/* Schema table layout */ +.quarto-openapi-table { + overflow-x: auto; +} + +.quarto-openapi-table colgroup { + display: none; +} + +.quarto-openapi-table table { + table-layout: auto; +} + +.quarto-openapi-table td:first-child, +.quarto-openapi-table th:first-child { + white-space: nowrap; +} + +.quarto-openapi-table td:nth-child(2), +.quarto-openapi-table th:nth-child(2) { + white-space: nowrap; +} + +/* Nesting indentation for schema property tables */ +.schema-nest-1 { padding-left: 1em; } +.schema-nest-2 { padding-left: 2em; } +.schema-nest-3 { padding-left: 3em; } +.schema-nest-4 { padding-left: 4em; }