Skip to content

Parse markdown adjacent to a raw HTML block line - #660

Open
gordonwoodhull wants to merge 1 commit into
mainfrom
feature/bd-block-html-adjacent-markdown-unparsed-0qnjuwuy
Open

Parse markdown adjacent to a raw HTML block line#660
gordonwoodhull wants to merge 1 commit into
mainfrom
feature/bd-block-html-adjacent-markdown-unparsed-0qnjuwuy

Conversation

@gordonwoodhull

Copy link
Copy Markdown
Member

Since 0.29.0, a line of prose immediately following a raw HTML block line is emitted verbatim. Backticks, asterisks and every other inline construct survive into the rendered page as literal text:

<details>
<summary>Example custom instructions</summary>
This example demonstrates how a `quarto.instructions.md` file shapes …

Quarto 1 parses that, as does pandoc 3.8.1, and so did q2 0.28.0. A blank line is where the parser recovers, which makes it read like a content typo rather than an engine bug.

Cause

The lift added in bd-block-html-wrapped-in-p-w8qebxig turns a paragraph opening with a block-level HTML tag into a RawBlock by re-reading its source text verbatim. The extent is right — a markdown paragraph and a CommonMark type-6 HTML block both run to the next blank line — but re-reading the source also switches off inline parsing inside it.

That conflates two pandoc extensions. Dropping native_divs costs the <p> wrapper and the Div node, which was intended. Dropping markdown_in_html_blocks costs inline parsing, which was not. On a tight <div>, q2's output was byte-identical to pandoc -f markdown-native_divs-markdown_in_html_blocks, and its AST was a single verbatim RawBlock where -native_divs alone gives RawBlock/Plain/RawBlock.

Reader

process_paragraph already holds the fully-parsed inlines and was discarding them. It now partitions that vector: runs of block-level raw HTML coalesce into a RawBlock, text between them becomes a Plain, and the parts return as IntermediateSection, which every block container already splices. Classification is per-inline — no tag matching, no lookahead — so it stays inside the no-backtracking constraint in dev-docs/syntax-notes.md.

Two details are load-bearing. The tag test runs on trimmed text, because tree-sitter folds preceding whitespace into a non-first RawInline. And each part gets its own span; cloning the paragraph's onto all of them gives siblings overlapping ranges and misleads the incremental writer.

<pre>, <script>, <style> and <textarea> are exempt and stay verbatim — CommonMark's raw-text elements, which pandoc also leaves unparsed. All four are in BLOCK_TAGS, so without the exemption a split would parse markdown inside a <script>.

Writer

Block-level raw HTML is written bare rather than inside a ```{=html} fence, reversing the normalisation the lift originally shipped with. The fence is kept for everything else, gated on whether the text would re-read as the same single RawBlock — a pure run of block tags, or a raw-text element. Without that gate, an author's own ```{=html} block loses its fence and its contents are parsed on the next read: a backtick becomes Code, a # line becomes a Header.

Blocks are always separated by a blank line. Pandoc's writer is tight for RawBlockPlain and PlainRawBlock, but that is safe only because pandoc's reader guarantees Plain here means "inside an HTML block that is still open". q2 has no such invariant — Plain is emitted for every split interior, and filters emit them freely — so the writer cannot tell whether a Plain and an adjacent RawBlock came from one paragraph or two, and the AST does not record the answer.

Known divergences

  • A split interior returns as a Paragraph after an AST → qmd → read cycle, gaining a <p>. For <div> this matches Quarto 1; for a phrasing-only element such as <summary> it is invalid, though only after a write/read cycle and never on first render. bd-8md6k9dv.
  • Interiors are always Plain, never Paragraph. Pandoc chooses by tracking which element is open, which needs balanced matching. Every resulting difference is a <p> wrapper — the native_divs gap qmd already accepts. Against Quarto 1 this matches on every markdown-parsing question.
  • A block-level tag mid-paragraph still does not interrupt the paragraph, so a trailing </details> lands inside a <p>. Pre-existing and byte-identical on 0.28.0, 0.29.0 and main. bd-495qnexy.

Notes for review

incremental_writer_tests::roundtrip_comment_in_blockquote no longer uses assert_roundtrip, which demands full structural equality after a rewrite. That guarantee no longer holds; the test now asserts tags in block position, prose parsed, and a fixed point from the first rewrite.

Two snapshots change, both the same class — html-comment-17 and html-comment-44, where a comment followed by same-line text becomes RawBlock + Plain instead of one verbatim RawBlock. Neither the old nor the new output carries the <p> Quarto 1 emits there; the comment simply moves onto its own line.

Workspace 13740 passed / 0 failed / 199 skipped (baseline 13719 at origin/main; +21 are the tests added here). Clippy, cargo fmt and cargo xtask lint clean; cargo xtask verify passes all 14 steps including the WASM leg. Both repro fixtures in q2-positron-docs/llms-info/repros/ are green simultaneously, and q2 matches Quarto 1 on all four rows of the regression repro.

…ent-markdown-unparsed-0qnjuwuy)

Since 0.29.0 a line of prose immediately after a raw HTML block line was
emitted verbatim — backticks, asterisks and every other inline construct
survived into the page as literal text:

    <details>
    <summary>Example custom instructions</summary>
    This example demonstrates how a `quarto.instructions.md` file shapes …

Quarto 1 parses that, as does pandoc 3.8.1, and so did q2 0.28.0. A blank line
was where the parser recovered, so to an author it read like a content typo
rather than an engine bug.

The lift added in bd-block-html-wrapped-in-p-w8qebxig turned a paragraph
opening with a block-level HTML tag into a `RawBlock` by re-reading its source
text verbatim. The extent was right — a paragraph and a CommonMark type-6 HTML
block both run to the next blank line — but re-reading the source also switched
off inline parsing inside it. That conflated two pandoc extensions: dropping
`native_divs` costs the `<p>` wrapper and the `Div` node, which was intended;
dropping `markdown_in_html_blocks` costs inline parsing, which was not. Measured
on a tight `<div>`, q2's output was byte-identical to
`pandoc -f markdown-native_divs-markdown_in_html_blocks`, and its AST was one
verbatim `RawBlock` where `-native_divs` alone gives `RawBlock`/`Plain`/
`RawBlock`.

`process_paragraph` already held the fully-parsed inlines and discarded them.
It now partitions that vector instead: runs of block-level raw HTML coalesce
into a `RawBlock`, the text between them becomes a `Plain`, and the parts are
returned as `IntermediateSection`, which every block container already splices.
Each inline is classified on its own — no tag matching, no lookahead — so this
stays inside the no-backtracking constraint in `dev-docs/syntax-notes.md`. Two
details earn their comments: the tag test runs on trimmed text, because
tree-sitter folds preceding whitespace into a non-first `RawInline`; and each
part gets its own span, since cloning the paragraph's onto all of them gives
siblings overlapping ranges and misleads the incremental writer.

`<pre>`, `<script>`, `<style>` and `<textarea>` are exempt and stay verbatim.
They are CommonMark's raw-text elements, pandoc does not parse markdown inside
them, and all four are in `BLOCK_TAGS` — without the exemption a split would
parse markdown inside a `<script>`.

Block-level raw HTML is now written bare rather than inside a ```{=html} fence,
which reverses the normalisation the lift originally shipped with. The fence is
kept for anything else, gated on whether the text would re-read as the same
single `RawBlock`: a pure run of block tags, or a raw-text element. Without that
gate an author's own ```{=html} block loses its fence and its contents are
parsed on the next read — a backtick becoming `Code`, a `#` line becoming a
`Header`.

Writing the AST back out does not reproduce the `Plain`. Blocks are always
separated by a blank line, so a lifted interior re-reads as a `Paragraph` and
gains a `<p>`; for `<div>` that matches Quarto 1, and for a phrasing-only
element such as `<summary>` it is invalid, though only after a write/read cycle
and never on first render (bd-8md6k9dv). Preserving the `Plain` would mean
writing it tight against the neighbouring tag, which requires knowing the two
blocks came from one paragraph. The AST does not record that. A rule that
inferred it from block types alone — as pandoc's writer does, licensed by a
reader invariant we do not have — merged a `Plain` with a following *unrelated*
`<script>` so its JavaScript was parsed as markdown, let a closing tag fall back
inside a paragraph, and collapsed filter-produced blocks into one. The writer
does not guess.

Interiors are always `Plain`, never `Paragraph`. Pandoc chooses between them by
tracking which element is still open, which needs the balanced matching
`syntax-notes.md` rejects. Every resulting difference is a `<p>` wrapper — the
`native_divs` gap qmd already accepts — and against Quarto 1 this matches on
every markdown-parsing question. Documented in `dev-docs/syntax-notes.md` and
`crates/pampa/README.md`.

`incremental_writer_tests::roundtrip_comment_in_blockquote` no longer uses
`assert_roundtrip`, which demands full structural equality after a rewrite.
That guarantee genuinely no longer holds, so it asserts what does: tags in block
position, prose parsed, shape stable from the first rewrite.

Snapshots: 2 modified, both the same class — `html-comment-17` and
`html-comment-44`, where a comment followed by text on the same line is now
`RawBlock` + `Plain` instead of one verbatim `RawBlock`. Rendering was checked
before accepting: neither the old nor the new output carries the `<p>` Quarto 1
emits there, so the comment simply moves onto its own line, closer to Q1.

Not fixed, filed as bd-495qnexy: a block-level tag mid-paragraph still does not
interrupt the paragraph, so a trailing `</details>` lands inside a `<p>`.
Pre-existing and byte-identical on 0.28.0, 0.29.0 and main.

Tests: workspace 13697 passed, 0 failed, 199 skipped — baseline 13676, and +21
is exactly the tests added here. Clippy, cargo fmt and cargo xtask lint clean;
cargo xtask verify passes all 14 steps including the WASM leg. Both repro
fixtures are green and q2 matches Quarto 1 on all four rows of the regression
repro; verified end-to-end through `q2 render` with the output inspected.
@posit-snyk-bot

posit-snyk-bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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.

2 participants