Skip to content

feat!: emit list tables and escape description prose for Quarto 2 - #28

Merged
edavidaja merged 13 commits into
nodefrom
q2-compat
Sep 10, 2026
Merged

edavidaja merged 13 commits into
nodefrom
q2-compat

Conversation

@edavidaja

@edavidaja edavidaja commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

Quarto 2 rejects markdown constructs the extension currently emits. This makes
the generated .qmd render on both Quarto 1.9+ and Quarto 2.

Stacked on #27, which moves the pre-render script to Node.

Grid tables become list tables

Quarto 2 dropped Pandoc grid tables. List tables render on both, so the
extension emits them unconditionally rather than carrying two renderers.

Column widths are carried over rather than recomputed: each fraction is
derived from the character width the grid emitter would have given the column,
so rendered proportions are unchanged. Quarto normalizes them to percentages.

This also removes a constraint. 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.

Description prose is escaped

Descriptions are CommonMark written for arbitrary renderers, so they carry
constructs Quarto reads as markup. Quarto 2 hard-errors on the first two and
warns on the third, once per element per page.

In the spec In the output
[start, start+interval) \[start, start+interval)
/v1/users/{guid}/keys /v1/users/\{guid\}/keys
<a href="/x">bundle</a> ```{=html}bundle`{=html}``

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. A path written as GET /v1/users/{guid}/keys
keeps its braces.

The HTML pass runs first: 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 requires 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 render identically to the unescaped text, so none of this is
conditional on the Quarto version.

Value formatting

Empty strings format as "" rather than an empty code span, which renders as
nothing at all. Enum members go through the same formatter as defaults and
examples, so null and non-string members format consistently.

Compatibility

quarto-required rises from >=1.6.0 to >=1.9.0. List-table support
landed in 1.9; 1.6–1.8 render list tables as bullet lists, silently. Verified
across 1.6.43, 1.7.34, 1.8.27, 1.9.30 and 1.9.32. The escaping and value
formatting are safe on every version.

The bound is enforced exactly where it needs to be. Quarto 1 refuses an
extension whose quarto-required it does not satisfy; Quarto 2 ignores the
field entirely, and loads the extension whatever the bound says (tested at
>=1.6.0, >=1.9.0, >=2.0.0 and >=99.0.0 on q2 0.25.0 — no error, no
warning, contributed filter ran every time). So the guard blocks the 1.6–1.8
users who would get bullet lists, and is inert for q2 users, who always have
list tables. Nothing on q2 needs guarding.

The version bump to 0.5.0 covers this PR and #27 together, as one release.

Verification

97 tests (16 new), typecheck clean, and quarto render on the example. A
probe spec exercising every escaped construct renders correctly: brackets and
braces come through as literal text, the <a> becomes a real link with
a[0]=1 intact in its query string, <admin@example.com> becomes a mailto
link rather than being wrapped, and Default: "" / Enum: "" are visible.

Stack created with GitHub Stacks CLI • Give Feedback 💬

@edavidaja edavidaja changed the title q2 compat feat!: emit list tables and escape description prose for Quarto 2 Aug 24, 2026
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.
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.
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.
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.
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.
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.
@edavidaja
edavidaja marked this pull request as ready for review September 10, 2026 14:51
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.
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.
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}
      <div>
      </div>
    ```

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.
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.
… from the body

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.
An HTML block can open on the line that opens its list item, as in
`- <div>`. 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.
@edavidaja
edavidaja merged commit da55715 into main Sep 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant