From f87c419b5dcd7f7159ca8c0dd918058ad2f578d5 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 24 Aug 2026 16:44:53 -0400 Subject: [PATCH 01/13] feat!: emit Quarto list tables instead of Pandoc grid tables Quarto 2 dropped Pandoc grid tables. List tables render on both, so the extension emits them unconditionally rather than carrying two table renderers; quarto-required rises from 1.6 to 1.9, where list-table support landed. Column widths are carried over rather than recomputed: each column's fraction is derived from the character width the grid emitter would have given it, so rendered column proportions are unchanged. Quarto normalizes the fractions to percentages at render time. List tables also drop a constraint grid tables imposed. Grid cells had to be in final form before layout, because changing a cell's length afterward broke the alignment Pandoc reads the table from. That is what motivated rewriting operationId refs before rendering (#25). The rewrite is still needed so every sink sees the same text, but it is no longer load-bearing for layout, and the tests no longer assert a width invariant that cannot break. --- _extensions/quarto-openapi/_extension.yml | 2 +- _extensions/quarto-openapi/lib/markdown.ts | 90 +++++++++++-------- _extensions/quarto-openapi/lib/schema.ts | 4 +- _extensions/quarto-openapi/lib/sections.ts | 8 +- .../posit-dev/quarto-openapi/_extension.yml | 2 +- .../posit-dev/quarto-openapi/lib/markdown.ts | 90 +++++++++++-------- .../posit-dev/quarto-openapi/lib/schema.ts | 4 +- .../posit-dev/quarto-openapi/lib/sections.ts | 8 +- tests/markdown_test.ts | 60 +++++++------ tests/rewrite_refs_test.ts | 5 +- tests/schema_test.ts | 8 +- 11 files changed, 162 insertions(+), 119 deletions(-) diff --git a/_extensions/quarto-openapi/_extension.yml b/_extensions/quarto-openapi/_extension.yml index b5d22dc..7ccc2c5 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" +quarto-required: ">=1.9.0" contributes: metadata: project: diff --git a/_extensions/quarto-openapi/lib/markdown.ts b/_extensions/quarto-openapi/lib/markdown.ts index 452cba0..55a2e29 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. * - * Grid tables support multi-line cells and are the most flexible - * table format in Pandoc/Quarto. + * 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 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. + * 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. + * + * 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; diff --git a/_extensions/quarto-openapi/lib/schema.ts b/_extensions/quarto-openapi/lib/schema.ts index 73c9d2b..232e011 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; } diff --git a/_extensions/quarto-openapi/lib/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index a6519c8..2bcbb59 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -20,7 +20,7 @@ import { heading, methodBadge, pathToAnchor, - gridTable, + listTable, type TableRow, } from "./markdown.ts"; @@ -102,8 +102,8 @@ export function rewriteOperationIdRefs(text: string, idToPath: Map): void { const walk = (node: unknown): void => { @@ -444,7 +444,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/_extension.yml b/example/_extensions/posit-dev/quarto-openapi/_extension.yml index b5d22dc..7ccc2c5 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" +quarto-required: ">=1.9.0" contributes: metadata: project: diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts b/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts index 452cba0..55a2e29 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. * - * Grid tables support multi-line cells and are the most flexible - * table format in Pandoc/Quarto. + * 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 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. + * 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. + * + * 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; diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts b/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts index 73c9d2b..232e011 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; } diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts index a6519c8..2bcbb59 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts @@ -20,7 +20,7 @@ import { heading, methodBadge, pathToAnchor, - gridTable, + listTable, type TableRow, } from "./markdown.ts"; @@ -102,8 +102,8 @@ export function rewriteOperationIdRefs(text: string, idToPath: Map): void { const walk = (node: unknown): void => { @@ -444,7 +444,7 @@ function renderParameters( }; }); - lines.push(...gridTable(["Name", "Type", "Description"], rows)); + lines.push(...listTable(["Name", "Type", "Description"], rows)); lines.push(""); } diff --git a/tests/markdown_test.ts b/tests/markdown_test.ts index 23c4cd7..c7c3fb3 100644 --- a/tests/markdown_test.ts +++ b/tests/markdown_test.ts @@ -4,7 +4,7 @@ import { assertStringIncludes, } from "./assert.ts"; import { - gridTable, + listTable, heading, pathToAnchor, sanitizeId, @@ -13,37 +13,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 +78,24 @@ 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."); }); diff --git a/tests/rewrite_refs_test.ts b/tests/rewrite_refs_test.ts index c3e6456..d8c1cf3 100644 --- a/tests/rewrite_refs_test.ts +++ b/tests/rewrite_refs_test.ts @@ -160,7 +160,7 @@ function findTableBlocks(text: string): string[][] { return blocks; } -test("renderApiReferenceBody: rewritten link in a request body table stays well-formed", () => { +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..47ffe0c 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"); }); From c1683e2fd41c4b945519f74172f6ab16c0bbd541 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 24 Aug 2026 16:54:58 -0400 Subject: [PATCH 02/13] feat: make description prose safe to emit as Quarto markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI descriptions are CommonMark written for arbitrary renderers, so they carry constructs Quarto reads as markup. Quarto 2 hard-errors on the first two below and warns on the third, once per element per rendered page. - an unclosed `[`, as in interval notation `[start, start+interval)`, opens a span - `{...}`, as in a URL path parameter `/v1/users/{guid}/keys`, is attribute syntax - a bare HTML tag becomes a raw inline lib/escape.ts rewrites all three. Every rewrite skips inline code spans and fenced code blocks, where braces and angle brackets already render verbatim and a backslash or backtick would become literal output instead of markup — so a path written as `GET /v1/users/{guid}/keys` keeps its braces. The HTML pass runs before the bracket pass: it wraps each tag in backticks, so the bracket pass then treats those as code spans and leaves a `[` inside an attribute (`href="/x?a[0]=1"`) alone. The tag pattern is deliberately conservative, requiring a well-formed name and angle-bracket-free attributes, which leaves `a < b` and CommonMark autolinks alone; wrapping an autolink would break the link Quarto otherwise makes of it. All three rewrites render identically to the unescaped text, so this is not conditional on the Quarto version. Also format empty-string values as `""` rather than an empty code span, which renders as nothing at all, and run enum values through the same formatter as defaults and examples so null and non-string members format consistently. --- README.md | 23 ++- _extensions/quarto-openapi/lib/escape.ts | 171 ++++++++++++++++++ _extensions/quarto-openapi/lib/schema.ts | 8 +- .../quarto-openapi/openapi-to-markdown.ts | 12 +- .../posit-dev/quarto-openapi/lib/escape.ts | 171 ++++++++++++++++++ .../posit-dev/quarto-openapi/lib/schema.ts | 8 +- .../quarto-openapi/openapi-to-markdown.ts | 12 +- tests/escape_test.ts | 108 +++++++++++ tests/schema_test.ts | 25 +++ 9 files changed, 531 insertions(+), 7 deletions(-) create mode 100644 _extensions/quarto-openapi/lib/escape.ts create mode 100644 example/_extensions/posit-dev/quarto-openapi/lib/escape.ts create mode 100644 tests/escape_test.ts 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/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts new file mode 100644 index 0000000..b7e6d6c --- /dev/null +++ b/_extensions/quarto-openapi/lib/escape.ts @@ -0,0 +1,171 @@ +/** + * 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 spans and fenced code blocks, where braces and + * angle brackets already render verbatim and a backslash or backtick would + * become literal output rather than markup. + */ + +/** + * 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 chars = text.split(""); + const opens: number[] = []; + const unmatched: number[] = []; + let inFence = false; + let atLineStart = true; + let i = 0; + while (i < chars.length) { + const c = chars[i]; + if (atLineStart && text.startsWith("```", i)) { + inFence = !inFence; + while (i < chars.length && chars[i] !== "\n") i++; + continue; + } + atLineStart = c === "\n"; + if (inFence) { + i++; + continue; + } + if (c === "`") { + // Skip an inline code span, matching the opening backtick run length. + let run = 0; + while (chars[i + run] === "`") run++; + const close = text.indexOf("`".repeat(run), i + run); + i = close === -1 ? i + run : close + run; + continue; + } + if (c === "\\") { + i += 2; + continue; + } + if (c === "[") opens.push(i); + else if (c === "]") { + if (opens.length > 0) opens.pop(); + else unmatched.push(i); + } + i++; + } + unmatched.push(...opens); + // Splice from the back so earlier positions stay valid. + unmatched.sort((a, b) => b - a); + for (const pos of unmatched) chars.splice(pos, 0, "\\"); + return chars.join(""); +} + +/** + * Apply `fn` to the parts of `text` that are ordinary markdown prose, passing + * inline code spans and fenced code blocks through untouched. + */ +function transformOutsideCode( + text: string, + fn: (prose: string) => string, +): string { + const out: string[] = []; + let segStart = 0; + let inFence = false; + let atLineStart = true; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (atLineStart && text.startsWith("```", i)) { + if (!inFence) { + out.push(fn(text.slice(segStart, i))); + segStart = i; + } + inFence = !inFence; + while (i < text.length && text[i] !== "\n") i++; + if (!inFence) { + out.push(text.slice(segStart, i)); + segStart = i; + } + continue; + } + atLineStart = c === "\n"; + if (inFence) { + i++; + continue; + } + if (c === "`") { + let run = 0; + while (text[i + run] === "`") run++; + const close = text.indexOf("`".repeat(run), i + run); + const end = close === -1 ? i + run : close + run; + out.push(fn(text.slice(segStart, i))); + out.push(text.slice(i, end)); + segStart = end; + i = end; + continue; + } + if (c === "\\") { + i += 2; + continue; + } + i++; + } + out.push(inFence ? text.slice(segStart) : fn(text.slice(segStart))); + return out.join(""); +} + +/** + * 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`. + */ +export function escapePathBracesOutsideCode(text: string): string { + return transformOutsideCode(text, (s) => + s.replace(/\/\{([A-Za-z_]+)\}/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 is marked first: it wraps each tag 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(value)); + } else { + escapeSpecDescriptions(value); + } + } + } +} diff --git a/_extensions/quarto-openapi/lib/schema.ts b/_extensions/quarto-openapi/lib/schema.ts index 232e011..68fb0a4 100644 --- a/_extensions/quarto-openapi/lib/schema.ts +++ b/_extensions/quarto-openapi/lib/schema.ts @@ -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/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/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..b7e6d6c --- /dev/null +++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts @@ -0,0 +1,171 @@ +/** + * 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 spans and fenced code blocks, where braces and + * angle brackets already render verbatim and a backslash or backtick would + * become literal output rather than markup. + */ + +/** + * 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 chars = text.split(""); + const opens: number[] = []; + const unmatched: number[] = []; + let inFence = false; + let atLineStart = true; + let i = 0; + while (i < chars.length) { + const c = chars[i]; + if (atLineStart && text.startsWith("```", i)) { + inFence = !inFence; + while (i < chars.length && chars[i] !== "\n") i++; + continue; + } + atLineStart = c === "\n"; + if (inFence) { + i++; + continue; + } + if (c === "`") { + // Skip an inline code span, matching the opening backtick run length. + let run = 0; + while (chars[i + run] === "`") run++; + const close = text.indexOf("`".repeat(run), i + run); + i = close === -1 ? i + run : close + run; + continue; + } + if (c === "\\") { + i += 2; + continue; + } + if (c === "[") opens.push(i); + else if (c === "]") { + if (opens.length > 0) opens.pop(); + else unmatched.push(i); + } + i++; + } + unmatched.push(...opens); + // Splice from the back so earlier positions stay valid. + unmatched.sort((a, b) => b - a); + for (const pos of unmatched) chars.splice(pos, 0, "\\"); + return chars.join(""); +} + +/** + * Apply `fn` to the parts of `text` that are ordinary markdown prose, passing + * inline code spans and fenced code blocks through untouched. + */ +function transformOutsideCode( + text: string, + fn: (prose: string) => string, +): string { + const out: string[] = []; + let segStart = 0; + let inFence = false; + let atLineStart = true; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (atLineStart && text.startsWith("```", i)) { + if (!inFence) { + out.push(fn(text.slice(segStart, i))); + segStart = i; + } + inFence = !inFence; + while (i < text.length && text[i] !== "\n") i++; + if (!inFence) { + out.push(text.slice(segStart, i)); + segStart = i; + } + continue; + } + atLineStart = c === "\n"; + if (inFence) { + i++; + continue; + } + if (c === "`") { + let run = 0; + while (text[i + run] === "`") run++; + const close = text.indexOf("`".repeat(run), i + run); + const end = close === -1 ? i + run : close + run; + out.push(fn(text.slice(segStart, i))); + out.push(text.slice(i, end)); + segStart = end; + i = end; + continue; + } + if (c === "\\") { + i += 2; + continue; + } + i++; + } + out.push(inFence ? text.slice(segStart) : fn(text.slice(segStart))); + return out.join(""); +} + +/** + * 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`. + */ +export function escapePathBracesOutsideCode(text: string): string { + return transformOutsideCode(text, (s) => + s.replace(/\/\{([A-Za-z_]+)\}/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 is marked first: it wraps each tag 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(value)); + } else { + escapeSpecDescriptions(value); + } + } + } +} diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts b/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts index 232e011..68fb0a4 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/schema.ts @@ -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/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/tests/escape_test.ts b/tests/escape_test.ts new file mode 100644 index 0000000..2865267 --- /dev/null +++ b/tests/escape_test.ts @@ -0,0 +1,108 @@ +import { test } from "node:test"; +import { assertEquals } from "./assert.ts"; +import { + escapePathBracesOutsideCode, + escapeSpecDescriptions, + escapeUnmatchedBrackets, + 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}', + ); +}); diff --git a/tests/schema_test.ts b/tests/schema_test.ts index 47ffe0c..e43d3b3 100644 --- a/tests/schema_test.ts +++ b/tests/schema_test.ts @@ -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`'); +}); From 7d54c74f91bc510d96ca3d72a38eff54df36a901 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 24 Aug 2026 16:54:59 -0400 Subject: [PATCH 03/13] chore: bump version to 0.5.0 --- _extensions/quarto-openapi/_extension.yml | 2 +- example/_extensions/posit-dev/quarto-openapi/_extension.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/_extensions/quarto-openapi/_extension.yml b/_extensions/quarto-openapi/_extension.yml index 7ccc2c5..9c6b3da 100644 --- a/_extensions/quarto-openapi/_extension.yml +++ b/_extensions/quarto-openapi/_extension.yml @@ -1,6 +1,6 @@ title: quarto-openapi author: Posit Software, PBC -version: 0.4.2 +version: 0.5.0 quarto-required: ">=1.9.0" contributes: metadata: diff --git a/example/_extensions/posit-dev/quarto-openapi/_extension.yml b/example/_extensions/posit-dev/quarto-openapi/_extension.yml index 7ccc2c5..9c6b3da 100644 --- a/example/_extensions/posit-dev/quarto-openapi/_extension.yml +++ b/example/_extensions/posit-dev/quarto-openapi/_extension.yml @@ -1,6 +1,6 @@ title: quarto-openapi author: Posit Software, PBC -version: 0.4.2 +version: 0.5.0 quarto-required: ">=1.9.0" contributes: metadata: From 7ee655e7442038f95d5805b09c5d1a873e80d72c Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 25 Aug 2026 08:08:14 -0400 Subject: [PATCH 04/13] fix: skip every CommonMark fence and widen path parameter names The escaping passes recognized only an unindented run of exactly three backticks as a fence, so a description with a tilde fence, a longer backtick fence, or a fence indented into a list-table cell had its brackets, HTML, and path braces rewritten inside literal code. Both passes now share one scanner that tracks the fence character and its opening length. OpenAPI puts no character restrictions on parameter names, but the path brace pass matched only letters and underscores, leaving `/{user-id}` and `/{key2}` bare for Quarto to read as an attribute block. --- _extensions/quarto-openapi/lib/escape.ts | 188 +++++++++++------- .../posit-dev/quarto-openapi/lib/escape.ts | 188 +++++++++++------- tests/escape_test.ts | 38 ++++ 3 files changed, 266 insertions(+), 148 deletions(-) diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts index b7e6d6c..b9e6e18 100644 --- a/_extensions/quarto-openapi/lib/escape.ts +++ b/_extensions/quarto-openapi/lib/escape.ts @@ -11,54 +11,132 @@ * become literal output rather than markup. */ +/** A run of text that is either markdown prose or literal code. */ +interface Segment { + code: boolean; + text: string; +} + /** - * 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. + * An opening code fence: three or more backticks or tildes at the start of a + * line. CommonMark allows up to three spaces of indentation; more indentation + * is accepted here because a fence inside a list-table cell is indented to the + * item's content level, and a fence in an indented code block is literal code + * either way. */ -export function escapeUnmatchedBrackets(text: string): string { - const chars = text.split(""); - const opens: number[] = []; - const unmatched: number[] = []; - let inFence = false; +const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})/; + +/** A fence of the same character, at least as long, and nothing else. */ +const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; + +/** The index of the newline ending the line at `from`, or the end of `text`. */ +function lineEnd(text: string, from: number): number { + const nl = text.indexOf("\n", from); + return nl === -1 ? text.length : nl; +} + +function closesFence(line: string, marker: string): boolean { + const found = line.match(FENCE_CLOSE)?.[1]; + return found !== undefined && + found[0] === marker[0] && + found.length >= marker.length; +} + +/** + * Return the end of the fenced block opened by `marker` on the line at + * `start`, including the closing fence. An unclosed block runs to the end. + */ +function fenceEnd(text: string, start: number, marker: string): number { + let i = lineEnd(text, start); + while (i < text.length) { + i++; // step over the newline ending the previous line + const end = lineEnd(text, i); + if (closesFence(text.slice(i, end), marker)) return end; + i = end; + } + return text.length; +} + +/** + * Split `text` into prose and code segments. Code segments are fenced blocks + * and inline code spans; concatenating every segment reproduces `text`. + */ +function splitCode(text: string): Segment[] { + const segments: Segment[] = []; + let start = 0; let atLineStart = true; let i = 0; - while (i < chars.length) { - const c = chars[i]; - if (atLineStart && text.startsWith("```", i)) { - inFence = !inFence; - while (i < chars.length && chars[i] !== "\n") i++; - continue; - } - atLineStart = c === "\n"; - if (inFence) { - i++; - continue; + + const emit = (end: number, code: boolean) => { + if (end > start) segments.push({ code, text: text.slice(start, end) }); + start = end; + }; + + while (i < text.length) { + if (atLineStart) { + const marker = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN)?.[1]; + if (marker !== undefined) { + emit(i, false); + i = fenceEnd(text, i, marker); + emit(i, true); + continue; + } } + const c = text[i]; if (c === "`") { - // Skip an inline code span, matching the opening backtick run length. + // An inline code span closes on a backtick run of the same length. let run = 0; - while (chars[i + run] === "`") run++; + 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); + atLineStart = false; continue; } if (c === "\\") { + // An escaped backtick cannot open a code span. i += 2; + atLineStart = false; continue; } - if (c === "[") opens.push(i); - else if (c === "]") { - if (opens.length > 0) opens.pop(); - else unmatched.push(i); - } + atLineStart = c === "\n"; i++; } + emit(text.length, false); + return segments; +} + +/** + * 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((a, b) => b - a); - for (const pos of unmatched) chars.splice(pos, 0, "\\"); - return chars.join(""); + 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(""); } /** @@ -69,50 +147,9 @@ function transformOutsideCode( text: string, fn: (prose: string) => string, ): string { - const out: string[] = []; - let segStart = 0; - let inFence = false; - let atLineStart = true; - let i = 0; - while (i < text.length) { - const c = text[i]; - if (atLineStart && text.startsWith("```", i)) { - if (!inFence) { - out.push(fn(text.slice(segStart, i))); - segStart = i; - } - inFence = !inFence; - while (i < text.length && text[i] !== "\n") i++; - if (!inFence) { - out.push(text.slice(segStart, i)); - segStart = i; - } - continue; - } - atLineStart = c === "\n"; - if (inFence) { - i++; - continue; - } - if (c === "`") { - let run = 0; - while (text[i + run] === "`") run++; - const close = text.indexOf("`".repeat(run), i + run); - const end = close === -1 ? i + run : close + run; - out.push(fn(text.slice(segStart, i))); - out.push(text.slice(i, end)); - segStart = end; - i = end; - continue; - } - if (c === "\\") { - i += 2; - continue; - } - i++; - } - out.push(inFence ? text.slice(segStart) : fn(text.slice(segStart))); - return out.join(""); + return splitCode(text) + .map((segment) => (segment.code ? segment.text : fn(segment.text))) + .join(""); } /** @@ -140,10 +177,13 @@ export function markInlineHtmlExplicit(text: string): string { * 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(/\/\{([A-Za-z_]+)\}/g, "/\\{$1\\}"), + s.replace(/\/\{([^{}\/\s]+)\}/g, "/\\{$1\\}"), ); } diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts index b7e6d6c..b9e6e18 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts @@ -11,54 +11,132 @@ * become literal output rather than markup. */ +/** A run of text that is either markdown prose or literal code. */ +interface Segment { + code: boolean; + text: string; +} + /** - * 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. + * An opening code fence: three or more backticks or tildes at the start of a + * line. CommonMark allows up to three spaces of indentation; more indentation + * is accepted here because a fence inside a list-table cell is indented to the + * item's content level, and a fence in an indented code block is literal code + * either way. */ -export function escapeUnmatchedBrackets(text: string): string { - const chars = text.split(""); - const opens: number[] = []; - const unmatched: number[] = []; - let inFence = false; +const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})/; + +/** A fence of the same character, at least as long, and nothing else. */ +const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; + +/** The index of the newline ending the line at `from`, or the end of `text`. */ +function lineEnd(text: string, from: number): number { + const nl = text.indexOf("\n", from); + return nl === -1 ? text.length : nl; +} + +function closesFence(line: string, marker: string): boolean { + const found = line.match(FENCE_CLOSE)?.[1]; + return found !== undefined && + found[0] === marker[0] && + found.length >= marker.length; +} + +/** + * Return the end of the fenced block opened by `marker` on the line at + * `start`, including the closing fence. An unclosed block runs to the end. + */ +function fenceEnd(text: string, start: number, marker: string): number { + let i = lineEnd(text, start); + while (i < text.length) { + i++; // step over the newline ending the previous line + const end = lineEnd(text, i); + if (closesFence(text.slice(i, end), marker)) return end; + i = end; + } + return text.length; +} + +/** + * Split `text` into prose and code segments. Code segments are fenced blocks + * and inline code spans; concatenating every segment reproduces `text`. + */ +function splitCode(text: string): Segment[] { + const segments: Segment[] = []; + let start = 0; let atLineStart = true; let i = 0; - while (i < chars.length) { - const c = chars[i]; - if (atLineStart && text.startsWith("```", i)) { - inFence = !inFence; - while (i < chars.length && chars[i] !== "\n") i++; - continue; - } - atLineStart = c === "\n"; - if (inFence) { - i++; - continue; + + const emit = (end: number, code: boolean) => { + if (end > start) segments.push({ code, text: text.slice(start, end) }); + start = end; + }; + + while (i < text.length) { + if (atLineStart) { + const marker = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN)?.[1]; + if (marker !== undefined) { + emit(i, false); + i = fenceEnd(text, i, marker); + emit(i, true); + continue; + } } + const c = text[i]; if (c === "`") { - // Skip an inline code span, matching the opening backtick run length. + // An inline code span closes on a backtick run of the same length. let run = 0; - while (chars[i + run] === "`") run++; + 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); + atLineStart = false; continue; } if (c === "\\") { + // An escaped backtick cannot open a code span. i += 2; + atLineStart = false; continue; } - if (c === "[") opens.push(i); - else if (c === "]") { - if (opens.length > 0) opens.pop(); - else unmatched.push(i); - } + atLineStart = c === "\n"; i++; } + emit(text.length, false); + return segments; +} + +/** + * 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((a, b) => b - a); - for (const pos of unmatched) chars.splice(pos, 0, "\\"); - return chars.join(""); + 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(""); } /** @@ -69,50 +147,9 @@ function transformOutsideCode( text: string, fn: (prose: string) => string, ): string { - const out: string[] = []; - let segStart = 0; - let inFence = false; - let atLineStart = true; - let i = 0; - while (i < text.length) { - const c = text[i]; - if (atLineStart && text.startsWith("```", i)) { - if (!inFence) { - out.push(fn(text.slice(segStart, i))); - segStart = i; - } - inFence = !inFence; - while (i < text.length && text[i] !== "\n") i++; - if (!inFence) { - out.push(text.slice(segStart, i)); - segStart = i; - } - continue; - } - atLineStart = c === "\n"; - if (inFence) { - i++; - continue; - } - if (c === "`") { - let run = 0; - while (text[i + run] === "`") run++; - const close = text.indexOf("`".repeat(run), i + run); - const end = close === -1 ? i + run : close + run; - out.push(fn(text.slice(segStart, i))); - out.push(text.slice(i, end)); - segStart = end; - i = end; - continue; - } - if (c === "\\") { - i += 2; - continue; - } - i++; - } - out.push(inFence ? text.slice(segStart) : fn(text.slice(segStart))); - return out.join(""); + return splitCode(text) + .map((segment) => (segment.code ? segment.text : fn(segment.text))) + .join(""); } /** @@ -140,10 +177,13 @@ export function markInlineHtmlExplicit(text: string): string { * 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(/\/\{([A-Za-z_]+)\}/g, "/\\{$1\\}"), + s.replace(/\/\{([^{}\/\s]+)\}/g, "/\\{$1\\}"), ); } diff --git a/tests/escape_test.ts b/tests/escape_test.ts index 2865267..01d50ca 100644 --- a/tests/escape_test.ts +++ b/tests/escape_test.ts @@ -106,3 +106,41 @@ test("escapeSpecDescriptions: leaves a bracket inside an HTML attribute alone", '``{=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); +}); From aa58e5b16edbe9fe0b423c64807317ea6038843e Mon Sep 17 00:00:00 2001 From: edavidaja Date: Tue, 25 Aug 2026 08:08:22 -0400 Subject: [PATCH 05/13] test: render generated list tables through Quarto The markdown 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. These tests render what listTable() emits and check the header row, the column widths, multi-paragraph cells, and a fenced code block in a cell. The tests skip when Quarto is missing or older than 1.9, so CI installs it. --- .github/workflows/test.yml | 5 ++ tests/quarto_render_test.ts | 151 ++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 tests/quarto_render_test.ts 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/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, '
Date: Tue, 25 Aug 2026 08:15:51 -0400
Subject: [PATCH 06/13] fix: only read a deeply indented fence as a fence when
 it closes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Accepting any indentation let an unclosed `` ``` `` inside an indented code
block open a fence that ran to the end of the text, skipping every rewrite
in the prose after it. Past three spaces — CommonMark's limit, and less
than the indentation of a list-table cell — a fence now has to close to
count.
---
 _extensions/quarto-openapi/lib/escape.ts      | 34 ++++++++++++-------
 .../posit-dev/quarto-openapi/lib/escape.ts    | 34 ++++++++++++-------
 tests/escape_test.ts                          | 14 ++++++++
 3 files changed, 56 insertions(+), 26 deletions(-)

diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts
index b9e6e18..7a1e3c3 100644
--- a/_extensions/quarto-openapi/lib/escape.ts
+++ b/_extensions/quarto-openapi/lib/escape.ts
@@ -19,12 +19,12 @@ interface Segment {
 
 /**
  * An opening code fence: three or more backticks or tildes at the start of a
- * line. CommonMark allows up to three spaces of indentation; more indentation
- * is accepted here because a fence inside a list-table cell is indented to the
- * item's content level, and a fence in an indented code block is literal code
- * either way.
+ * line, with any indentation. CommonMark allows up to three spaces, but a fence
+ * inside a list-table cell is indented to the item's content level, so a
+ * further-indented fence has to count too — see `fenceEnd` for the condition
+ * that keeps that from misreading an indented code block.
  */
-const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})/;
+const FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})/;
 
 /** A fence of the same character, at least as long, and nothing else. */
 const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
@@ -44,7 +44,7 @@ function closesFence(line: string, marker: string): boolean {
 
 /**
  * Return the end of the fenced block opened by `marker` on the line at
- * `start`, including the closing fence. An unclosed block runs to the end.
+ * `start`, including the closing fence, or -1 when nothing closes it.
  */
 function fenceEnd(text: string, start: number, marker: string): number {
   let i = lineEnd(text, start);
@@ -54,7 +54,7 @@ function fenceEnd(text: string, start: number, marker: string): number {
     if (closesFence(text.slice(i, end), marker)) return end;
     i = end;
   }
-  return text.length;
+  return -1;
 }
 
 /**
@@ -74,12 +74,20 @@ function splitCode(text: string): Segment[] {
 
   while (i < text.length) {
     if (atLineStart) {
-      const marker = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN)?.[1];
-      if (marker !== undefined) {
-        emit(i, false);
-        i = fenceEnd(text, i, marker);
-        emit(i, true);
-        continue;
+      const open = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN);
+      if (open) {
+        const [, indent, marker] = open;
+        const end = fenceEnd(text, i, marker);
+        // An unclosed fence runs to the end of the text, except past three
+        // spaces of indentation: there a lone fence is the literal content of
+        // an indented code block, and reading it as an opener would swallow
+        // every rewrite in the prose that follows.
+        if (end !== -1 || indent.length <= 3) {
+          emit(i, false);
+          i = end === -1 ? text.length : end;
+          emit(i, true);
+          continue;
+        }
       }
     }
     const c = text[i];
diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
index b9e6e18..7a1e3c3 100644
--- a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
+++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
@@ -19,12 +19,12 @@ interface Segment {
 
 /**
  * An opening code fence: three or more backticks or tildes at the start of a
- * line. CommonMark allows up to three spaces of indentation; more indentation
- * is accepted here because a fence inside a list-table cell is indented to the
- * item's content level, and a fence in an indented code block is literal code
- * either way.
+ * line, with any indentation. CommonMark allows up to three spaces, but a fence
+ * inside a list-table cell is indented to the item's content level, so a
+ * further-indented fence has to count too — see `fenceEnd` for the condition
+ * that keeps that from misreading an indented code block.
  */
-const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})/;
+const FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})/;
 
 /** A fence of the same character, at least as long, and nothing else. */
 const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
@@ -44,7 +44,7 @@ function closesFence(line: string, marker: string): boolean {
 
 /**
  * Return the end of the fenced block opened by `marker` on the line at
- * `start`, including the closing fence. An unclosed block runs to the end.
+ * `start`, including the closing fence, or -1 when nothing closes it.
  */
 function fenceEnd(text: string, start: number, marker: string): number {
   let i = lineEnd(text, start);
@@ -54,7 +54,7 @@ function fenceEnd(text: string, start: number, marker: string): number {
     if (closesFence(text.slice(i, end), marker)) return end;
     i = end;
   }
-  return text.length;
+  return -1;
 }
 
 /**
@@ -74,12 +74,20 @@ function splitCode(text: string): Segment[] {
 
   while (i < text.length) {
     if (atLineStart) {
-      const marker = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN)?.[1];
-      if (marker !== undefined) {
-        emit(i, false);
-        i = fenceEnd(text, i, marker);
-        emit(i, true);
-        continue;
+      const open = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN);
+      if (open) {
+        const [, indent, marker] = open;
+        const end = fenceEnd(text, i, marker);
+        // An unclosed fence runs to the end of the text, except past three
+        // spaces of indentation: there a lone fence is the literal content of
+        // an indented code block, and reading it as an opener would swallow
+        // every rewrite in the prose that follows.
+        if (end !== -1 || indent.length <= 3) {
+          emit(i, false);
+          i = end === -1 ? text.length : end;
+          emit(i, true);
+          continue;
+        }
       }
     }
     const c = text[i];
diff --git a/tests/escape_test.ts b/tests/escape_test.ts
index 01d50ca..a4bbfb6 100644
--- a/tests/escape_test.ts
+++ b/tests/escape_test.ts
@@ -144,3 +144,17 @@ test("escapePathBracesOutsideCode: leaves a path parameter inside a tilde fence
   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);
+});

From adac605cef9366fdda1df5a62aa5a7ebf16ae83c Mon Sep 17 00:00:00 2001
From: edavidaja 
Date: Tue, 25 Aug 2026 08:18:01 -0400
Subject: [PATCH 07/13] fix: match a closing fence against the opener's
 indentation

Any later fence of the same character closed an indented one, so a literal
fence in an indented code block paired with an unrelated fence at column
zero and swallowed the prose between them. A closer now has to sit within
the three columns CommonMark allows either fence to shift by, and
indentation is measured in columns so a leading tab counts as four.
---
 _extensions/quarto-openapi/lib/escape.ts      | 58 ++++++++++++++-----
 .../posit-dev/quarto-openapi/lib/escape.ts    | 58 ++++++++++++++-----
 tests/escape_test.ts                          | 21 +++++++
 3 files changed, 111 insertions(+), 26 deletions(-)

diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts
index 7a1e3c3..72f844d 100644
--- a/_extensions/quarto-openapi/lib/escape.ts
+++ b/_extensions/quarto-openapi/lib/escape.ts
@@ -21,13 +21,22 @@ interface Segment {
  * An opening code fence: three or more backticks or tildes at the start of a
  * line, with any indentation. CommonMark allows up to three spaces, but a fence
  * inside a list-table cell is indented to the item's content level, so a
- * further-indented fence has to count too — see `fenceEnd` for the condition
+ * further-indented fence has to count too — see `splitCode` for the condition
  * that keeps that from misreading an indented code block.
  */
 const FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})/;
 
-/** A fence of the same character, at least as long, and nothing else. */
-const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
+/** A line holding nothing but a fence. */
+const FENCE_CLOSE = /^([ \t]*)(`{3,}|~{3,})[ \t]*$/;
+
+/** Indentation in columns, expanding tabs to four-column stops as CommonMark does. */
+function indentWidth(indent: string): number {
+  let width = 0;
+  for (const c of indent) {
+    width = c === "\t" ? width + 4 - (width % 4) : width + 1;
+  }
+  return width;
+}
 
 /** The index of the newline ending the line at `from`, or the end of `text`. */
 function lineEnd(text: string, from: number): number {
@@ -35,23 +44,40 @@ function lineEnd(text: string, from: number): number {
   return nl === -1 ? text.length : nl;
 }
 
-function closesFence(line: string, marker: string): boolean {
-  const found = line.match(FENCE_CLOSE)?.[1];
-  return found !== undefined &&
-    found[0] === marker[0] &&
-    found.length >= marker.length;
+/**
+ * Whether `line` closes a fence opened with `marker` at `openIndent` columns.
+ * The fence must use the same character and be at least as long, and it may
+ * shift by up to the three columns CommonMark allows either fence to be
+ * indented by within its containing block.
+ */
+function closesFence(
+  line: string,
+  marker: string,
+  openIndent: number,
+): boolean {
+  const close = line.match(FENCE_CLOSE);
+  if (!close) return false;
+  const [, indent, found] = close;
+  return found[0] === marker[0] &&
+    found.length >= marker.length &&
+    Math.abs(indentWidth(indent) - openIndent) <= 3;
 }
 
 /**
  * Return the end of the fenced block opened by `marker` on the line at
  * `start`, including the closing fence, or -1 when nothing closes it.
  */
-function fenceEnd(text: string, start: number, marker: string): number {
+function fenceEnd(
+  text: string,
+  start: number,
+  marker: string,
+  openIndent: number,
+): number {
   let i = lineEnd(text, start);
   while (i < text.length) {
     i++; // step over the newline ending the previous line
     const end = lineEnd(text, i);
-    if (closesFence(text.slice(i, end), marker)) return end;
+    if (closesFence(text.slice(i, end), marker, openIndent)) return end;
     i = end;
   }
   return -1;
@@ -77,17 +103,23 @@ function splitCode(text: string): Segment[] {
       const open = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN);
       if (open) {
         const [, indent, marker] = open;
-        const end = fenceEnd(text, i, marker);
+        const openIndent = indentWidth(indent);
+        const end = fenceEnd(text, i, marker, openIndent);
         // An unclosed fence runs to the end of the text, except past three
-        // spaces of indentation: there a lone fence is the literal content of
+        // columns of indentation: there a lone fence is the literal content of
         // an indented code block, and reading it as an opener would swallow
         // every rewrite in the prose that follows.
-        if (end !== -1 || indent.length <= 3) {
+        if (end !== -1 || openIndent <= 3) {
           emit(i, false);
           i = end === -1 ? text.length : end;
           emit(i, true);
           continue;
         }
+        // Literal content, not a fence: keep it in the prose segment and scan
+        // on, so the run cannot pair with a later one as an inline code span.
+        i += indent.length + marker.length;
+        atLineStart = false;
+        continue;
       }
     }
     const c = text[i];
diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
index 7a1e3c3..72f844d 100644
--- a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
+++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
@@ -21,13 +21,22 @@ interface Segment {
  * An opening code fence: three or more backticks or tildes at the start of a
  * line, with any indentation. CommonMark allows up to three spaces, but a fence
  * inside a list-table cell is indented to the item's content level, so a
- * further-indented fence has to count too — see `fenceEnd` for the condition
+ * further-indented fence has to count too — see `splitCode` for the condition
  * that keeps that from misreading an indented code block.
  */
 const FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})/;
 
-/** A fence of the same character, at least as long, and nothing else. */
-const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
+/** A line holding nothing but a fence. */
+const FENCE_CLOSE = /^([ \t]*)(`{3,}|~{3,})[ \t]*$/;
+
+/** Indentation in columns, expanding tabs to four-column stops as CommonMark does. */
+function indentWidth(indent: string): number {
+  let width = 0;
+  for (const c of indent) {
+    width = c === "\t" ? width + 4 - (width % 4) : width + 1;
+  }
+  return width;
+}
 
 /** The index of the newline ending the line at `from`, or the end of `text`. */
 function lineEnd(text: string, from: number): number {
@@ -35,23 +44,40 @@ function lineEnd(text: string, from: number): number {
   return nl === -1 ? text.length : nl;
 }
 
-function closesFence(line: string, marker: string): boolean {
-  const found = line.match(FENCE_CLOSE)?.[1];
-  return found !== undefined &&
-    found[0] === marker[0] &&
-    found.length >= marker.length;
+/**
+ * Whether `line` closes a fence opened with `marker` at `openIndent` columns.
+ * The fence must use the same character and be at least as long, and it may
+ * shift by up to the three columns CommonMark allows either fence to be
+ * indented by within its containing block.
+ */
+function closesFence(
+  line: string,
+  marker: string,
+  openIndent: number,
+): boolean {
+  const close = line.match(FENCE_CLOSE);
+  if (!close) return false;
+  const [, indent, found] = close;
+  return found[0] === marker[0] &&
+    found.length >= marker.length &&
+    Math.abs(indentWidth(indent) - openIndent) <= 3;
 }
 
 /**
  * Return the end of the fenced block opened by `marker` on the line at
  * `start`, including the closing fence, or -1 when nothing closes it.
  */
-function fenceEnd(text: string, start: number, marker: string): number {
+function fenceEnd(
+  text: string,
+  start: number,
+  marker: string,
+  openIndent: number,
+): number {
   let i = lineEnd(text, start);
   while (i < text.length) {
     i++; // step over the newline ending the previous line
     const end = lineEnd(text, i);
-    if (closesFence(text.slice(i, end), marker)) return end;
+    if (closesFence(text.slice(i, end), marker, openIndent)) return end;
     i = end;
   }
   return -1;
@@ -77,17 +103,23 @@ function splitCode(text: string): Segment[] {
       const open = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN);
       if (open) {
         const [, indent, marker] = open;
-        const end = fenceEnd(text, i, marker);
+        const openIndent = indentWidth(indent);
+        const end = fenceEnd(text, i, marker, openIndent);
         // An unclosed fence runs to the end of the text, except past three
-        // spaces of indentation: there a lone fence is the literal content of
+        // columns of indentation: there a lone fence is the literal content of
         // an indented code block, and reading it as an opener would swallow
         // every rewrite in the prose that follows.
-        if (end !== -1 || indent.length <= 3) {
+        if (end !== -1 || openIndent <= 3) {
           emit(i, false);
           i = end === -1 ? text.length : end;
           emit(i, true);
           continue;
         }
+        // Literal content, not a fence: keep it in the prose segment and scan
+        // on, so the run cannot pair with a later one as an inline code span.
+        i += indent.length + marker.length;
+        atLineStart = false;
+        continue;
       }
     }
     const c = text[i];
diff --git a/tests/escape_test.ts b/tests/escape_test.ts
index a4bbfb6..d61f16f 100644
--- a/tests/escape_test.ts
+++ b/tests/escape_test.ts
@@ -158,3 +158,24 @@ 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);
+});

From 4780827529f704b154064ee6ab1c1c8c2bd79e35 Mon Sep 17 00:00:00 2001
From: edavidaja 
Date: Thu, 10 Sep 2026 12:32:37 -0400
Subject: [PATCH 08/13] fix: emit an HTML block as a raw block, not a run of
 raw inlines

Marking every tag of an HTML block as a raw inline made the block into
paragraph content. Quarto then wrapped it in a paragraph the HTML parser
had to close early, leaving an empty paragraph on either side, and the
search index ran neighboring cells together: "occurred.500Internal
Server Error", so "Bad Request" and "HTTP status" stopped matching.

Fence the block as ```{=html} instead, which keeps it a block.

Deciding where a block starts has to agree with CommonMark, so
markdown-it now supplies the line ranges of every fence, indented code
block and HTML block. That replaces the hand-rolled fence scanner, which
this branch has already corrected three times for indentation and
unclosed fences. Inline code spans are still paired by hand: markdown-it
carries no source offsets on inline tokens, and pairing backtick runs is
all CommonMark asks for once block structure is settled.

One difference to accept. Quarto reads the text inside a bare HTML block
as markdown, so an apostrophe there turns curly, and inside a fence it
stays straight. No explicit form reproduces the implicit one: the
{=html} block that Quarto 2's own warning recommends is verbatim. On the
Connect spec this is 44 characters in one table.
---
 _extensions/quarto-openapi/lib/escape.ts      | 238 ++++++++++--------
 _extensions/quarto-openapi/package-lock.json  |  96 +++++++
 _extensions/quarto-openapi/package.json       |   1 +
 .../posit-dev/quarto-openapi/lib/escape.ts    | 238 ++++++++++--------
 .../quarto-openapi/package-lock.json          |  96 +++++++
 .../posit-dev/quarto-openapi/package.json     |   1 +
 tests/escape_test.ts                          |  58 +++++
 7 files changed, 516 insertions(+), 212 deletions(-)

diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts
index 72f844d..4c3f9fd 100644
--- a/_extensions/quarto-openapi/lib/escape.ts
+++ b/_extensions/quarto-openapi/lib/escape.ts
@@ -6,11 +6,19 @@
  * 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 spans and fenced code blocks, where braces and
- * angle brackets already render verbatim and a backslash or backtick would
- * become literal output rather than markup.
+ * 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;
@@ -18,79 +26,75 @@ interface Segment {
 }
 
 /**
- * An opening code fence: three or more backticks or tildes at the start of a
- * line, with any indentation. CommonMark allows up to three spaces, but a fence
- * inside a list-table cell is indented to the item's content level, so a
- * further-indented fence has to count too — see `splitCode` for the condition
- * that keeps that from misreading an indented code block.
+ * 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 FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})/;
-
-/** A line holding nothing but a fence. */
-const FENCE_CLOSE = /^([ \t]*)(`{3,}|~{3,})[ \t]*$/;
-
-/** Indentation in columns, expanding tabs to four-column stops as CommonMark does. */
-function indentWidth(indent: string): number {
-  let width = 0;
-  for (const c of indent) {
-    width = c === "\t" ? width + 4 - (width % 4) : width + 1;
-  }
-  return width;
-}
-
-/** The index of the newline ending the line at `from`, or the end of `text`. */
-function lineEnd(text: string, from: number): number {
-  const nl = text.indexOf("\n", from);
-  return nl === -1 ? text.length : nl;
-}
+const LITERAL_BLOCKS = new Set(["fence", "code_block", "html_block"]);
 
 /**
- * Whether `line` closes a fence opened with `marker` at `openIndent` columns.
- * The fence must use the same character and be at least as long, and it may
- * shift by up to the three columns CommonMark allows either fence to be
- * indented by within its containing block.
+ * Line ranges, as `[start, end)` line indices, that the parser reads as
+ * literal blocks.
  */
-function closesFence(
-  line: string,
-  marker: string,
-  openIndent: number,
-): boolean {
-  const close = line.match(FENCE_CLOSE);
-  if (!close) return false;
-  const [, indent, found] = close;
-  return found[0] === marker[0] &&
-    found.length >= marker.length &&
-    Math.abs(indentWidth(indent) - openIndent) <= 3;
+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;
 }
 
-/**
- * Return the end of the fenced block opened by `marker` on the line at
- * `start`, including the closing fence, or -1 when nothing closes it.
- */
-function fenceEnd(
-  text: string,
-  start: number,
-  marker: string,
-  openIndent: number,
-): number {
-  let i = lineEnd(text, start);
-  while (i < text.length) {
-    i++; // step over the newline ending the previous line
-    const end = lineEnd(text, i);
-    if (closesFence(text.slice(i, end), marker, openIndent)) return end;
-    i = end;
+/** 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 -1;
+  return offsets;
 }
 
 /**
- * Split `text` into prose and code segments. Code segments are fenced blocks
- * and inline code spans; concatenating every segment reproduces `text`.
+ * 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 atLineStart = true;
   let i = 0;
 
   const emit = (end: number, code: boolean) => {
@@ -99,49 +103,22 @@ function splitCode(text: string): Segment[] {
   };
 
   while (i < text.length) {
-    if (atLineStart) {
-      const open = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN);
-      if (open) {
-        const [, indent, marker] = open;
-        const openIndent = indentWidth(indent);
-        const end = fenceEnd(text, i, marker, openIndent);
-        // An unclosed fence runs to the end of the text, except past three
-        // columns of indentation: there a lone fence is the literal content of
-        // an indented code block, and reading it as an opener would swallow
-        // every rewrite in the prose that follows.
-        if (end !== -1 || openIndent <= 3) {
-          emit(i, false);
-          i = end === -1 ? text.length : end;
-          emit(i, true);
-          continue;
-        }
-        // Literal content, not a fence: keep it in the prose segment and scan
-        // on, so the run cannot pair with a later one as an inline code span.
-        i += indent.length + marker.length;
-        atLineStart = false;
-        continue;
-      }
-    }
-    const c = text[i];
-    if (c === "`") {
-      // An inline 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);
-      atLineStart = false;
-      continue;
-    }
-    if (c === "\\") {
+    if (text[i] === "\\") {
       // An escaped backtick cannot open a code span.
       i += 2;
-      atLineStart = false;
       continue;
     }
-    atLineStart = c === "\n";
-    i++;
+    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;
@@ -181,7 +158,7 @@ export function escapeUnmatchedBrackets(text: string): string {
 
 /**
  * Apply `fn` to the parts of `text` that are ordinary markdown prose, passing
- * inline code spans and fenced code blocks through untouched.
+ * code through untouched.
  */
 function transformOutsideCode(
   text: string,
@@ -192,6 +169,52 @@ function transformOutsideCode(
     .join("");
 }
 
+/**
+ * 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);
+    // 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(`${fence}{=html}`, ...block, 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
@@ -231,9 +254,10 @@ export function escapePathBracesOutsideCode(text: string): string {
  * Apply the prose-safety rewrites to every `description` field in the spec, in
  * place, so all sinks — prose, tables, tabsets — emit safe text.
  *
- * HTML is marked first: it wraps each tag 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.
+ * 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)) {
@@ -242,7 +266,9 @@ export function escapeSpecDescriptions(node: unknown): void {
     const obj = node as Record;
     for (const [key, value] of Object.entries(obj)) {
       if (key === "description" && typeof value === "string") {
-        obj[key] = escapeUnmatchedBrackets(markInlineHtmlExplicit(value));
+        obj[key] = escapeUnmatchedBrackets(
+          markInlineHtmlExplicit(fenceHtmlBlocks(value)),
+        );
       } else {
         escapeSpecDescriptions(value);
       }
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/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
index 72f844d..4c3f9fd 100644
--- a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
+++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts
@@ -6,11 +6,19 @@
  * 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 spans and fenced code blocks, where braces and
- * angle brackets already render verbatim and a backslash or backtick would
- * become literal output rather than markup.
+ * 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;
@@ -18,79 +26,75 @@ interface Segment {
 }
 
 /**
- * An opening code fence: three or more backticks or tildes at the start of a
- * line, with any indentation. CommonMark allows up to three spaces, but a fence
- * inside a list-table cell is indented to the item's content level, so a
- * further-indented fence has to count too — see `splitCode` for the condition
- * that keeps that from misreading an indented code block.
+ * 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 FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})/;
-
-/** A line holding nothing but a fence. */
-const FENCE_CLOSE = /^([ \t]*)(`{3,}|~{3,})[ \t]*$/;
-
-/** Indentation in columns, expanding tabs to four-column stops as CommonMark does. */
-function indentWidth(indent: string): number {
-  let width = 0;
-  for (const c of indent) {
-    width = c === "\t" ? width + 4 - (width % 4) : width + 1;
-  }
-  return width;
-}
-
-/** The index of the newline ending the line at `from`, or the end of `text`. */
-function lineEnd(text: string, from: number): number {
-  const nl = text.indexOf("\n", from);
-  return nl === -1 ? text.length : nl;
-}
+const LITERAL_BLOCKS = new Set(["fence", "code_block", "html_block"]);
 
 /**
- * Whether `line` closes a fence opened with `marker` at `openIndent` columns.
- * The fence must use the same character and be at least as long, and it may
- * shift by up to the three columns CommonMark allows either fence to be
- * indented by within its containing block.
+ * Line ranges, as `[start, end)` line indices, that the parser reads as
+ * literal blocks.
  */
-function closesFence(
-  line: string,
-  marker: string,
-  openIndent: number,
-): boolean {
-  const close = line.match(FENCE_CLOSE);
-  if (!close) return false;
-  const [, indent, found] = close;
-  return found[0] === marker[0] &&
-    found.length >= marker.length &&
-    Math.abs(indentWidth(indent) - openIndent) <= 3;
+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;
 }
 
-/**
- * Return the end of the fenced block opened by `marker` on the line at
- * `start`, including the closing fence, or -1 when nothing closes it.
- */
-function fenceEnd(
-  text: string,
-  start: number,
-  marker: string,
-  openIndent: number,
-): number {
-  let i = lineEnd(text, start);
-  while (i < text.length) {
-    i++; // step over the newline ending the previous line
-    const end = lineEnd(text, i);
-    if (closesFence(text.slice(i, end), marker, openIndent)) return end;
-    i = end;
+/** 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 -1;
+  return offsets;
 }
 
 /**
- * Split `text` into prose and code segments. Code segments are fenced blocks
- * and inline code spans; concatenating every segment reproduces `text`.
+ * 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 atLineStart = true;
   let i = 0;
 
   const emit = (end: number, code: boolean) => {
@@ -99,49 +103,22 @@ function splitCode(text: string): Segment[] {
   };
 
   while (i < text.length) {
-    if (atLineStart) {
-      const open = text.slice(i, lineEnd(text, i)).match(FENCE_OPEN);
-      if (open) {
-        const [, indent, marker] = open;
-        const openIndent = indentWidth(indent);
-        const end = fenceEnd(text, i, marker, openIndent);
-        // An unclosed fence runs to the end of the text, except past three
-        // columns of indentation: there a lone fence is the literal content of
-        // an indented code block, and reading it as an opener would swallow
-        // every rewrite in the prose that follows.
-        if (end !== -1 || openIndent <= 3) {
-          emit(i, false);
-          i = end === -1 ? text.length : end;
-          emit(i, true);
-          continue;
-        }
-        // Literal content, not a fence: keep it in the prose segment and scan
-        // on, so the run cannot pair with a later one as an inline code span.
-        i += indent.length + marker.length;
-        atLineStart = false;
-        continue;
-      }
-    }
-    const c = text[i];
-    if (c === "`") {
-      // An inline 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);
-      atLineStart = false;
-      continue;
-    }
-    if (c === "\\") {
+    if (text[i] === "\\") {
       // An escaped backtick cannot open a code span.
       i += 2;
-      atLineStart = false;
       continue;
     }
-    atLineStart = c === "\n";
-    i++;
+    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;
@@ -181,7 +158,7 @@ export function escapeUnmatchedBrackets(text: string): string {
 
 /**
  * Apply `fn` to the parts of `text` that are ordinary markdown prose, passing
- * inline code spans and fenced code blocks through untouched.
+ * code through untouched.
  */
 function transformOutsideCode(
   text: string,
@@ -192,6 +169,52 @@ function transformOutsideCode(
     .join("");
 }
 
+/**
+ * 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);
+    // 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(`${fence}{=html}`, ...block, 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
@@ -231,9 +254,10 @@ export function escapePathBracesOutsideCode(text: string): string {
  * Apply the prose-safety rewrites to every `description` field in the spec, in
  * place, so all sinks — prose, tables, tabsets — emit safe text.
  *
- * HTML is marked first: it wraps each tag 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.
+ * 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)) {
@@ -242,7 +266,9 @@ export function escapeSpecDescriptions(node: unknown): void {
     const obj = node as Record;
     for (const [key, value] of Object.entries(obj)) {
       if (key === "description" && typeof value === "string") {
-        obj[key] = escapeUnmatchedBrackets(markInlineHtmlExplicit(value));
+        obj[key] = escapeUnmatchedBrackets(
+          markInlineHtmlExplicit(fenceHtmlBlocks(value)),
+        );
       } else {
         escapeSpecDescriptions(value);
       }
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
index d61f16f..1862e5b 100644
--- a/tests/escape_test.ts
+++ b/tests/escape_test.ts
@@ -4,6 +4,7 @@ import {
   escapePathBracesOutsideCode,
   escapeSpecDescriptions,
   escapeUnmatchedBrackets,
+  fenceHtmlBlocks,
   markInlineHtmlExplicit,
 } from "../_extensions/quarto-openapi/lib/escape.ts";
 
@@ -179,3 +180,60 @@ 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 = "
${header}
\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); +}); From 9565ea2200c1cd09d8afa533381bb29afb1a5efa Mon Sep 17 00:00:00 2001 From: edavidaja Date: Thu, 10 Sep 2026 12:32:50 -0400 Subject: [PATCH 09/13] fix: number a section anchor when the page already declares it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quarto derives a section heading's anchor from its text, and renames a derived anchor that collides with another derived one. It does not compare a derived anchor with a declared one. Quarto 1 warns and emits the duplicate; Quarto 2 emits it silently. Two collisions in the Connect spec: - info.description writes "#### API Keys {#api-keys}", so the API Keys resource section takes the same anchor. Its sidebar link then lands on the authentication prose, and #api-keys-1, which Quarto 1 produced and which is a published URL, resolves nowhere. - under the default operation-id anchor style, the Bootstrap section collides with the "bootstrap" operationId. Collect the anchors the page declares outright — the ones spec prose writes, and the one this generator gives each endpoint — and declare a numbered anchor on a section heading whose derived anchor is among them. Sections that do not collide stay bare, so Quarto keeps deriving the anchors it already gets right. On the Connect spec this reproduces all 607 section anchors of the published page, in order, with no duplicates, under both Quarto versions. --- _extensions/quarto-openapi/lib/markdown.ts | 28 ++++++ _extensions/quarto-openapi/lib/sections.ts | 97 ++++++++++++++++++- .../posit-dev/quarto-openapi/lib/markdown.ts | 28 ++++++ .../posit-dev/quarto-openapi/lib/sections.ts | 97 ++++++++++++++++++- tests/markdown_test.ts | 26 +++++ tests/sections_test.ts | 66 ++++++++++++- 6 files changed, 330 insertions(+), 12 deletions(-) diff --git a/_extensions/quarto-openapi/lib/markdown.ts b/_extensions/quarto-openapi/lib/markdown.ts index 55a2e29..4c1bdbc 100644 --- a/_extensions/quarto-openapi/lib/markdown.ts +++ b/_extensions/quarto-openapi/lib/markdown.ts @@ -133,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/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index 2bcbb59..74a76dc 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -17,6 +17,8 @@ import { HTTP_METHODS, isReference } from "./types.ts"; import { resolve } from "./refs.ts"; import { renderSchema } from "./schema.ts"; import { + autoIdentifier, + disambiguateId, heading, methodBadge, pathToAnchor, @@ -39,6 +41,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" }; @@ -135,13 +142,68 @@ export function renderApiReferenceBody( if (anchorStyle === "path") { rewriteSpecRefs(spec, buildOperationIdToPathMap(spec)); } + const takenIds = declaredAnchors(spec, anchorStyle); const lines: string[] = []; for (const section of groupByResource(spec)) { - lines.push(...renderSection(spec, section, { anchorStyle })); + lines.push(...renderSection(spec, section, { anchorStyle, takenIds })); } return lines; } +/** The anchor an endpoint heading declares. */ +function endpointAnchor( + endpoint: Endpoint, + anchorStyle: RenderOptions["anchorStyle"], +): string { + const { method, path, operation } = endpoint; + return anchorStyle === "path" + ? pathToAnchor(method, path) + : operation.operationId || pathToAnchor(method, path); +} + +/** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ +const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; + +/** + * Every anchor the page declares outright: the ones the spec's prose writes, + * and the ones this generator gives each endpoint. + * + * 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 this set itself. + */ +function declaredAnchors( + spec: OpenAPISpec, + anchorStyle: RenderOptions["anchorStyle"], +): 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") { + for (const [, hash, attr] of value.matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); + } + } else { + walk(value); + } + } + } + }; + walk(spec); + + for (const section of groupByResource(spec)) { + for (const endpoint of section.endpoints) { + anchors.add(endpointAnchor(endpoint, anchorStyle)); + } + } + return anchors; +} + /** * Extract the resource name from a path. * /v1/content/{guid}/bundles -> "content" @@ -261,13 +323,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) { @@ -325,9 +414,7 @@ function shiftHeadings(description: string): string { function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint, options: RenderOptions = DEFAULT_OPTIONS): string[] { const { method, path, operation } = endpoint; const title = operation.summary || `${methodBadge(method)} ${path}`; - const anchor = options.anchorStyle === "path" - ? pathToAnchor(method, path) - : operation.operationId || pathToAnchor(method, path); + const anchor = endpointAnchor(endpoint, options.anchorStyle); const lines: string[] = []; diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts b/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts index 55a2e29..4c1bdbc 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/markdown.ts @@ -133,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/sections.ts b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts index 2bcbb59..74a76dc 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts @@ -17,6 +17,8 @@ import { HTTP_METHODS, isReference } from "./types.ts"; import { resolve } from "./refs.ts"; import { renderSchema } from "./schema.ts"; import { + autoIdentifier, + disambiguateId, heading, methodBadge, pathToAnchor, @@ -39,6 +41,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" }; @@ -135,13 +142,68 @@ export function renderApiReferenceBody( if (anchorStyle === "path") { rewriteSpecRefs(spec, buildOperationIdToPathMap(spec)); } + const takenIds = declaredAnchors(spec, anchorStyle); const lines: string[] = []; for (const section of groupByResource(spec)) { - lines.push(...renderSection(spec, section, { anchorStyle })); + lines.push(...renderSection(spec, section, { anchorStyle, takenIds })); } return lines; } +/** The anchor an endpoint heading declares. */ +function endpointAnchor( + endpoint: Endpoint, + anchorStyle: RenderOptions["anchorStyle"], +): string { + const { method, path, operation } = endpoint; + return anchorStyle === "path" + ? pathToAnchor(method, path) + : operation.operationId || pathToAnchor(method, path); +} + +/** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ +const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; + +/** + * Every anchor the page declares outright: the ones the spec's prose writes, + * and the ones this generator gives each endpoint. + * + * 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 this set itself. + */ +function declaredAnchors( + spec: OpenAPISpec, + anchorStyle: RenderOptions["anchorStyle"], +): 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") { + for (const [, hash, attr] of value.matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); + } + } else { + walk(value); + } + } + } + }; + walk(spec); + + for (const section of groupByResource(spec)) { + for (const endpoint of section.endpoints) { + anchors.add(endpointAnchor(endpoint, anchorStyle)); + } + } + return anchors; +} + /** * Extract the resource name from a path. * /v1/content/{guid}/bundles -> "content" @@ -261,13 +323,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) { @@ -325,9 +414,7 @@ function shiftHeadings(description: string): string { function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint, options: RenderOptions = DEFAULT_OPTIONS): string[] { const { method, path, operation } = endpoint; const title = operation.summary || `${methodBadge(method)} ${path}`; - const anchor = options.anchorStyle === "path" - ? pathToAnchor(method, path) - : operation.operationId || pathToAnchor(method, path); + const anchor = endpointAnchor(endpoint, options.anchorStyle); const lines: string[] = []; diff --git a/tests/markdown_test.ts b/tests/markdown_test.ts index c7c3fb3..304ebf7 100644 --- a/tests/markdown_test.ts +++ b/tests/markdown_test.ts @@ -4,6 +4,8 @@ import { assertStringIncludes, } from "./assert.ts"; import { + autoIdentifier, + disambiguateId, listTable, heading, pathToAnchor, @@ -99,3 +101,27 @@ test("listTable: cell text is emitted verbatim, whatever its length", () => { 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/sections_test.ts b/tests/sections_test.ts index f33e4b8..376b051 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,65 @@ 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", + ); +}); From 709dc4c33e53a16b8436a94537f9a7fe5a60f71b Mon Sep 17 00:00:00 2001 From: edavidaja Date: Thu, 10 Sep 2026 13:11:09 -0400 Subject: [PATCH 10/13] fix: indent a raw HTML fence to match the block it wraps fenceHtmlBlocks emitted both fences at column zero whatever the block's own indentation. An HTML block inside a list item was then torn out of the list: - Item ```{=html}
``` The column-zero opener closes the list, so the block renders as a sibling of the list rather than as the item's content. Indent both fences to the block's first line. Verified under Quarto 1 and Quarto 2: the div stays inside the list item in both. Also export stripCode, which returns prose with code spans and literal blocks replaced by newlines, for callers that scan description text for syntax and must not match an example of it. --- _extensions/quarto-openapi/lib/escape.ts | 18 +++++++++++++++++- .../posit-dev/quarto-openapi/lib/escape.ts | 18 +++++++++++++++++- tests/escape_test.ts | 14 ++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts index 4c3f9fd..5a87ad5 100644 --- a/_extensions/quarto-openapi/lib/escape.ts +++ b/_extensions/quarto-openapi/lib/escape.ts @@ -124,6 +124,19 @@ function splitInlineCode(text: string): Segment[] { 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 @@ -208,7 +221,10 @@ export function fenceHtmlBlocks(text: string): string { ), ); const fence = "`".repeat(Math.max(3, longest + 1)); - out.push(`${fence}{=html}`, ...block, fence); + // Both fences sit at the block's own indentation. At column zero they + // would close whatever list item or blockquote contains the block. + const indent = block[0].slice(0, block[0].length - block[0].trimStart().length); + out.push(`${indent}${fence}{=html}`, ...block, `${indent}${fence}`); cursor = end; } out.push(...lines.slice(cursor)); diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts index 4c3f9fd..5a87ad5 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts @@ -124,6 +124,19 @@ function splitInlineCode(text: string): Segment[] { 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 @@ -208,7 +221,10 @@ export function fenceHtmlBlocks(text: string): string { ), ); const fence = "`".repeat(Math.max(3, longest + 1)); - out.push(`${fence}{=html}`, ...block, fence); + // Both fences sit at the block's own indentation. At column zero they + // would close whatever list item or blockquote contains the block. + const indent = block[0].slice(0, block[0].length - block[0].trimStart().length); + out.push(`${indent}${fence}{=html}`, ...block, `${indent}${fence}`); cursor = end; } out.push(...lines.slice(cursor)); diff --git a/tests/escape_test.ts b/tests/escape_test.ts index 1862e5b..9367351 100644 --- a/tests/escape_test.ts +++ b/tests/escape_test.ts @@ -237,3 +237,17 @@ 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 ```", + ); +}); From 43295ceca0261c3eb49ad0da353070dcd204cabc Mon Sep 17 00:00:00 2001 From: edavidaja Date: Thu, 10 Sep 2026 13:11:22 -0400 Subject: [PATCH 11/13] fix: take section anchors from the rendered body, not a guess at it declaredAnchors listed each endpoint's top-level anchor and nothing else, so a section heading could still derive an anchor the page had already declared. Three ways through: - renderEndpoint also declares -parameters, -request-body, -responses and a per-status tab anchor. A tag named "Bootstrap Responses" with operationId "bootstrap" derived bootstrap-responses, which the Responses heading had taken. - heading() runs sanitizeId over the anchor it writes, and the set held the raw one. operationId "api keys" was registered as "api keys" and emitted as "api-keys", so a section named API Keys derived "api-keys" and saw no collision. - the anchor pattern matched description text without regard for code, so a description reading "write `{#api-keys}` as literal syntax" moved the API Keys section to api-keys-1 and broke the #api-keys link it was meant to protect. Render the endpoints first and read the anchors off those lines. The generator no longer restates which anchors it writes, so it cannot disagree with itself, and sanitizeId has already run. Read the spec's own prose through stripCode. The Connect reference is byte-identical either way: it uses path-style anchors, and no tag matches an endpoint sub-anchor. --- _extensions/quarto-openapi/lib/sections.ts | 80 +++++++++++-------- .../posit-dev/quarto-openapi/lib/sections.ts | 80 +++++++++++-------- tests/sections_test.ts | 61 ++++++++++++++ 3 files changed, 151 insertions(+), 70 deletions(-) diff --git a/_extensions/quarto-openapi/lib/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index 74a76dc..50718ba 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -16,6 +16,7 @@ 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, @@ -142,52 +143,65 @@ export function renderApiReferenceBody( if (anchorStyle === "path") { rewriteSpecRefs(spec, buildOperationIdToPathMap(spec)); } - const takenIds = declaredAnchors(spec, anchorStyle); + // 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())) takenIds.add(anchor); + const lines: string[] = []; - for (const section of groupByResource(spec)) { - lines.push(...renderSection(spec, section, { anchorStyle, takenIds })); - } + sections.forEach((section, i) => { + lines.push( + heading(2, section.name, claimSectionAnchor(section.name, { + anchorStyle, + takenIds, + })), + ); + lines.push(""); + lines.push(...bodies[i]); + }); return lines; } -/** The anchor an endpoint heading declares. */ -function endpointAnchor( - endpoint: Endpoint, - anchorStyle: RenderOptions["anchorStyle"], -): string { - const { method, path, operation } = endpoint; - return anchorStyle === "path" - ? pathToAnchor(method, path) - : operation.operationId || pathToAnchor(method, path); -} - /** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; +/** Every anchor declared in `lines`. */ +function anchorsIn(lines: Iterable): Set { + const anchors = new Set(); + for (const line of lines) { + for (const [, hash, attr] of line.matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); + } + } + return anchors; +} + /** - * Every anchor the page declares outright: the ones the spec's prose writes, - * and the ones this generator gives each endpoint. + * The anchors the spec's own prose declares, ignoring any written inside code, + * which is an example of the syntax rather than a use of it. * * 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 this set itself. + * anchor Quarto derives from its text, has to avoid these itself. */ -function declaredAnchors( - spec: OpenAPISpec, - anchorStyle: RenderOptions["anchorStyle"], -): Set { - const anchors = new Set(); - +function declaredAnchors(spec: OpenAPISpec): Set { + const prose: string[] = []; 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") { - for (const [, hash, attr] of value.matchAll(DECLARED_ANCHOR)) { - anchors.add(hash ?? attr); - } + prose.push(stripCode(value)); } else { walk(value); } @@ -195,13 +209,7 @@ function declaredAnchors( } }; walk(spec); - - for (const section of groupByResource(spec)) { - for (const endpoint of section.endpoints) { - anchors.add(endpointAnchor(endpoint, anchorStyle)); - } - } - return anchors; + return anchorsIn(prose); } /** @@ -414,7 +422,9 @@ function shiftHeadings(description: string): string { function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint, options: RenderOptions = DEFAULT_OPTIONS): string[] { const { method, path, operation } = endpoint; const title = operation.summary || `${methodBadge(method)} ${path}`; - const anchor = endpointAnchor(endpoint, options.anchorStyle); + const anchor = options.anchorStyle === "path" + ? pathToAnchor(method, path) + : operation.operationId || pathToAnchor(method, path); const lines: string[] = []; diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts index 74a76dc..50718ba 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts @@ -16,6 +16,7 @@ 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, @@ -142,52 +143,65 @@ export function renderApiReferenceBody( if (anchorStyle === "path") { rewriteSpecRefs(spec, buildOperationIdToPathMap(spec)); } - const takenIds = declaredAnchors(spec, anchorStyle); + // 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())) takenIds.add(anchor); + const lines: string[] = []; - for (const section of groupByResource(spec)) { - lines.push(...renderSection(spec, section, { anchorStyle, takenIds })); - } + sections.forEach((section, i) => { + lines.push( + heading(2, section.name, claimSectionAnchor(section.name, { + anchorStyle, + takenIds, + })), + ); + lines.push(""); + lines.push(...bodies[i]); + }); return lines; } -/** The anchor an endpoint heading declares. */ -function endpointAnchor( - endpoint: Endpoint, - anchorStyle: RenderOptions["anchorStyle"], -): string { - const { method, path, operation } = endpoint; - return anchorStyle === "path" - ? pathToAnchor(method, path) - : operation.operationId || pathToAnchor(method, path); -} - /** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; +/** Every anchor declared in `lines`. */ +function anchorsIn(lines: Iterable): Set { + const anchors = new Set(); + for (const line of lines) { + for (const [, hash, attr] of line.matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); + } + } + return anchors; +} + /** - * Every anchor the page declares outright: the ones the spec's prose writes, - * and the ones this generator gives each endpoint. + * The anchors the spec's own prose declares, ignoring any written inside code, + * which is an example of the syntax rather than a use of it. * * 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 this set itself. + * anchor Quarto derives from its text, has to avoid these itself. */ -function declaredAnchors( - spec: OpenAPISpec, - anchorStyle: RenderOptions["anchorStyle"], -): Set { - const anchors = new Set(); - +function declaredAnchors(spec: OpenAPISpec): Set { + const prose: string[] = []; 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") { - for (const [, hash, attr] of value.matchAll(DECLARED_ANCHOR)) { - anchors.add(hash ?? attr); - } + prose.push(stripCode(value)); } else { walk(value); } @@ -195,13 +209,7 @@ function declaredAnchors( } }; walk(spec); - - for (const section of groupByResource(spec)) { - for (const endpoint of section.endpoints) { - anchors.add(endpointAnchor(endpoint, anchorStyle)); - } - } - return anchors; + return anchorsIn(prose); } /** @@ -414,7 +422,9 @@ function shiftHeadings(description: string): string { function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint, options: RenderOptions = DEFAULT_OPTIONS): string[] { const { method, path, operation } = endpoint; const title = operation.summary || `${methodBadge(method)} ${path}`; - const anchor = endpointAnchor(endpoint, options.anchorStyle); + const anchor = options.anchorStyle === "path" + ? pathToAnchor(method, path) + : operation.operationId || pathToAnchor(method, path); const lines: string[] = []; diff --git a/tests/sections_test.ts b/tests/sections_test.ts index 376b051..522c89b 100644 --- a/tests/sections_test.ts +++ b/tests/sections_test.ts @@ -405,3 +405,64 @@ test("renderApiReferenceBody: disambiguates a group heading against an endpoint "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)}`, + ); +}); From d2b0ef075d4717ead1633eac26c4f49c96cf0d6b Mon Sep 17 00:00:00 2001 From: edavidaja Date: Thu, 10 Sep 2026 13:15:28 -0400 Subject: [PATCH 12/13] fix: carry the container prefix onto a raw HTML fence, and strip code from the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the previous two commits. The fence prefix copied leading whitespace only. An HTML block inside a blockquote still got a column-zero opener, which ended the quote and sent the `>` markers into the raw HTML as content. Copy the whole container prefix — indentation and blockquote markers — so a block inside a quote, a list item, or both stays where it was written. anchorsIn read the rendered endpoint bodies verbatim, and those bodies carry the operation descriptions. A description reading "write `{#api-keys}` as literal syntax" therefore still registered a false anchor, the case the code-span handling was added to stop. anchorsIn now strips code itself, so no caller can forget, and it takes one whole document, because a fenced block only reads as code when its opener and closer are scanned together. The Connect reference is byte-identical either way. --- _extensions/quarto-openapi/lib/escape.ts | 16 +++++-- _extensions/quarto-openapi/lib/sections.ts | 29 +++++++------ .../posit-dev/quarto-openapi/lib/escape.ts | 16 +++++-- .../posit-dev/quarto-openapi/lib/sections.ts | 29 +++++++------ tests/escape_test.ts | 14 +++++++ tests/sections_test.ts | 42 +++++++++++++++++++ 6 files changed, 114 insertions(+), 32 deletions(-) diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts index 5a87ad5..7a4367b 100644 --- a/_extensions/quarto-openapi/lib/escape.ts +++ b/_extensions/quarto-openapi/lib/escape.ts @@ -182,6 +182,12 @@ function transformOutsideCode( .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. * @@ -221,10 +227,12 @@ export function fenceHtmlBlocks(text: string): string { ), ); const fence = "`".repeat(Math.max(3, longest + 1)); - // Both fences sit at the block's own indentation. At column zero they - // would close whatever list item or blockquote contains the block. - const indent = block[0].slice(0, block[0].length - block[0].trimStart().length); - out.push(`${indent}${fence}{=html}`, ...block, `${indent}${fence}`); + // 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] ?? ""; + out.push(`${prefix}${fence}{=html}`, ...block, `${prefix}${fence}`); cursor = end; } out.push(...lines.slice(cursor)); diff --git a/_extensions/quarto-openapi/lib/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index 50718ba..de10ce7 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -154,7 +154,7 @@ export function renderApiReferenceBody( ); const takenIds = declaredAnchors(spec); - for (const anchor of anchorsIn(bodies.flat())) takenIds.add(anchor); + for (const anchor of anchorsIn(bodies.flat().join("\n"))) takenIds.add(anchor); const lines: string[] = []; sections.forEach((section, i) => { @@ -173,20 +173,23 @@ export function renderApiReferenceBody( /** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; -/** Every anchor declared in `lines`. */ -function anchorsIn(lines: Iterable): Set { +/** + * 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 line of lines) { - for (const [, hash, attr] of line.matchAll(DECLARED_ANCHOR)) { - anchors.add(hash ?? attr); - } + for (const [, hash, attr] of stripCode(markdown).matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); } return anchors; } /** - * The anchors the spec's own prose declares, ignoring any written inside code, - * which is an example of the syntax rather than a use of it. + * 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 @@ -194,14 +197,16 @@ function anchorsIn(lines: Iterable): Set { * anchor Quarto derives from its text, has to avoid these itself. */ function declaredAnchors(spec: OpenAPISpec): Set { - const prose: string[] = []; + 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") { - prose.push(stripCode(value)); + // 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); } @@ -209,7 +214,7 @@ function declaredAnchors(spec: OpenAPISpec): Set { } }; walk(spec); - return anchorsIn(prose); + return anchors; } /** diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts index 5a87ad5..7a4367b 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts @@ -182,6 +182,12 @@ function transformOutsideCode( .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. * @@ -221,10 +227,12 @@ export function fenceHtmlBlocks(text: string): string { ), ); const fence = "`".repeat(Math.max(3, longest + 1)); - // Both fences sit at the block's own indentation. At column zero they - // would close whatever list item or blockquote contains the block. - const indent = block[0].slice(0, block[0].length - block[0].trimStart().length); - out.push(`${indent}${fence}{=html}`, ...block, `${indent}${fence}`); + // 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] ?? ""; + out.push(`${prefix}${fence}{=html}`, ...block, `${prefix}${fence}`); cursor = end; } out.push(...lines.slice(cursor)); diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts index 50718ba..de10ce7 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/sections.ts @@ -154,7 +154,7 @@ export function renderApiReferenceBody( ); const takenIds = declaredAnchors(spec); - for (const anchor of anchorsIn(bodies.flat())) takenIds.add(anchor); + for (const anchor of anchorsIn(bodies.flat().join("\n"))) takenIds.add(anchor); const lines: string[] = []; sections.forEach((section, i) => { @@ -173,20 +173,23 @@ export function renderApiReferenceBody( /** An anchor written as `{#id}`, or as `id="…"` inside an attribute block. */ const DECLARED_ANCHOR = /\{#([^\s{}]+)\}|\{[^{}]*\bid="([^"]+)"[^{}]*\}/g; -/** Every anchor declared in `lines`. */ -function anchorsIn(lines: Iterable): Set { +/** + * 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 line of lines) { - for (const [, hash, attr] of line.matchAll(DECLARED_ANCHOR)) { - anchors.add(hash ?? attr); - } + for (const [, hash, attr] of stripCode(markdown).matchAll(DECLARED_ANCHOR)) { + anchors.add(hash ?? attr); } return anchors; } /** - * The anchors the spec's own prose declares, ignoring any written inside code, - * which is an example of the syntax rather than a use of it. + * 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 @@ -194,14 +197,16 @@ function anchorsIn(lines: Iterable): Set { * anchor Quarto derives from its text, has to avoid these itself. */ function declaredAnchors(spec: OpenAPISpec): Set { - const prose: string[] = []; + 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") { - prose.push(stripCode(value)); + // 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); } @@ -209,7 +214,7 @@ function declaredAnchors(spec: OpenAPISpec): Set { } }; walk(spec); - return anchorsIn(prose); + return anchors; } /** diff --git a/tests/escape_test.ts b/tests/escape_test.ts index 9367351..9271671 100644 --- a/tests/escape_test.ts +++ b/tests/escape_test.ts @@ -251,3 +251,17 @@ test("fenceHtmlBlocks: indents to the shallowest line of the block", () => { " ```{=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 > ```", + ); +}); diff --git a/tests/sections_test.ts b/tests/sections_test.ts index 522c89b..193c856 100644 --- a/tests/sections_test.ts +++ b/tests/sections_test.ts @@ -466,3 +466,45 @@ test("renderApiReferenceBody: ignores an anchor written inside a code span", () `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)}`, + ); +}); From dfbc4e52af8921b81e7d432e7294515d4bbc0eb6 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Thu, 10 Sep 2026 13:18:26 -0400 Subject: [PATCH 13/13] fix: leave an HTML block bare when fencing it would move the block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An HTML block can open on the line that opens its list item, as in `-
`. The container prefix is empty there, so both fences went to column zero with the list marker inside the opener: the marker became HTML content and the list ended early. Fencing that case correctly means holding the marker back and re-indenting every line of the block. Skip it instead. A bare block renders the same under Quarto 1 and Quarto 2 — verified: the div stays inside the list item in both — and only draws a Quarto 2 warning. The fence exists to silence that warning, so declining to fence costs a warning and never costs correctness. --- _extensions/quarto-openapi/lib/escape.ts | 22 ++++++++++++++----- .../posit-dev/quarto-openapi/lib/escape.ts | 22 ++++++++++++++----- tests/escape_test.ts | 10 +++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/_extensions/quarto-openapi/lib/escape.ts b/_extensions/quarto-openapi/lib/escape.ts index 7a4367b..c9eacf3 100644 --- a/_extensions/quarto-openapi/lib/escape.ts +++ b/_extensions/quarto-openapi/lib/escape.ts @@ -219,6 +219,23 @@ export function fenceHtmlBlocks(text: string): string { 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, @@ -227,11 +244,6 @@ export function fenceHtmlBlocks(text: string): string { ), ); const fence = "`".repeat(Math.max(3, longest + 1)); - // 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] ?? ""; out.push(`${prefix}${fence}{=html}`, ...block, `${prefix}${fence}`); cursor = end; } diff --git a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts index 7a4367b..c9eacf3 100644 --- a/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts +++ b/example/_extensions/posit-dev/quarto-openapi/lib/escape.ts @@ -219,6 +219,23 @@ export function fenceHtmlBlocks(text: string): string { 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, @@ -227,11 +244,6 @@ export function fenceHtmlBlocks(text: string): string { ), ); const fence = "`".repeat(Math.max(3, longest + 1)); - // 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] ?? ""; out.push(`${prefix}${fence}{=html}`, ...block, `${prefix}${fence}`); cursor = end; } diff --git a/tests/escape_test.ts b/tests/escape_test.ts index 9271671..f015705 100644 --- a/tests/escape_test.ts +++ b/tests/escape_test.ts @@ -265,3 +265,13 @@ test("fenceHtmlBlocks: keeps a blockquote marker inside a list item", () => { "- 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); +});