diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 553a384..a63af95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,11 @@ jobs: # The pre-render script imports `yaml`; typecheck needs it resolvable. - run: npm ci --prefix _extensions/quarto-openapi - run: npm run typecheck + # The list-table tests render through Quarto, which must be at least the + # 1.9 the extension requires; older releases read list tables as bullets. + - uses: quarto-dev/quarto-actions/setup@v2 + with: + version: "1.9.32" - run: npm test - name: Check the example copy of the extension is in sync run: npm run sync-example -- --check diff --git a/README.md b/README.md index c2284f6..62742c7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ The extension runs as a pre-render script: it reads your OpenAPI spec and genera ## Requirements +Quarto 1.9 or later, or Quarto 2. Tables are emitted as list tables, which +earlier 1.x releases render as bullet lists. + The pre-render script runs on Node.js 22.6 or later, which Quarto must be able to find on `PATH` (or via `QUARTO_NODE`), and npm to install its one dependency. Quarto 2 runs `.ts` render scripts with Node directly; Quarto 1 @@ -140,7 +143,8 @@ quarto-openapi/ refs.ts # $ref resolution sections.ts # path grouping and endpoint rendering schema.ts # schema-to-table conversion - markdown.ts # grid table and markdown utilities + markdown.ts # list table and markdown utilities + escape.ts # makes description prose safe as markdown tests/ # node:test suites for lib/ scripts/ sync-example.ts # copies the extension into example/ @@ -172,6 +176,23 @@ Only TypeScript syntax that Node can erase is allowed, since Node strips types rather than compiling them — no `enum`, `namespace`, or parameter properties. `tsconfig.json` sets `erasableSyntaxOnly` to catch this at typecheck time. +## How descriptions are escaped + +OpenAPI descriptions are CommonMark written for arbitrary renderers, so they +contain constructs Quarto reads as markup. Three rewrites run over them before +anything is rendered, each skipping inline code spans and fenced code blocks: + +| In the spec | In the output | Why | +|---|---|---| +| `[start, start+interval)` | `\[start, start+interval)` | an unclosed `[` opens a span, which Quarto 2 rejects | +| `/v1/users/{guid}/keys` | `/v1/users/\{guid\}/keys` | `{...}` is Quarto's attribute syntax | +| `bundle` | `` ``{=html}bundle``{=html} `` | Quarto makes it a raw inline anyway, and warns once per element per page | + +All three render identically to the unescaped text. CommonMark autolinks +(``, ``) and comparisons (`a < b`) are +left alone, and braces and brackets inside code render verbatim, so a path +written as `` `GET /v1/users/{guid}/keys` `` keeps its braces. + ## Limitations - OpenAPI 3.x only. Swagger 2.0 specs are not supported. Use a tool like [swagger2openapi](https://github.com/Mermade/oas-kit/tree/main/packages/swagger2openapi) to convert. diff --git a/_extensions/quarto-openapi/_extension.yml b/_extensions/quarto-openapi/_extension.yml index b5d22dc..9c6b3da 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.4.2 -quarto-required: ">=1.6.0" +version: 0.5.0 +quarto-required: ">=1.9.0" contributes: metadata: project: diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts new file mode 100644 index 0000000..c9eacf3 --- /dev/null +++ b/_extensions/quarto-openapi/lib/escape.ts @@ -0,0 +1,313 @@ +/** + * Rewrites that make OpenAPI description text safe to emit as Quarto markdown. + * + * Descriptions are CommonMark written for arbitrary renderers, so they contain + * constructs Quarto reads as markup: an unclosed `[` opens a span, `{...}` + * opens an attribute block, and a bare HTML tag becomes a raw inline. Quarto 2 + * hard-errors on the first two and warns on the third. + * + * Every rewrite here skips code, where braces and angle brackets already render + * verbatim and a backslash or backtick would become literal output rather than + * markup. A CommonMark parser decides what is code: block structure is where + * hand-rolled scanning goes wrong, because a fence can open inside a list item, + * carry any indentation, and never close. + */ + +import MarkdownIt from "markdown-it"; +import type { Token } from "markdown-it"; + +/** CommonMark, so the reading matches the spec the descriptions are written to. */ +const md = new MarkdownIt("commonmark"); + +/** A run of text that is either markdown prose or literal code. */ +interface Segment { + code: boolean; + text: string; +} + +/** + * Token types whose source lines are literal content. `html_block` is not code, + * but its angle brackets and braces are markup for a browser rather than for + * Quarto, so the rewrites have to leave it alone too. + */ +const LITERAL_BLOCKS = new Set(["fence", "code_block", "html_block"]); + +/** + * Line ranges, as `[start, end)` line indices, that the parser reads as + * literal blocks. + */ +function literalBlockRanges(text: string): [number, number][] { + const ranges: [number, number][] = []; + const walk = (tokens: Token[]): void => { + for (const token of tokens) { + if (LITERAL_BLOCKS.has(token.type) && token.map) ranges.push(token.map); + if (token.children) walk(token.children); + } + }; + walk(md.parse(text, {})); + return ranges; +} + +/** The character offset at which each line of `text` starts. */ +function lineOffsets(text: string): number[] { + const offsets = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") offsets.push(i + 1); + } + return offsets; +} + +/** + * Split `text` into prose and code segments. Concatenating every segment + * reproduces `text`. + * + * Literal blocks come from the parser. Inline code spans are found by pairing + * backtick runs in what is left, which is all CommonMark asks for once block + * structure is settled. + */ +function splitCode(text: string): Segment[] { + const offsets = lineOffsets(text); + const blocks = literalBlockRanges(text) + .map(([start, end]): [number, number] => [ + offsets[start] ?? text.length, + offsets[end] ?? text.length, + ]) + .sort((a, b) => a[0] - b[0]); + + const segments: Segment[] = []; + const push = (code: boolean, chunk: string) => { + if (chunk) segments.push({ code, text: chunk }); + }; + + let cursor = 0; + for (const [start, end] of blocks) { + if (start < cursor) continue; // nested inside a block already taken + push(false, ""); + segments.push(...splitInlineCode(text.slice(cursor, start))); + push(true, text.slice(start, end)); + cursor = end; + } + segments.push(...splitInlineCode(text.slice(cursor))); + return segments; +} + +/** Split prose into segments, marking inline code spans as code. */ +function splitInlineCode(text: string): Segment[] { + const segments: Segment[] = []; + let start = 0; + let i = 0; + + const emit = (end: number, code: boolean) => { + if (end > start) segments.push({ code, text: text.slice(start, end) }); + start = end; + }; + + while (i < text.length) { + if (text[i] === "\\") { + // An escaped backtick cannot open a code span. + i += 2; + continue; + } + if (text[i] !== "`") { + i++; + continue; + } + // A code span closes on a backtick run of the same length. + let run = 0; + while (text[i + run] === "`") run++; + const close = text.indexOf("`".repeat(run), i + run); + emit(i, false); + i = close === -1 ? i + run : close + run; + emit(i, true); + } + emit(text.length, false); + return segments; +} + +/** + * `text` with every code span and literal block replaced by a newline, so a + * caller can scan the prose for syntax without matching an example of it. + * + * A newline rather than nothing, so the text on either side of a code span + * cannot join up and read as one construct. + */ +export function stripCode(text: string): string { + return splitCode(text) + .map((segment) => (segment.code ? "\n" : segment.text)) + .join(""); +} + +/** + * Escape square brackets that don't pair up into link or span syntax, e.g. + * interval notation like `[timestamp, timestamp+interval)`. Paired brackets + * are left alone, as is anything inside code. + */ +export function escapeUnmatchedBrackets(text: string): string { + const segments = splitCode(text); + const parts = segments.map((segment) => segment.text.split("")); + // Positions as [segment, offset]; brackets pair across intervening code. + const opens: [number, number][] = []; + const unmatched: [number, number][] = []; + + segments.forEach((segment, seg) => { + if (segment.code) return; + const chars = parts[seg]; + for (let i = 0; i < chars.length; i++) { + if (chars[i] === "\\") i++; + else if (chars[i] === "[") opens.push([seg, i]); + else if (chars[i] === "]") { + if (opens.length > 0) opens.pop(); + else unmatched.push([seg, i]); + } + } + }); + unmatched.push(...opens); + + // Splice from the back so earlier positions stay valid. + unmatched.sort(([segA, a], [segB, b]) => segB - segA || b - a); + for (const [seg, pos] of unmatched) parts[seg].splice(pos, 0, "\\"); + return parts.map((part) => part.join("")).join(""); +} + +/** + * Apply `fn` to the parts of `text` that are ordinary markdown prose, passing + * code through untouched. + */ +function transformOutsideCode( + text: string, + fn: (prose: string) => string, +): string { + return splitCode(text) + .map((segment) => (segment.code ? segment.text : fn(segment.text))) + .join(""); +} + +/** + * The container prefix of a line: the indentation and blockquote markers that + * place it inside a list item, a quote, or both. + */ +const CONTAINER_PREFIX = /^(?:[ \t]|>[ \t]?)*/; + +/** + * Wrap CommonMark HTML blocks in an explicit `{=html}` raw block. + * + * Quarto passes a bare block through as a raw HTML block and warns once per + * element. Fencing it says the same thing without the warning, and keeps the + * block a block. Marking each tag as a raw *inline* instead would not: the + * block becomes paragraph content, which leaves an empty paragraph on either + * side and runs the text of neighboring cells together in a search index. + * + * One difference to accept: Quarto reads the text inside a bare block as + * markdown, so `doesn't` picks up a curly apostrophe there and stays straight + * inside a fence. Fencing is what the format means, and no explicit form + * reproduces the implicit one exactly. + * + * Runs before `markInlineHtmlExplicit`, which skips code and so leaves the + * fenced block alone. + */ +export function fenceHtmlBlocks(text: string): string { + const blocks = md + .parse(text, {}) + .filter((token) => token.type === "html_block" && token.map) + .map((token) => token.map as [number, number]); + if (blocks.length === 0) return text; + + const lines = text.split("\n"); + const out: string[] = []; + let cursor = 0; + + for (const [start, end] of blocks) { + out.push(...lines.slice(cursor, start)); + const block = lines.slice(start, end); + + // Both fences repeat the block's container prefix — the indentation and + // blockquote markers that put it inside a list item or a quote. At column + // zero the opener would close that container, and the markers would then + // read as HTML content. + const prefix = block[0].match(CONTAINER_PREFIX)?.[0] ?? ""; + + // A block can also open on the line that opens its list item, as in + // `-
`. Fencing that needs the item's marker held back and every + // line re-indented, so leave the block bare instead: Quarto renders it + // the same either way and only warns. + if (!block[0].startsWith("<", prefix.length)) { + out.push(...block); + cursor = end; + continue; + } + + // The fence has to outrun the longest backtick run in the block it holds. + const longest = Math.max( + 0, + ...block.flatMap((line) => + [...line.matchAll(/`+/g)].map((run) => run[0].length) + ), + ); + const fence = "`".repeat(Math.max(3, longest + 1)); + out.push(`${prefix}${fence}{=html}`, ...block, `${prefix}${fence}`); + cursor = end; + } + out.push(...lines.slice(cursor)); + return out.join("\n"); +} + +/** + * Mark raw HTML in a description as an explicit raw inline. Descriptions are + * CommonMark, so a spec may legitimately use HTML; Quarto converts such a tag + * to a raw inline anyway and warns once per element per page. Saying so + * explicitly produces identical output without the warning. + * + * Deliberately conservative: a match needs a well-formed tag name and + * attributes free of angle brackets, which leaves alone `a < b`, `x <= 5`, and + * CommonMark autolinks like `` and ``, + * which Quarto renders as links and which wrapping would break. + */ +export function markInlineHtmlExplicit(text: string): string { + return transformOutsideCode(text, (s) => + s.replace( + /<\/?[A-Za-z][A-Za-z0-9-]*(?:\s+[^<>]*?)?\/?>/g, + (tag) => `\`${tag}\`{=html}`, + ), + ); +} + +/** + * Escape URL path parameters (`/{guid}` -> `/\{guid\}`) in prose. Quarto + * reserves `{...}` for attribute syntax and rejects bare braces, but renders + * them verbatim inside code — where a backslash would be literal and corrupt + * the output as `GET /v1/users/\{guid\}/keys`. + * + * OpenAPI puts no character restrictions on parameter names, so any brace pair + * following a slash and holding a single path segment counts. + */ +export function escapePathBracesOutsideCode(text: string): string { + return transformOutsideCode(text, (s) => + s.replace(/\/\{([^{}\/\s]+)\}/g, "/\\{$1\\}"), + ); +} + +/** + * Apply the prose-safety rewrites to every `description` field in the spec, in + * place, so all sinks — prose, tables, tabsets — emit safe text. + * + * HTML blocks are fenced first, so the later passes see them as code and leave + * them verbatim. Remaining tags are inline, and marking them wraps each one in + * backticks, so the bracket pass then sees those as code spans and leaves any + * `[` inside an attribute (e.g. `href="…?a[0]=1"`) alone. + */ +export function escapeSpecDescriptions(node: unknown): void { + if (Array.isArray(node)) { + for (const item of node) escapeSpecDescriptions(item); + } else if (node !== null && typeof node === "object") { + const obj = node as Record; + for (const [key, value] of Object.entries(obj)) { + if (key === "description" && typeof value === "string") { + obj[key] = escapeUnmatchedBrackets( + markInlineHtmlExplicit(fenceHtmlBlocks(value)), + ); + } else { + escapeSpecDescriptions(value); + } + } + } +} diff --git a/_extensions/quarto-openapi/lib/markdown.ts b/_extensions/quarto-openapi/lib/markdown.ts index 452cba0..4c1bdbc 100644 --- a/_extensions/quarto-openapi/lib/markdown.ts +++ b/_extensions/quarto-openapi/lib/markdown.ts @@ -1,38 +1,52 @@ /** * Markdown generation utilities for Quarto-compatible output. - * Produces Pandoc grid tables, fenced code blocks, tabsets, and anchored headings. + * Produces Quarto list tables, fenced code blocks, tabsets, and anchored headings. */ export interface TableRow { cells: string[]; } -/** Fenced div markers wrapping every gridTable() block. */ +/** Fenced div markers wrapping every listTable() block. */ export const TABLE_DIV_OPEN = "::: {.quarto-openapi-table}"; export const TABLE_DIV_CLOSE = ":::"; /** - * Generate a Pandoc grid table. + * Format a column-width fraction the way Lua's tostring does (%.14g), which + * is what Quarto's own grid-to-list table conversion emits. + */ +function formatWidth(x: number): string { + let s = x.toPrecision(14); + if (!s.includes("e") && s.includes(".")) { + s = s.replace(/0+$/, "").replace(/\.$/, ""); + } + return s; +} + +/** + * Generate a Quarto list table. + * + * Cells are emitted verbatim as markdown: a cell's first line follows the item + * marker and later lines are indented to the item's content level, so + * multi-line and multi-paragraph cells work. A row whose cells are all single + * blocks emits as a tight list; a row with any multi-block cell (one holding a + * blank line) emits loose, matching Pandoc list semantics. * - * Grid tables support multi-line cells and are the most flexible - * table format in Pandoc/Quarto. + * Unlike the grid tables this replaces, cell text does not have to be in its + * final form before layout — there is no alignment to preserve. * - * Grid tables are fixed-width: cells must be in their final form, because - * changing a cell's length afterward breaks the alignment Pandoc needs to - * read the table. + * Column width fractions are derived from the character widths a grid table + * would have used, so rendered column proportions do not change. */ -export function gridTable(headers: string[], rows: TableRow[]): string[] { +export function listTable(headers: string[], rows: TableRow[]): string[] { if (rows.length === 0) return []; const numCols = headers.length; - // Split each cell into lines for multi-line support const headerLines = headers.map((h) => h.split("\n")); - const rowLines = rows.map((row) => - row.cells.map((cell) => cell.split("\n")) - ); + const rowLines = rows.map((row) => row.cells.map((cell) => cell.split("\n"))); - // Compute column widths (max width of any line in any cell) + // Column content widths (max line length, minimum 4). const colWidths = new Array(numCols).fill(0); for (let col = 0; col < numCols; col++) { for (const line of headerLines[col]) { @@ -43,36 +57,40 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] { colWidths[col] = Math.max(colWidths[col], line.length); } } - // Minimum width of 4 for readability colWidths[col] = Math.max(colWidths[col], 4); } + // Width fractions as Pandoc's grid-table reader would have derived them: + // each column takes its content width plus two padding chars and one + // separator, over the full line width (or 72, whichever is larger). + const colUnits = colWidths.map((w) => w + 3); + const total = colUnits.reduce((a, b) => a + b, 0); + const denom = Math.max(72, total + 1); + const widths = colUnits.map((u) => formatWidth(u / denom)).join(","); + const lines: string[] = []; + lines.push(TABLE_DIV_OPEN); + lines.push(`::: {.list-table header-rows="1" widths="${widths}"}`); + lines.push(""); - const separator = (char: string) => - "+" + colWidths.map((w) => char.repeat(w + 2)).join("+") + "+"; - - const emitRow = (cellLines: string[][]) => { - const maxLines = Math.max(...cellLines.map((cl) => cl.length)); - for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { - const parts = cellLines.map((cl, col) => { - const text = lineIdx < cl.length ? cl[lineIdx] : ""; - return " " + text.padEnd(colWidths[col]) + " "; - }); - lines.push("|" + parts.join("|") + "|"); - } + const emitGroup = (cellLines: string[][]) => { + // Loose when any cell holds more than one block. + const loose = cellLines.some((cl) => cl.some((line) => line === "")); + cellLines.forEach((cl, col) => { + if (loose && col > 0) lines.push(""); + lines.push((col === 0 ? "* * " : " * ") + cl[0]); + for (const line of cl.slice(1)) { + lines.push(line === "" ? "" : " " + line); + } + }); + lines.push(""); }; - lines.push(TABLE_DIV_OPEN); - lines.push(separator("-")); - emitRow(headerLines); - lines.push(separator("=")); - - for (const row of rowLines) { - emitRow(row); - lines.push(separator("-")); - } + emitGroup(headerLines); + for (const row of rowLines) emitGroup(row); + lines.push(":::"); + lines.push(""); lines.push(TABLE_DIV_CLOSE); return lines; @@ -115,6 +133,34 @@ export function sanitizeId(raw: string): string { return raw.replace(/[^A-Za-z0-9._\/-]/g, "-"); } +/** + * The anchor Pandoc derives from heading text when the heading declares none: + * drop everything up to the first letter, drop punctuation, lowercase, and + * join words with hyphens. An empty result becomes `section`. + */ +export function autoIdentifier(text: string): string { + const id = text + .replace(/^[^\p{L}]+/u, "") + .toLowerCase() + .replace(/[^\p{L}\p{N}_.\- \t]/gu, "") + .trim() + .replace(/[ \t]+/g, "-"); + return id || "section"; +} + +/** + * The first anchor of the form `base-1`, `base-2`, … that `taken` does not + * hold. Matches how Pandoc numbers a heading whose anchor is already in use. + */ +export function disambiguateId( + base: string, + taken: ReadonlySet, +): string { + let n = 1; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + /** * Generate a heading with an explicit anchor ID. */ diff --git a/_extensions/quarto-openapi/lib/schema.ts b/_extensions/quarto-openapi/lib/schema.ts index 73c9d2b..68fb0a4 100644 --- a/_extensions/quarto-openapi/lib/schema.ts +++ b/_extensions/quarto-openapi/lib/schema.ts @@ -9,7 +9,7 @@ import type { Schema, Reference } from "./types.ts"; import { isReference } from "./types.ts"; import type { OpenAPISpec } from "./types.ts"; import { resolve } from "./refs.ts"; -import { gridTable, type TableRow } from "./markdown.ts"; +import { listTable, type TableRow } from "./markdown.ts"; interface PropertyRow { name: string; @@ -172,7 +172,7 @@ function renderObject( cells: [row.name, row.type, row.description], })); - lines.push(...gridTable(["Name", "Type", "Description"], tableRows)); + lines.push(...listTable(["Name", "Type", "Description"], tableRows)); return lines; } @@ -332,7 +332,10 @@ function buildDescription(schema: Schema, isRequired: boolean): string { attrs.push(`Default: \`${formatValue(schema.default)}\``); if (schema.minimum !== undefined) attrs.push(`Minimum: \`${schema.minimum}\``); if (schema.maximum !== undefined) attrs.push(`Maximum: \`${schema.maximum}\``); - if (schema.enum) attrs.push(`Enum: ${schema.enum.map((v) => `\`${v}\``).join(", ")}`); + if (schema.enum) + attrs.push( + `Enum: ${schema.enum.map((v) => `\`${formatValue(v)}\``).join(", ")}`, + ); if (schema.example !== undefined) attrs.push(`Example: \`${formatValue(schema.example)}\``); @@ -380,6 +383,7 @@ function formatPrimitiveType(schema: Schema): string { function formatValue(value: unknown): string { if (value === null) return "null"; - if (typeof value === "string") return value; + // Quoted, because an empty code span renders as nothing at all. + if (typeof value === "string") return value === "" ? '""' : value; return JSON.stringify(value); } diff --git a/_extensions/quarto-openapi/lib/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index a6519c8..de10ce7 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -16,11 +16,14 @@ import type { import { HTTP_METHODS, isReference } from "./types.ts"; import { resolve } from "./refs.ts"; import { renderSchema } from "./schema.ts"; +import { stripCode } from "./escape.ts"; import { + autoIdentifier, + disambiguateId, heading, methodBadge, pathToAnchor, - gridTable, + listTable, type TableRow, } from "./markdown.ts"; @@ -39,6 +42,11 @@ export interface Endpoint { export interface RenderOptions { anchorStyle: "operation-id" | "path"; + /** + * Anchors that are already in use. A section heading whose derived anchor + * is in this set declares a numbered anchor instead. + */ + takenIds?: Set; } const DEFAULT_OPTIONS: RenderOptions = { anchorStyle: "operation-id" }; @@ -102,8 +110,8 @@ export function rewriteOperationIdRefs(text: string, idToPath: Map): void { const walk = (node: unknown): void => { @@ -135,13 +143,80 @@ export function renderApiReferenceBody( if (anchorStyle === "path") { rewriteSpecRefs(spec, buildOperationIdToPathMap(spec)); } + // Endpoints first: their anchors do not depend on the section headings, and + // a heading's derived anchor has to avoid every one of them. + const sections = groupByResource(spec); + const bodies = sections.map((section) => + section.endpoints.flatMap((endpoint) => [ + ...renderEndpoint(spec, endpoint, { anchorStyle }), + "", + ]) + ); + + const takenIds = declaredAnchors(spec); + for (const anchor of anchorsIn(bodies.flat().join("\n"))) takenIds.add(anchor); + const lines: string[] = []; - for (const section of groupByResource(spec)) { - lines.push(...renderSection(spec, section, { anchorStyle })); - } + sections.forEach((section, i) => { + lines.push( + heading(2, section.name, claimSectionAnchor(section.name, { + anchorStyle, + takenIds, + })), + ); + lines.push(""); + lines.push(...bodies[i]); + }); return lines; } +/** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ +const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; + +/** + * Every anchor `markdown` declares, ignoring any written inside code, which is + * an example of the syntax rather than a use of it. Takes one whole document, + * because a fenced block only reads as code when its opener and closer are + * scanned together. + */ +function anchorsIn(markdown: string): Set { + const anchors = new Set(); + for (const [, hash, attr] of stripCode(markdown).matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); + } + return anchors; +} + +/** + * The anchors the spec's own prose declares. Covers prose the rendered body + * does not hold, above all `info.description`, which the caller prepends. + * + * Quarto renames a derived anchor that collides with another derived anchor. + * It does not compare a derived anchor with a declared one: Quarto 1 warns and + * emits the duplicate, Quarto 2 emits it silently. So a section heading, whose + * anchor Quarto derives from its text, has to avoid these itself. + */ +function declaredAnchors(spec: OpenAPISpec): Set { + const anchors = new Set(); + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) walk(item); + } else if (node !== null && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + if (key === "description" && typeof value === "string") { + // One description at a time: each is its own document, and an + // unclosed fence in one must not swallow the next. + for (const anchor of anchorsIn(value)) anchors.add(anchor); + } else { + walk(value); + } + } + } + }; + walk(spec); + return anchors; +} + /** * Extract the resource name from a path. * /v1/content/{guid}/bundles -> "content" @@ -261,13 +336,40 @@ export function groupByResource(spec: OpenAPISpec): Section[] { return sections; } +/** + * The anchor to declare on a section heading, or undefined to let Quarto + * derive it. A declared anchor is only needed when the derived one is taken; + * declaring one everywhere would pin anchors that Quarto already gets right. + * + * Adds whichever anchor the heading ends up with to `options.takenIds`, so two + * sections that collide take different numbers. + */ +function claimSectionAnchor( + name: string, + options: RenderOptions, +): string | undefined { + const taken = options.takenIds; + if (!taken) return undefined; + + const derived = autoIdentifier(name); + if (!taken.has(derived)) { + taken.add(derived); + return undefined; + } + const anchor = disambiguateId(derived, taken); + taken.add(anchor); + return anchor; +} + /** * Render a section with a ## heading and ### per endpoint. */ export function renderSection(spec: OpenAPISpec, section: Section, options: RenderOptions = DEFAULT_OPTIONS): string[] { const lines: string[] = []; - lines.push(heading(2, section.name)); + lines.push( + heading(2, section.name, claimSectionAnchor(section.name, options)), + ); lines.push(""); for (const endpoint of section.endpoints) { @@ -444,7 +546,7 @@ function renderParameters( }; }); - lines.push(...gridTable(["Name", "Type", "Description"], rows)); + lines.push(...listTable(["Name", "Type", "Description"], rows)); lines.push(""); } diff --git a/_extensions/quarto-openapi/openapi-to-markdown.ts b/_extensions/quarto-openapi/openapi-to-markdown.ts index 12277d5..ca1e93d 100644 --- a/_extensions/quarto-openapi/openapi-to-markdown.ts +++ b/_extensions/quarto-openapi/openapi-to-markdown.ts @@ -20,6 +20,10 @@ import { join, dirname, extname } from "node:path"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import type { OpenAPISpec } from "./lib/types.ts"; import { groupByResource, renderApiReferenceBody } from "./lib/sections.ts"; +import { + escapePathBracesOutsideCode, + escapeSpecDescriptions, +} from "./lib/escape.ts"; type AnchorStyle = "operation-id" | "path"; @@ -102,6 +106,9 @@ async function main() { process.exit(1); } + // Make description prose safe to emit as markdown before anything reads it. + escapeSpecDescriptions(spec); + console.log(`Loaded OpenAPI ${spec.openapi} spec: ${spec.info.title}`); console.log(`Paths: ${Object.keys(spec.paths).length}`); console.log( @@ -143,7 +150,10 @@ async function main() { // Sections lines.push(...body); - const output = lines.join("\n") + "\n"; + // Escape URL path parameters last, over the whole document: they occur in + // generated text as well as in descriptions. Anchor attributes are safe from + // this — sanitizeId strips braces, so `/{word}` never appears inside one. + const output = escapePathBracesOutsideCode(lines.join("\n") + "\n"); // Write output const outputPath = join(projectDir, config.output); diff --git a/_extensions/quarto-openapi/package-lock.json b/_extensions/quarto-openapi/package-lock.json index 774cf4d..5acb7b6 100644 --- a/_extensions/quarto-openapi/package-lock.json +++ b/_extensions/quarto-openapi/package-lock.json @@ -6,9 +6,105 @@ "": { "name": "quarto-openapi-extension", "dependencies": { + "markdown-it": "^15.0.1", "yaml": "^2.9.0" } }, + "node_modules/argparse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.1.tgz", + "integrity": "sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, + "node_modules/markdown-it": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.1.tgz", + "integrity": "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/_extensions/quarto-openapi/package.json b/_extensions/quarto-openapi/package.json index 4908443..c51d335 100644 --- a/_extensions/quarto-openapi/package.json +++ b/_extensions/quarto-openapi/package.json @@ -4,6 +4,7 @@ "type": "module", "description": "Dependencies for the quarto-openapi pre-render script. node resolves bare specifiers from node_modules upward from the script's directory, so they must be installed here, not at the repo root.", "dependencies": { + "markdown-it": "^15.0.1", "yaml": "^2.9.0" } } diff --git a/example/_extensions/posit-dev/quarto-openapi/_extension.yml b/example/_extensions/posit-dev/quarto-openapi/_extension.yml index b5d22dc..9c6b3da 100644 --- a/example/_extensions/posit-dev/quarto-openapi/_extension.yml +++ b/example/_extensions/posit-dev/quarto-openapi/_extension.yml @@ -1,7 +1,7 @@ title: quarto-openapi author: Posit Software, PBC -version: 0.4.2 -quarto-required: ">=1.6.0" +version: 0.5.0 +quarto-required: ">=1.9.0" contributes: metadata: project: diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts new file mode 100644 index 0000000..c9eacf3 --- /dev/null +++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts @@ -0,0 +1,313 @@ +/** + * Rewrites that make OpenAPI description text safe to emit as Quarto markdown. + * + * Descriptions are CommonMark written for arbitrary renderers, so they contain + * constructs Quarto reads as markup: an unclosed `[` opens a span, `{...}` + * opens an attribute block, and a bare HTML tag becomes a raw inline. Quarto 2 + * hard-errors on the first two and warns on the third. + * + * Every rewrite here skips code, where braces and angle brackets already render + * verbatim and a backslash or backtick would become literal output rather than + * markup. A CommonMark parser decides what is code: block structure is where + * hand-rolled scanning goes wrong, because a fence can open inside a list item, + * carry any indentation, and never close. + */ + +import MarkdownIt from "markdown-it"; +import type { Token } from "markdown-it"; + +/** CommonMark, so the reading matches the spec the descriptions are written to. */ +const md = new MarkdownIt("commonmark"); + +/** A run of text that is either markdown prose or literal code. */ +interface Segment { + code: boolean; + text: string; +} + +/** + * Token types whose source lines are literal content. `html_block` is not code, + * but its angle brackets and braces are markup for a browser rather than for + * Quarto, so the rewrites have to leave it alone too. + */ +const LITERAL_BLOCKS = new Set(["fence", "code_block", "html_block"]); + +/** + * Line ranges, as `[start, end)` line indices, that the parser reads as + * literal blocks. + */ +function literalBlockRanges(text: string): [number, number][] { + const ranges: [number, number][] = []; + const walk = (tokens: Token[]): void => { + for (const token of tokens) { + if (LITERAL_BLOCKS.has(token.type) && token.map) ranges.push(token.map); + if (token.children) walk(token.children); + } + }; + walk(md.parse(text, {})); + return ranges; +} + +/** The character offset at which each line of `text` starts. */ +function lineOffsets(text: string): number[] { + const offsets = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") offsets.push(i + 1); + } + return offsets; +} + +/** + * Split `text` into prose and code segments. Concatenating every segment + * reproduces `text`. + * + * Literal blocks come from the parser. Inline code spans are found by pairing + * backtick runs in what is left, which is all CommonMark asks for once block + * structure is settled. + */ +function splitCode(text: string): Segment[] { + const offsets = lineOffsets(text); + const blocks = literalBlockRanges(text) + .map(([start, end]): [number, number] => [ + offsets[start] ?? text.length, + offsets[end] ?? text.length, + ]) + .sort((a, b) => a[0] - b[0]); + + const segments: Segment[] = []; + const push = (code: boolean, chunk: string) => { + if (chunk) segments.push({ code, text: chunk }); + }; + + let cursor = 0; + for (const [start, end] of blocks) { + if (start < cursor) continue; // nested inside a block already taken + push(false, ""); + segments.push(...splitInlineCode(text.slice(cursor, start))); + push(true, text.slice(start, end)); + cursor = end; + } + segments.push(...splitInlineCode(text.slice(cursor))); + return segments; +} + +/** Split prose into segments, marking inline code spans as code. */ +function splitInlineCode(text: string): Segment[] { + const segments: Segment[] = []; + let start = 0; + let i = 0; + + const emit = (end: number, code: boolean) => { + if (end > start) segments.push({ code, text: text.slice(start, end) }); + start = end; + }; + + while (i < text.length) { + if (text[i] === "\\") { + // An escaped backtick cannot open a code span. + i += 2; + continue; + } + if (text[i] !== "`") { + i++; + continue; + } + // A code span closes on a backtick run of the same length. + let run = 0; + while (text[i + run] === "`") run++; + const close = text.indexOf("`".repeat(run), i + run); + emit(i, false); + i = close === -1 ? i + run : close + run; + emit(i, true); + } + emit(text.length, false); + return segments; +} + +/** + * `text` with every code span and literal block replaced by a newline, so a + * caller can scan the prose for syntax without matching an example of it. + * + * A newline rather than nothing, so the text on either side of a code span + * cannot join up and read as one construct. + */ +export function stripCode(text: string): string { + return splitCode(text) + .map((segment) => (segment.code ? "\n" : segment.text)) + .join(""); +} + +/** + * Escape square brackets that don't pair up into link or span syntax, e.g. + * interval notation like `[timestamp, timestamp+interval)`. Paired brackets + * are left alone, as is anything inside code. + */ +export function escapeUnmatchedBrackets(text: string): string { + const segments = splitCode(text); + const parts = segments.map((segment) => segment.text.split("")); + // Positions as [segment, offset]; brackets pair across intervening code. + const opens: [number, number][] = []; + const unmatched: [number, number][] = []; + + segments.forEach((segment, seg) => { + if (segment.code) return; + const chars = parts[seg]; + for (let i = 0; i < chars.length; i++) { + if (chars[i] === "\\") i++; + else if (chars[i] === "[") opens.push([seg, i]); + else if (chars[i] === "]") { + if (opens.length > 0) opens.pop(); + else unmatched.push([seg, i]); + } + } + }); + unmatched.push(...opens); + + // Splice from the back so earlier positions stay valid. + unmatched.sort(([segA, a], [segB, b]) => segB - segA || b - a); + for (const [seg, pos] of unmatched) parts[seg].splice(pos, 0, "\\"); + return parts.map((part) => part.join("")).join(""); +} + +/** + * Apply `fn` to the parts of `text` that are ordinary markdown prose, passing + * code through untouched. + */ +function transformOutsideCode( + text: string, + fn: (prose: string) => string, +): string { + return splitCode(text) + .map((segment) => (segment.code ? segment.text : fn(segment.text))) + .join(""); +} + +/** + * The container prefix of a line: the indentation and blockquote markers that + * place it inside a list item, a quote, or both. + */ +const CONTAINER_PREFIX = /^(?:[ \t]|>[ \t]?)*/; + +/** + * Wrap CommonMark HTML blocks in an explicit `{=html}` raw block. + * + * Quarto passes a bare block through as a raw HTML block and warns once per + * element. Fencing it says the same thing without the warning, and keeps the + * block a block. Marking each tag as a raw *inline* instead would not: the + * block becomes paragraph content, which leaves an empty paragraph on either + * side and runs the text of neighboring cells together in a search index. + * + * One difference to accept: Quarto reads the text inside a bare block as + * markdown, so `doesn't` picks up a curly apostrophe there and stays straight + * inside a fence. Fencing is what the format means, and no explicit form + * reproduces the implicit one exactly. + * + * Runs before `markInlineHtmlExplicit`, which skips code and so leaves the + * fenced block alone. + */ +export function fenceHtmlBlocks(text: string): string { + const blocks = md + .parse(text, {}) + .filter((token) => token.type === "html_block" && token.map) + .map((token) => token.map as [number, number]); + if (blocks.length === 0) return text; + + const lines = text.split("\n"); + const out: string[] = []; + let cursor = 0; + + for (const [start, end] of blocks) { + out.push(...lines.slice(cursor, start)); + const block = lines.slice(start, end); + + // Both fences repeat the block's container prefix — the indentation and + // blockquote markers that put it inside a list item or a quote. At column + // zero the opener would close that container, and the markers would then + // read as HTML content. + const prefix = block[0].match(CONTAINER_PREFIX)?.[0] ?? ""; + + // A block can also open on the line that opens its list item, as in + // `-
`. Fencing that needs the item's marker held back and every + // line re-indented, so leave the block bare instead: Quarto renders it + // the same either way and only warns. + if (!block[0].startsWith("<", prefix.length)) { + out.push(...block); + cursor = end; + continue; + } + + // The fence has to outrun the longest backtick run in the block it holds. + const longest = Math.max( + 0, + ...block.flatMap((line) => + [...line.matchAll(/`+/g)].map((run) => run[0].length) + ), + ); + const fence = "`".repeat(Math.max(3, longest + 1)); + out.push(`${prefix}${fence}{=html}`, ...block, `${prefix}${fence}`); + cursor = end; + } + out.push(...lines.slice(cursor)); + return out.join("\n"); +} + +/** + * Mark raw HTML in a description as an explicit raw inline. Descriptions are + * CommonMark, so a spec may legitimately use HTML; Quarto converts such a tag + * to a raw inline anyway and warns once per element per page. Saying so + * explicitly produces identical output without the warning. + * + * Deliberately conservative: a match needs a well-formed tag name and + * attributes free of angle brackets, which leaves alone `a < b`, `x <= 5`, and + * CommonMark autolinks like `` and ``, + * which Quarto renders as links and which wrapping would break. + */ +export function markInlineHtmlExplicit(text: string): string { + return transformOutsideCode(text, (s) => + s.replace( + /<\/?[A-Za-z][A-Za-z0-9-]*(?:\s+[^<>]*?)?\/?>/g, + (tag) => `\`${tag}\`{=html}`, + ), + ); +} + +/** + * Escape URL path parameters (`/{guid}` -> `/\{guid\}`) in prose. Quarto + * reserves `{...}` for attribute syntax and rejects bare braces, but renders + * them verbatim inside code — where a backslash would be literal and corrupt + * the output as `GET /v1/users/\{guid\}/keys`. + * + * OpenAPI puts no character restrictions on parameter names, so any brace pair + * following a slash and holding a single path segment counts. + */ +export function escapePathBracesOutsideCode(text: string): string { + return transformOutsideCode(text, (s) => + s.replace(/\/\{([^{}\/\s]+)\}/g, "/\\{$1\\}"), + ); +} + +/** + * Apply the prose-safety rewrites to every `description` field in the spec, in + * place, so all sinks — prose, tables, tabsets — emit safe text. + * + * HTML blocks are fenced first, so the later passes see them as code and leave + * them verbatim. Remaining tags are inline, and marking them wraps each one in + * backticks, so the bracket pass then sees those as code spans and leaves any + * `[` inside an attribute (e.g. `href="…?a[0]=1"`) alone. + */ +export function escapeSpecDescriptions(node: unknown): void { + if (Array.isArray(node)) { + for (const item of node) escapeSpecDescriptions(item); + } else if (node !== null && typeof node === "object") { + const obj = node as Record; + for (const [key, value] of Object.entries(obj)) { + if (key === "description" && typeof value === "string") { + obj[key] = escapeUnmatchedBrackets( + markInlineHtmlExplicit(fenceHtmlBlocks(value)), + ); + } else { + escapeSpecDescriptions(value); + } + } + } +} diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts b/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts index 452cba0..4c1bdbc 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts @@ -1,38 +1,52 @@ /** * Markdown generation utilities for Quarto-compatible output. - * Produces Pandoc grid tables, fenced code blocks, tabsets, and anchored headings. + * Produces Quarto list tables, fenced code blocks, tabsets, and anchored headings. */ export interface TableRow { cells: string[]; } -/** Fenced div markers wrapping every gridTable() block. */ +/** Fenced div markers wrapping every listTable() block. */ export const TABLE_DIV_OPEN = "::: {.quarto-openapi-table}"; export const TABLE_DIV_CLOSE = ":::"; /** - * Generate a Pandoc grid table. + * Format a column-width fraction the way Lua's tostring does (%.14g), which + * is what Quarto's own grid-to-list table conversion emits. + */ +function formatWidth(x: number): string { + let s = x.toPrecision(14); + if (!s.includes("e") && s.includes(".")) { + s = s.replace(/0+$/, "").replace(/\.$/, ""); + } + return s; +} + +/** + * Generate a Quarto list table. + * + * Cells are emitted verbatim as markdown: a cell's first line follows the item + * marker and later lines are indented to the item's content level, so + * multi-line and multi-paragraph cells work. A row whose cells are all single + * blocks emits as a tight list; a row with any multi-block cell (one holding a + * blank line) emits loose, matching Pandoc list semantics. * - * Grid tables support multi-line cells and are the most flexible - * table format in Pandoc/Quarto. + * Unlike the grid tables this replaces, cell text does not have to be in its + * final form before layout — there is no alignment to preserve. * - * Grid tables are fixed-width: cells must be in their final form, because - * changing a cell's length afterward breaks the alignment Pandoc needs to - * read the table. + * Column width fractions are derived from the character widths a grid table + * would have used, so rendered column proportions do not change. */ -export function gridTable(headers: string[], rows: TableRow[]): string[] { +export function listTable(headers: string[], rows: TableRow[]): string[] { if (rows.length === 0) return []; const numCols = headers.length; - // Split each cell into lines for multi-line support const headerLines = headers.map((h) => h.split("\n")); - const rowLines = rows.map((row) => - row.cells.map((cell) => cell.split("\n")) - ); + const rowLines = rows.map((row) => row.cells.map((cell) => cell.split("\n"))); - // Compute column widths (max width of any line in any cell) + // Column content widths (max line length, minimum 4). const colWidths = new Array(numCols).fill(0); for (let col = 0; col < numCols; col++) { for (const line of headerLines[col]) { @@ -43,36 +57,40 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] { colWidths[col] = Math.max(colWidths[col], line.length); } } - // Minimum width of 4 for readability colWidths[col] = Math.max(colWidths[col], 4); } + // Width fractions as Pandoc's grid-table reader would have derived them: + // each column takes its content width plus two padding chars and one + // separator, over the full line width (or 72, whichever is larger). + const colUnits = colWidths.map((w) => w + 3); + const total = colUnits.reduce((a, b) => a + b, 0); + const denom = Math.max(72, total + 1); + const widths = colUnits.map((u) => formatWidth(u / denom)).join(","); + const lines: string[] = []; + lines.push(TABLE_DIV_OPEN); + lines.push(`::: {.list-table header-rows="1" widths="${widths}"}`); + lines.push(""); - const separator = (char: string) => - "+" + colWidths.map((w) => char.repeat(w + 2)).join("+") + "+"; - - const emitRow = (cellLines: string[][]) => { - const maxLines = Math.max(...cellLines.map((cl) => cl.length)); - for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { - const parts = cellLines.map((cl, col) => { - const text = lineIdx < cl.length ? cl[lineIdx] : ""; - return " " + text.padEnd(colWidths[col]) + " "; - }); - lines.push("|" + parts.join("|") + "|"); - } + const emitGroup = (cellLines: string[][]) => { + // Loose when any cell holds more than one block. + const loose = cellLines.some((cl) => cl.some((line) => line === "")); + cellLines.forEach((cl, col) => { + if (loose && col > 0) lines.push(""); + lines.push((col === 0 ? "* * " : " * ") + cl[0]); + for (const line of cl.slice(1)) { + lines.push(line === "" ? "" : " " + line); + } + }); + lines.push(""); }; - lines.push(TABLE_DIV_OPEN); - lines.push(separator("-")); - emitRow(headerLines); - lines.push(separator("=")); - - for (const row of rowLines) { - emitRow(row); - lines.push(separator("-")); - } + emitGroup(headerLines); + for (const row of rowLines) emitGroup(row); + lines.push(":::"); + lines.push(""); lines.push(TABLE_DIV_CLOSE); return lines; @@ -115,6 +133,34 @@ export function sanitizeId(raw: string): string { return raw.replace(/[^A-Za-z0-9._\/-]/g, "-"); } +/** + * The anchor Pandoc derives from heading text when the heading declares none: + * drop everything up to the first letter, drop punctuation, lowercase, and + * join words with hyphens. An empty result becomes `section`. + */ +export function autoIdentifier(text: string): string { + const id = text + .replace(/^[^\p{L}]+/u, "") + .toLowerCase() + .replace(/[^\p{L}\p{N}_.\- \t]/gu, "") + .trim() + .replace(/[ \t]+/g, "-"); + return id || "section"; +} + +/** + * The first anchor of the form `base-1`, `base-2`, … that `taken` does not + * hold. Matches how Pandoc numbers a heading whose anchor is already in use. + */ +export function disambiguateId( + base: string, + taken: ReadonlySet, +): string { + let n = 1; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + /** * Generate a heading with an explicit anchor ID. */ diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts b/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts index 73c9d2b..68fb0a4 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts @@ -9,7 +9,7 @@ import type { Schema, Reference } from "./types.ts"; import { isReference } from "./types.ts"; import type { OpenAPISpec } from "./types.ts"; import { resolve } from "./refs.ts"; -import { gridTable, type TableRow } from "./markdown.ts"; +import { listTable, type TableRow } from "./markdown.ts"; interface PropertyRow { name: string; @@ -172,7 +172,7 @@ function renderObject( cells: [row.name, row.type, row.description], })); - lines.push(...gridTable(["Name", "Type", "Description"], tableRows)); + lines.push(...listTable(["Name", "Type", "Description"], tableRows)); return lines; } @@ -332,7 +332,10 @@ function buildDescription(schema: Schema, isRequired: boolean): string { attrs.push(`Default: \`${formatValue(schema.default)}\``); if (schema.minimum !== undefined) attrs.push(`Minimum: \`${schema.minimum}\``); if (schema.maximum !== undefined) attrs.push(`Maximum: \`${schema.maximum}\``); - if (schema.enum) attrs.push(`Enum: ${schema.enum.map((v) => `\`${v}\``).join(", ")}`); + if (schema.enum) + attrs.push( + `Enum: ${schema.enum.map((v) => `\`${formatValue(v)}\``).join(", ")}`, + ); if (schema.example !== undefined) attrs.push(`Example: \`${formatValue(schema.example)}\``); @@ -380,6 +383,7 @@ function formatPrimitiveType(schema: Schema): string { function formatValue(value: unknown): string { if (value === null) return "null"; - if (typeof value === "string") return value; + // Quoted, because an empty code span renders as nothing at all. + if (typeof value === "string") return value === "" ? '""' : value; return JSON.stringify(value); } diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts index a6519c8..de10ce7 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts @@ -16,11 +16,14 @@ import type { import { HTTP_METHODS, isReference } from "./types.ts"; import { resolve } from "./refs.ts"; import { renderSchema } from "./schema.ts"; +import { stripCode } from "./escape.ts"; import { + autoIdentifier, + disambiguateId, heading, methodBadge, pathToAnchor, - gridTable, + listTable, type TableRow, } from "./markdown.ts"; @@ -39,6 +42,11 @@ export interface Endpoint { export interface RenderOptions { anchorStyle: "operation-id" | "path"; + /** + * Anchors that are already in use. A section heading whose derived anchor + * is in this set declares a numbered anchor instead. + */ + takenIds?: Set; } const DEFAULT_OPTIONS: RenderOptions = { anchorStyle: "operation-id" }; @@ -102,8 +110,8 @@ export function rewriteOperationIdRefs(text: string, idToPath: Map): void { const walk = (node: unknown): void => { @@ -135,13 +143,80 @@ export function renderApiReferenceBody( if (anchorStyle === "path") { rewriteSpecRefs(spec, buildOperationIdToPathMap(spec)); } + // Endpoints first: their anchors do not depend on the section headings, and + // a heading's derived anchor has to avoid every one of them. + const sections = groupByResource(spec); + const bodies = sections.map((section) => + section.endpoints.flatMap((endpoint) => [ + ...renderEndpoint(spec, endpoint, { anchorStyle }), + "", + ]) + ); + + const takenIds = declaredAnchors(spec); + for (const anchor of anchorsIn(bodies.flat().join("\n"))) takenIds.add(anchor); + const lines: string[] = []; - for (const section of groupByResource(spec)) { - lines.push(...renderSection(spec, section, { anchorStyle })); - } + sections.forEach((section, i) => { + lines.push( + heading(2, section.name, claimSectionAnchor(section.name, { + anchorStyle, + takenIds, + })), + ); + lines.push(""); + lines.push(...bodies[i]); + }); return lines; } +/** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ +const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; + +/** + * Every anchor `markdown` declares, ignoring any written inside code, which is + * an example of the syntax rather than a use of it. Takes one whole document, + * because a fenced block only reads as code when its opener and closer are + * scanned together. + */ +function anchorsIn(markdown: string): Set { + const anchors = new Set(); + for (const [, hash, attr] of stripCode(markdown).matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); + } + return anchors; +} + +/** + * The anchors the spec's own prose declares. Covers prose the rendered body + * does not hold, above all `info.description`, which the caller prepends. + * + * Quarto renames a derived anchor that collides with another derived anchor. + * It does not compare a derived anchor with a declared one: Quarto 1 warns and + * emits the duplicate, Quarto 2 emits it silently. So a section heading, whose + * anchor Quarto derives from its text, has to avoid these itself. + */ +function declaredAnchors(spec: OpenAPISpec): Set { + const anchors = new Set(); + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) walk(item); + } else if (node !== null && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + if (key === "description" && typeof value === "string") { + // One description at a time: each is its own document, and an + // unclosed fence in one must not swallow the next. + for (const anchor of anchorsIn(value)) anchors.add(anchor); + } else { + walk(value); + } + } + } + }; + walk(spec); + return anchors; +} + /** * Extract the resource name from a path. * /v1/content/{guid}/bundles -> "content" @@ -261,13 +336,40 @@ export function groupByResource(spec: OpenAPISpec): Section[] { return sections; } +/** + * The anchor to declare on a section heading, or undefined to let Quarto + * derive it. A declared anchor is only needed when the derived one is taken; + * declaring one everywhere would pin anchors that Quarto already gets right. + * + * Adds whichever anchor the heading ends up with to `options.takenIds`, so two + * sections that collide take different numbers. + */ +function claimSectionAnchor( + name: string, + options: RenderOptions, +): string | undefined { + const taken = options.takenIds; + if (!taken) return undefined; + + const derived = autoIdentifier(name); + if (!taken.has(derived)) { + taken.add(derived); + return undefined; + } + const anchor = disambiguateId(derived, taken); + taken.add(anchor); + return anchor; +} + /** * Render a section with a ## heading and ### per endpoint. */ export function renderSection(spec: OpenAPISpec, section: Section, options: RenderOptions = DEFAULT_OPTIONS): string[] { const lines: string[] = []; - lines.push(heading(2, section.name)); + lines.push( + heading(2, section.name, claimSectionAnchor(section.name, options)), + ); lines.push(""); for (const endpoint of section.endpoints) { @@ -444,7 +546,7 @@ function renderParameters( }; }); - lines.push(...gridTable(["Name", "Type", "Description"], rows)); + lines.push(...listTable(["Name", "Type", "Description"], rows)); lines.push(""); } diff --git a/example/_extensions/posit-dev/quarto-openapi/openapi-to-markdown.ts b/example/_extensions/posit-dev/quarto-openapi/openapi-to-markdown.ts index 12277d5..ca1e93d 100644 --- a/example/_extensions/posit-dev/quarto-openapi/openapi-to-markdown.ts +++ b/example/_extensions/posit-dev/quarto-openapi/openapi-to-markdown.ts @@ -20,6 +20,10 @@ import { join, dirname, extname } from "node:path"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import type { OpenAPISpec } from "./lib/types.ts"; import { groupByResource, renderApiReferenceBody } from "./lib/sections.ts"; +import { + escapePathBracesOutsideCode, + escapeSpecDescriptions, +} from "./lib/escape.ts"; type AnchorStyle = "operation-id" | "path"; @@ -102,6 +106,9 @@ async function main() { process.exit(1); } + // Make description prose safe to emit as markdown before anything reads it. + escapeSpecDescriptions(spec); + console.log(`Loaded OpenAPI ${spec.openapi} spec: ${spec.info.title}`); console.log(`Paths: ${Object.keys(spec.paths).length}`); console.log( @@ -143,7 +150,10 @@ async function main() { // Sections lines.push(...body); - const output = lines.join("\n") + "\n"; + // Escape URL path parameters last, over the whole document: they occur in + // generated text as well as in descriptions. Anchor attributes are safe from + // this — sanitizeId strips braces, so `/{word}` never appears inside one. + const output = escapePathBracesOutsideCode(lines.join("\n") + "\n"); // Write output const outputPath = join(projectDir, config.output); diff --git a/example/_extensions/posit-dev/quarto-openapi/package-lock.json b/example/_extensions/posit-dev/quarto-openapi/package-lock.json index 774cf4d..5acb7b6 100644 --- a/example/_extensions/posit-dev/quarto-openapi/package-lock.json +++ b/example/_extensions/posit-dev/quarto-openapi/package-lock.json @@ -6,9 +6,105 @@ "": { "name": "quarto-openapi-extension", "dependencies": { + "markdown-it": "^15.0.1", "yaml": "^2.9.0" } }, + "node_modules/argparse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.1.tgz", + "integrity": "sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, + "node_modules/markdown-it": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.1.tgz", + "integrity": "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/example/_extensions/posit-dev/quarto-openapi/package.json b/example/_extensions/posit-dev/quarto-openapi/package.json index 4908443..c51d335 100644 --- a/example/_extensions/posit-dev/quarto-openapi/package.json +++ b/example/_extensions/posit-dev/quarto-openapi/package.json @@ -4,6 +4,7 @@ "type": "module", "description": "Dependencies for the quarto-openapi pre-render script. node resolves bare specifiers from node_modules upward from the script's directory, so they must be installed here, not at the repo root.", "dependencies": { + "markdown-it": "^15.0.1", "yaml": "^2.9.0" } } diff --git a/tests/escape_test.ts b/tests/escape_test.ts new file mode 100644 index 0000000..f015705 --- /dev/null +++ b/tests/escape_test.ts @@ -0,0 +1,277 @@ +import { test } from "node:test"; +import { assertEquals } from "./assert.ts"; +import { + escapePathBracesOutsideCode, + escapeSpecDescriptions, + escapeUnmatchedBrackets, + fenceHtmlBlocks, + markInlineHtmlExplicit, +} from "../_extensions/quarto-openapi/lib/escape.ts"; + +test("escapeUnmatchedBrackets: escapes an unclosed bracket in prose", () => { + assertEquals( + escapeUnmatchedBrackets("Window is [start, start+interval)"), + "Window is \\[start, start+interval)", + ); +}); + +test("escapeUnmatchedBrackets: leaves link syntax alone", () => { + const text = "See [the pets endpoint](#listPets) for details."; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("escapeUnmatchedBrackets: escapes an unopened closing bracket", () => { + assertEquals(escapeUnmatchedBrackets("a] b"), "a\\] b"); +}); + +test("escapeUnmatchedBrackets: leaves brackets inside a code span alone", () => { + const text = "Pass `filter[0]=x` as the query."; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("escapeUnmatchedBrackets: leaves brackets inside a fenced block alone", () => { + const text = "Before.\n\n```json\n{\"a\": [1, 2}\n```\n\nAfter."; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("escapeUnmatchedBrackets: leaves an already-escaped bracket alone", () => { + assertEquals(escapeUnmatchedBrackets("a \\[ b"), "a \\[ b"); +}); + +test("markInlineHtmlExplicit: marks a raw HTML tag as a raw inline", () => { + assertEquals( + markInlineHtmlExplicit('Download the bundle.'), + 'Download the ``{=html}bundle``{=html}.', + ); +}); + +test("markInlineHtmlExplicit: leaves CommonMark autolinks alone", () => { + const text = "Mail or visit ."; + assertEquals(markInlineHtmlExplicit(text), text); +}); + +test("markInlineHtmlExplicit: leaves comparisons alone", () => { + const text = "Valid when a < b and x <= 5."; + assertEquals(markInlineHtmlExplicit(text), text); +}); + +test("markInlineHtmlExplicit: leaves tags inside code alone", () => { + const text = "Render `
` literally.\n\n```html\n
x
\n```"; + assertEquals(markInlineHtmlExplicit(text), text); +}); + +test("escapePathBracesOutsideCode: escapes a path parameter in prose", () => { + assertEquals( + escapePathBracesOutsideCode("Call GET /v1/users/{guid}/keys to list them."), + "Call GET /v1/users/\\{guid\\}/keys to list them.", + ); +}); + +test("escapePathBracesOutsideCode: leaves a path parameter inside code alone", () => { + const text = "Call `GET /v1/users/{guid}/keys` to list them."; + assertEquals(escapePathBracesOutsideCode(text), text); +}); + +test("escapeSpecDescriptions: reaches descriptions nested anywhere in the spec", () => { + const spec = { + info: { description: "Top [level" }, + paths: { + "/pets": { + get: { + responses: { + "200": { description: 'See x and [a, b)' }, + }, + }, + }, + }, + components: { schemas: { Pet: { properties: [{ description: "a]" }] } } }, + }; + + escapeSpecDescriptions(spec); + + assertEquals(spec.info.description, "Top \\[level"); + assertEquals( + spec.paths["/pets"].get.responses["200"].description, + 'See ``{=html}x``{=html} and \\[a, b)', + ); + assertEquals(spec.components.schemas.Pet.properties[0].description, "a\\]"); +}); + +test("escapeSpecDescriptions: leaves a bracket inside an HTML attribute alone", () => { + const spec = { info: { description: 'link' } }; + + escapeSpecDescriptions(spec); + + assertEquals( + spec.info.description, + '``{=html}link``{=html}', + ); +}); + +test("escapeUnmatchedBrackets: leaves brackets inside a tilde fence alone", () => { + const text = 'Before.\n\n~~~json\n{"a": [1, 2}\n~~~\n\nAfter.'; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("escapeUnmatchedBrackets: leaves brackets inside a long backtick fence alone", () => { + const text = 'Before.\n\n````md\n```\n[unclosed\n```\n````\n\nAfter.'; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("escapeUnmatchedBrackets: leaves brackets inside an indented fence alone", () => { + // Cell content in a list table is indented to the item's content level. + const text = " * Example:\n\n ```json\n [1, 2}\n ```\n"; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("markInlineHtmlExplicit: leaves tags inside a tilde fence alone", () => { + const text = "Render this:\n\n~~~html\n
x
\n~~~"; + assertEquals(markInlineHtmlExplicit(text), text); +}); + +test("escapePathBracesOutsideCode: escapes parameter names with digits and hyphens", () => { + assertEquals( + escapePathBracesOutsideCode("See /v1/users/{user-id}/keys/{key2}."), + "See /v1/users/\\{user-id\\}/keys/\\{key2\\}.", + ); +}); + +test("escapePathBracesOutsideCode: leaves a path parameter inside an indented fence alone", () => { + const text = " ```bash\n curl /v1/users/{user-id}\n ```\n"; + assertEquals(escapePathBracesOutsideCode(text), text); +}); + +test("escapePathBracesOutsideCode: leaves a path parameter inside a tilde fence alone", () => { + const text = "~~~bash\ncurl /v1/users/{guid}\n~~~\n"; + assertEquals(escapePathBracesOutsideCode(text), text); +}); + +test("escapePathBracesOutsideCode: an unclosed indented fence does not swallow prose", () => { + // At four spaces a lone fence is the literal content of an indented code + // block, so the prose after it still needs escaping. + assertEquals( + escapePathBracesOutsideCode(" ```\n\nThen call GET /v1/users/{id}."), + " ```\n\nThen call GET /v1/users/\\{id\\}.", + ); +}); + +test("escapeUnmatchedBrackets: an unclosed fence runs to the end of the text", () => { + const text = "Before.\n\n```json\n[1, 2}\n"; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("escapePathBracesOutsideCode: a column-zero fence does not close an indented one", () => { + // The indented line is literal content of an indented code block; the fence + // at column zero starts a block of its own. + assertEquals( + escapePathBracesOutsideCode(" ```\n\nCall /v1/users/{id}.\n```"), + " ```\n\nCall /v1/users/\\{id\\}.\n```", + ); +}); + +test("escapePathBracesOutsideCode: a tab counts as four columns of indent", () => { + assertEquals( + escapePathBracesOutsideCode("\t```\n\nCall /v1/users/{id}."), + "\t```\n\nCall /v1/users/\\{id\\}.", + ); +}); + +test("escapeUnmatchedBrackets: a closing fence may shift up to three spaces", () => { + const text = " ```json\n[1, 2}\n ```\n"; + assertEquals(escapeUnmatchedBrackets(text), text); +}); + +test("fenceHtmlBlocks: fences a block-level element as a raw HTML block", () => { + const text = "\n\n
1
"; + assertEquals( + fenceHtmlBlocks(text), + "```{=html}\n\n\n
1
\n```", + ); +}); + +test("fenceHtmlBlocks: leaves an inline tag inside a paragraph alone", () => { + const text = 'Download the bundle.'; + assertEquals(fenceHtmlBlocks(text), text); +}); + +test("fenceHtmlBlocks: leaves an autolink that opens a line alone", () => { + const text = " is the home page."; + assertEquals(fenceHtmlBlocks(text), text); +}); + +test("fenceHtmlBlocks: ends the block at a blank line", () => { + assertEquals( + fenceHtmlBlocks("
\n\nAfter the rule."), + "```{=html}\n
\n```\n\nAfter the rule.", + ); +}); + +test("fenceHtmlBlocks: outruns a backtick run inside the block", () => { + assertEquals( + fenceHtmlBlocks("

Use ```code``` here

"), + "````{=html}\n

Use ```code``` here

\n````", + ); +}); + +test("escapeSpecDescriptions: fences a block-level HTML table", () => { + const spec = { + info: { description: "Codes:\n\n\n\n
1
" }, + }; + + escapeSpecDescriptions(spec); + + assertEquals( + spec.info.description, + "Codes:\n\n```{=html}\n\n\n
1
\n```", + ); +}); + +test("fenceHtmlBlocks: fences a block that follows prose", () => { + assertEquals( + fenceHtmlBlocks("Intro.\n\n
\ncontent\n
\n\nOutro."), + "Intro.\n\n```{=html}\n
\ncontent\n
\n```\n\nOutro.", + ); +}); + +test("fenceHtmlBlocks: leaves a tag inside a fenced code block alone", () => { + const text = "```html\n\n\n
1
\n```"; + assertEquals(fenceHtmlBlocks(text), text); +}); + +test("fenceHtmlBlocks: indents the fences to match a block inside a list item", () => { + assertEquals( + fenceHtmlBlocks("- Item\n
\n content\n
"), + "- Item\n ```{=html}\n
\n content\n
\n ```", + ); +}); + +test("fenceHtmlBlocks: indents to the shallowest line of the block", () => { + assertEquals( + fenceHtmlBlocks("
\n deeper\n
"), + " ```{=html}\n
\n deeper\n
\n ```", + ); +}); + +test("fenceHtmlBlocks: keeps a blockquote marker outside the fence", () => { + assertEquals( + fenceHtmlBlocks(">
\n> content\n>
"), + "> ```{=html}\n>
\n> content\n>
\n> ```", + ); +}); + +test("fenceHtmlBlocks: keeps a blockquote marker inside a list item", () => { + assertEquals( + fenceHtmlBlocks("- Item\n\n >
\n > content\n >
"), + "- Item\n\n > ```{=html}\n >
\n > content\n >
\n > ```", + ); +}); + +test("fenceHtmlBlocks: leaves a block that opens on a list-marker line alone", () => { + const text = "-
\n content\n
"; + assertEquals(fenceHtmlBlocks(text), text); +}); + +test("fenceHtmlBlocks: leaves a block that opens on an ordered-marker line alone", () => { + const text = "1.
\n content\n
"; + assertEquals(fenceHtmlBlocks(text), text); +}); diff --git a/tests/markdown_test.ts b/tests/markdown_test.ts index 23c4cd7..304ebf7 100644 --- a/tests/markdown_test.ts +++ b/tests/markdown_test.ts @@ -4,7 +4,9 @@ import { assertStringIncludes, } from "./assert.ts"; import { - gridTable, + autoIdentifier, + disambiguateId, + listTable, heading, pathToAnchor, sanitizeId, @@ -13,37 +15,39 @@ import { type TableRow, } from "../_extensions/quarto-openapi/lib/markdown.ts"; -test("gridTable: produces valid Pandoc grid table syntax", () => { - const result = gridTable( +test("listTable: produces a Quarto list table with a header row", () => { + const result = listTable( ["Name", "Type"], [{ cells: ["id", "string"] }, { cells: ["age", "integer"] }], ); const output = result.join("\n"); - // Header separator uses = - assertStringIncludes(output, "+=="); - // Row separators use - - assertStringIncludes(output, "+--"); - // Content present - assertStringIncludes(output, "id"); - assertStringIncludes(output, "string"); - assertStringIncludes(output, "age"); - assertStringIncludes(output, "integer"); + assertStringIncludes(output, TABLE_DIV_OPEN); + assertStringIncludes(output, '::: {.list-table header-rows="1"'); + // First cell of a row opens the item, the rest are nested under it + assertStringIncludes(output, "* * Name"); + assertStringIncludes(output, " * Type"); + assertStringIncludes(output, "* * id"); + assertStringIncludes(output, " * string"); + assertStringIncludes(output, "* * age"); + assertStringIncludes(output, " * integer"); + assertEquals(result.at(-1), TABLE_DIV_CLOSE); }); -test("gridTable: multi-line cell content spans multiple rows", () => { - const result = gridTable( +test("listTable: multi-line cell content indents to the item content level", () => { + const result = listTable( ["Name", "Description"], [{ cells: ["id", "The unique\nidentifier"] }], ); const output = result.join("\n"); - assertStringIncludes(output, "The unique"); - assertStringIncludes(output, "identifier"); + assertStringIncludes(output, " * The unique"); + // Continuation lines sit under the item marker, not at column 0 + assertStringIncludes(output, "\n identifier"); }); -test("gridTable: returns empty array for no rows", () => { - const result = gridTable(["Name", "Type"], []); +test("listTable: returns empty array for no rows", () => { + const result = listTable(["Name", "Type"], []); assertEquals(result.length, 0); }); @@ -76,16 +80,48 @@ test("pathToAnchor: replaces braces with dashes", () => { ); }); -test("gridTable: every emitted line has the same length", () => { +test("listTable: a cell holding two blocks emits a loose list", () => { + const rows: TableRow[] = [ + { cells: ["`id`", "`string`", "First paragraph.\n\nSecond paragraph."] }, + ]; + + const output = listTable(["Name", "Type", "Description"], rows).join("\n"); + + // A loose row separates its items with blank lines so Pandoc reads each + // cell as block content rather than running the paragraphs together. + assertStringIncludes(output, "* * `id`\n\n * `string`\n\n * First paragraph.\n\n Second paragraph."); +}); + +test("listTable: cell text is emitted verbatim, whatever its length", () => { const rows: TableRow[] = [ { cells: ["`temp_ticket`", "`string`", "See [ref](#get-/v1/some/path) for details."] }, ]; - const lines = gridTable(["Name", "Type", "Description"], rows).filter( - (l) => l !== TABLE_DIV_OPEN && l !== TABLE_DIV_CLOSE, - ); + const output = listTable(["Name", "Type", "Description"], rows).join("\n"); - for (const line of lines) { - assertEquals(line.length, lines[0].length, `off-width line: ${JSON.stringify(line)}`); - } + assertStringIncludes(output, " * See [ref](#get-/v1/some/path) for details."); +}); + +test("autoIdentifier: lowercases and joins words with hyphens", () => { + assertEquals(autoIdentifier("OAuth Integration Templates"), "oauth-integration-templates"); +}); + +test("autoIdentifier: drops punctuation", () => { + assertEquals(autoIdentifier("Vanity URLs (beta)"), "vanity-urls-beta"); +}); + +test("autoIdentifier: drops everything before the first letter", () => { + assertEquals(autoIdentifier("3. Bundles"), "bundles"); +}); + +test("autoIdentifier: falls back to section when nothing is left", () => { + assertEquals(autoIdentifier("42 -- !"), "section"); +}); + +test("disambiguateId: takes the first free number", () => { + assertEquals(disambiguateId("api-keys", new Set(["api-keys"])), "api-keys-1"); + assertEquals( + disambiguateId("api-keys", new Set(["api-keys", "api-keys-1"])), + "api-keys-2", + ); }); diff --git a/tests/quarto_render_test.ts b/tests/quarto_render_test.ts new file mode 100644 index 0000000..3d6769e --- /dev/null +++ b/tests/quarto_render_test.ts @@ -0,0 +1,151 @@ +/** + * Integration test for list tables: renders what `listTable()` emits through + * Quarto and checks the HTML it produces. + * + * The markdown unit tests assert on generated text, which cannot tell a valid + * list table from an invalid one — Quarto reads a malformed one as a bullet + * list rather than failing. Only a real render shows whether the header row, + * the column widths, and multi-paragraph cells survive. + * + * Skipped when Quarto is missing or older than the 1.9 the extension requires. + */ + +import { test } from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { assert, assertEquals, assertStringIncludes } from "./assert.ts"; +import { listTable } from "../_extensions/quarto-openapi/lib/markdown.ts"; +import { escapePathBracesOutsideCode } from "../_extensions/quarto-openapi/lib/escape.ts"; + +const execFileAsync = promisify(execFile); + +/** Why to skip, or false when Quarto can render list tables here. */ +const skip = await (async (): Promise => { + try { + const { stdout } = await execFileAsync("quarto", ["--version"]); + const version = stdout.trim(); + const [major = 0, minor = 0] = version.split(".").map(Number); + if (major < 1 || (major === 1 && minor < 9)) { + return `quarto ${version} predates list tables`; + } + return false; + } catch { + return "quarto is not on PATH"; + } +})(); + +/** Render `markdown` as a standalone page and return the resulting HTML. */ +async function render(markdown: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "quarto-openapi-render-")); + try { + await writeFile(join(dir, "page.qmd"), markdown); + await execFileAsync("quarto", [ + "render", + "page.qmd", + "--to", + "html", + "--output", + "page.html", + ], { cwd: dir }); + return await readFile(join(dir, "page.html"), "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The first `` element in `html`, which is the one under test. */ +function tableOf(html: string): string { + const table = html.match(//)?.[0]; + assert(table, "expected the page to contain a table"); + return table; +} + +test("Quarto renders a generated list table as a table", { skip }, async () => { + const markdown = [ + "---", + "title: List table", + "---", + "", + ...listTable( + ["Name", "Type", "Description"], + [ + { + cells: [ + "`widget_id`", + "`string`", + "First paragraph.\n\nSecond paragraph.", + ], + }, + { cells: ["`limit`", "`integer`", "Page size."] }, + ], + ), + "", + ].join("\n"); + + const table = tableOf(await render(markdown)); + + // A malformed list table degrades to a bullet list instead of erroring. + assertEquals(table.includes("
    "), false, `read as a list:\n${table}`); + + // Header row. + assertStringIncludes(table, "
"); + for (const header of ["Name", "Type", "Description"]) { + assertStringIncludes(table, ``); + } + + // Body cells keep their inline markup. + assertStringIncludes(table, "widget_id"); + assertStringIncludes(table, "integer"); + assertStringIncludes(table, "Page size."); + + // A cell holding two blocks stays two paragraphs. + assertStringIncludes( + table, + "

First paragraph.

\n

Second paragraph.

", + ); + + // One column per header, and `widths` reaches the rendered proportions. + const widths = [...table.matchAll(//g)] + .map((match) => Number(match[1])); + assertEquals(widths.length, 3); + assert( + widths[2] > widths[0] && widths[2] > widths[1], + `expected the description column to be widest, got ${widths.join(",")}`, + ); +}); + +test("Quarto renders a fenced cell and escaped braces verbatim", { skip }, async () => { + // Cell content is indented to the item's content level, so a fence inside a + // cell is an indented fence. Quarto has to read it as code, and the escaped + // braces in the prose cell beside it have to reach the reader as literal + // braces rather than as backslashes or as an attribute block. + const markdown = escapePathBracesOutsideCode([ + "---", + "title: Path parameters", + "---", + "", + ...listTable( + ["Name", "Description"], + [ + { + cells: [ + "`widget-id`", + "Example:\n\n```bash\ncurl /v1/widgets/{widget-id}\n```", + ], + }, + { cells: ["`key2`", "Passed to GET /v1/widgets/{widget-id}/keys/{key2}."] }, + ], + ), + "", + ].join("\n")); + + const table = tableOf(await render(markdown)); + + assertStringIncludes(table, '
 {
+test("renderApiReferenceBody: refs are rewritten before anything is rendered", () => {
   const spec = specWithRequestBodyDescription(
     "The ID of the pet to adopt. Obtain it from the " +
       "[GET /v1/pets](#listPets) endpoint before creating an adoption.",
@@ -171,9 +171,6 @@ test("renderApiReferenceBody: rewritten link in a request body table stays well-
 
   const block = findTableBlocks(output).find((b) => b.some((l) => l.includes("pet_id")));
   assert(block, "expected to find the pet_id property table in the rendered output");
-  for (const line of block) {
-    assertEquals(line.length, block[0].length, `off-width line: ${JSON.stringify(line)}`);
-  }
   assert(
     block.some((l) => l.includes("#get-/v1/pets")),
     "expected the operationId anchor to be rewritten to its path-style form",
diff --git a/tests/schema_test.ts b/tests/schema_test.ts
index d248bfb..e43d3b3 100644
--- a/tests/schema_test.ts
+++ b/tests/schema_test.ts
@@ -22,7 +22,7 @@ function rendered(spec: OpenAPISpec, schema: Schema): string {
   return renderSchema(spec, schema).join("\n");
 }
 
-test("renderSchema: simple object produces a grid table with properties", () => {
+test("renderSchema: simple object produces a table with properties", () => {
   const spec = specWithSchemas();
   const schema: Schema = {
     type: "object",
@@ -40,8 +40,8 @@ test("renderSchema: simple object produces a grid table with properties", () =>
   assertStringIncludes(output, "`age`");
   assertStringIncludes(output, "`integer`");
   assertStringIncludes(output, "Age in years");
-  // Grid table markers
-  assertStringIncludes(output, "+===");
+  // List table markers
+  assertStringIncludes(output, '::: {.list-table header-rows="1"');
 });
 
 test("renderSchema: nested object flattens with dotted names", () => {
@@ -204,7 +204,7 @@ test("renderSchema: top-level fields have no span wrapper", () => {
   const output = rendered(spec, schema);
 
   // Top-level field should be plain backtick-quoted, not wrapped in a span
-  assertStringIncludes(output, "| `name`");
+  assertStringIncludes(output, "* * `name`");
   assert(!output.includes("{.schema-nest"), "top-level fields should not have nesting class");
 });
 
@@ -404,3 +404,28 @@ test("renderSchema: top-level any map renders 'Map of string to any'", () => {
 
   assertStringIncludes(output, "Map of string to any");
 });
+
+test("renderSchema: an empty-string default renders as a visible empty string", () => {
+  const spec = specWithSchemas();
+  const schema: Schema = {
+    type: "object",
+    properties: { prefix: { type: "string", default: "" } },
+  };
+
+  const output = rendered(spec, schema);
+
+  // An empty code span would render as nothing at all
+  assertStringIncludes(output, 'Default: `""`');
+});
+
+test("renderSchema: enum values go through the same formatter as defaults", () => {
+  const spec = specWithSchemas();
+  const schema: Schema = {
+    type: "object",
+    properties: { mode: { type: "string", enum: ["", "fast", null] } },
+  };
+
+  const output = rendered(spec, schema);
+
+  assertStringIncludes(output, 'Enum: `""`, `fast`, `null`');
+});
diff --git a/tests/sections_test.ts b/tests/sections_test.ts
index f33e4b8..193c856 100644
--- a/tests/sections_test.ts
+++ b/tests/sections_test.ts
@@ -1,6 +1,6 @@
 import { test } from "node:test";
-import { assertEquals } from "./assert.ts";
-import { groupByResource, buildOperationIdToPathMap, rewriteOperationIdRefs } from "../_extensions/quarto-openapi/lib/sections.ts";
+import { assert, assertEquals } from "./assert.ts";
+import { groupByResource, buildOperationIdToPathMap, renderApiReferenceBody, rewriteOperationIdRefs } from "../_extensions/quarto-openapi/lib/sections.ts";
 import type { OpenAPISpec } from "../_extensions/quarto-openapi/lib/types.ts";
 
 function minimalSpec(paths: OpenAPISpec["paths"]): OpenAPISpec {
@@ -343,3 +343,168 @@ test("rewriteOperationIdRefs handles multiple occurrences", () => {
 
   assertEquals(rewriteOperationIdRefs(input, idToPath), expected);
 });
+
+test("renderApiReferenceBody: disambiguates a group heading against a prose anchor", () => {
+  const spec = minimalSpec({
+    "/v1/keys": {
+      get: {
+        operationId: "listKeys",
+        summary: "List keys",
+        tags: ["API Keys"],
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+  spec.info.description = "## API Keys {#api-keys}\n\nHow to authenticate.";
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(
+    body.includes('## API Keys {id="api-keys-1"}'),
+    `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+  );
+});
+
+test("renderApiReferenceBody: leaves an uncontested group heading bare", () => {
+  const spec = minimalSpec({
+    "/v1/users": {
+      get: {
+        operationId: "listUsers",
+        summary: "List users",
+        tags: ["Users"],
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(body.includes("## Users\n"), `expected a bare heading, got:\n${body.slice(0, 200)}`);
+});
+
+test("renderApiReferenceBody: disambiguates a group heading against an endpoint anchor", () => {
+  const spec = minimalSpec({
+    "/v1/bootstrap": {
+      post: {
+        operationId: "bootstrap",
+        summary: "Create the first administrator",
+        tags: ["Bootstrap"],
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(
+    body.includes('## Bootstrap {id="bootstrap-1"}'),
+    `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+  );
+  assert(
+    body.includes('### Create the first administrator {id="bootstrap"}'),
+    "expected the endpoint to keep its anchor",
+  );
+});
+
+test("renderApiReferenceBody: disambiguates against an endpoint sub-anchor", () => {
+  const spec = minimalSpec({
+    "/v1/bootstrap": {
+      post: {
+        operationId: "bootstrap",
+        summary: "Boot",
+        tags: ["Bootstrap Responses"],
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(
+    body.includes('## Bootstrap Responses {id="bootstrap-responses-1"}'),
+    `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+  );
+});
+
+test("renderApiReferenceBody: disambiguates against a sanitized endpoint anchor", () => {
+  const spec = minimalSpec({
+    "/v1/keys": {
+      get: {
+        operationId: "api keys",
+        summary: "List keys",
+        tags: ["API Keys"],
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(
+    body.includes('## API Keys {id="api-keys-1"}'),
+    `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+  );
+});
+
+test("renderApiReferenceBody: ignores an anchor written inside a code span", () => {
+  const spec = minimalSpec({
+    "/v1/keys": {
+      get: {
+        operationId: "listKeys",
+        summary: "List keys",
+        tags: ["API Keys"],
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+  spec.info.description = "Write `{#api-keys}` as literal syntax.";
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(
+    body.includes("## API Keys\n"),
+    `expected a bare heading, got:\n${body.slice(0, 300)}`,
+  );
+});
+
+test("renderApiReferenceBody: ignores an anchor in an endpoint description code span", () => {
+  const spec = minimalSpec({
+    "/v1/keys": {
+      get: {
+        operationId: "listKeys",
+        summary: "List keys",
+        tags: ["API Keys"],
+        description: "Write `{#api-keys}` as literal syntax.",
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(
+    body.includes("## API Keys\n"),
+    `expected a bare heading, got:\n${body.slice(0, 300)}`,
+  );
+});
+
+test("renderApiReferenceBody: ignores an anchor in a fenced code block", () => {
+  const spec = minimalSpec({
+    "/v1/keys": {
+      get: {
+        operationId: "listKeys",
+        summary: "List keys",
+        tags: ["API Keys"],
+        description: "Example:\n\n```markdown\n## Heading {#api-keys}\n```",
+        responses: { "200": { description: "OK" } },
+      },
+    },
+  });
+
+  const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+  assert(
+    body.includes("## API Keys\n"),
+    `expected a bare heading, got:\n${body.slice(0, 300)}`,
+  );
+});
${header}