Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Quarto 2 rejects markdown constructs the extension currently emits. This makes
the generated
.qmdrender 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.
[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}/keyskeeps 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 andangle-bracket-free attributes, which leaves
a < band CommonMark autolinksalone — 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 asnothing at all. Enum members go through the same formatter as defaults and
examples, so
nulland non-string members format consistently.Compatibility
quarto-requiredrises from>=1.6.0to>=1.9.0. List-table supportlanded 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-requiredit does not satisfy; Quarto 2 ignores thefield entirely, and loads the extension whatever the bound says (tested at
>=1.6.0,>=1.9.0,>=2.0.0and>=99.0.0on q2 0.25.0 — no error, nowarning, 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 renderon the example. Aprobe spec exercising every escaped construct renders correctly: brackets and
braces come through as literal text, the
<a>becomes a real link witha[0]=1intact in its query string,<admin@example.com>becomes a mailtolink rather than being wrapped, and
Default: ""/Enum: ""are visible.Stack created with GitHub Stacks CLI • Give Feedback 💬