fix(callouts): render nested code blocks inside GFM/GLFM alerts#251
Conversation
The callout preprocessor ran AFTER the fenced-code extraction pass, so a code block nested inside a callout blockquote (each line prefixed with `> `) was captured whole \u2014 including the leading `> ` on the closing fence. When the placeholder was later restored inside the raw `<div class="callout-body">`, the closing line `> \`\`\`` no longer sat at 0-3 leading spaces, so CommonMark did not recognize it as a fence closer. marked.js ran the block unclosed, swallowed the callout's `</div></div>` and every downstream node, and the render leaked as raw blockquote text. Reorder the pipeline so callouts are converted BEFORE code-block extraction. The scan is fence-aware (backticks and tildes) so a literal `> [!TYPE]\' inside a top-level code block is not misread as a callout. Mirrored in backend/export.py so the standalone export matches the in-app preview. Verified end-to-end against a 12-case matrix covering GFM (github.com) and GLFM (gitlab.com) alert syntax: nested backtick fence (bug), nested tilde fence, all 5 types in UPPERCASE, GitLab lowercase with custom title, empty callout, plain blockquote passthrough, literal `[!TIP]` inside top-level backtick and tilde fences (regression), rich body markdown, paragraph separators, adjacent callouts, and nested-alert attempts (GFM disallows nesting). Signed-off-by: pablon <73798198+pablon@users.noreply.github.com>
|
Just tested locally and it works, approved and merging, awesome job! 😎 Thanks @pablon! 🙏🙏🙏 |
|
It's already released: Thanks again for the attention to detail! |
Oh I missed that one! ("tipo" = "type" in spanish 😅) |
Summary
Fenced code blocks nested inside a callout (
> [!TIP],> [!NOTE], etc.) were breaking the render: the callout closing</div>leaked into the page as visible text and every downstream node was pulled inside an unclosed code block. This PR fixes both the in-app preview (frontend/app.js) and the standalone export (backend/export.py) so the two paths stay in sync, and validates the behavior against a 12-case matrix covering GitHub Flavored Markdown (GFM) and GitLab Flavored Markdown (GLFM) alert syntax.Follow-up to #243, which introduced the callout preprocessor.
Reproduction
Any note that nests a fenced code block inside a callout triggers it. Minimal repro:
Before this PR:
Tipcallout title renders, but the closing</div></div>shows up as literal text in the page.> [include],> path = ..., and `> ```` render as raw blockquote-prefixed text outside the callout.After this PR: the callout closes correctly, the code block renders inside
.callout-body, and content after the callout renders normally as a sibling.Root cause
The callout preprocessor ran after the fenced-code protection pass:
The extraction regex
/```[\s\S]*?```/gcaptures the whole span from the openingto the closing ``` ```. When the fence is nested in a blockquote, the match crosses lines that begin with>, and — critically — includes the>that precedes the closing fence.Once the callout scanner has stripped
>from the body lines and wrapped everything in a raw HTML<div class="callout-body">, the placeholder is restored back into that div. But the closing line inside the restored block still reads> ```, which no longer sits at 0–3 leading spaces of whitespace, so CommonMark does not recognize it as a valid fence closer (CommonMark §4.5). marked.js runs the code block open, consumes the callout's</div></div>as code text, and continues until end of input.The fix
Convert callouts before the fenced-code extraction pass, so the body lines have
>stripped naturally — producing a clean fence (```gitconfig … ```) that later passes through the pipeline correctly.The outer scan is fence-aware: while inside a top-level
`` or~~~block, `> [!TIP]` written as literal text (e.g. in documentation about callouts) is passed through opaquely instead of being misinterpreted as a callout marker.Both the in-app preview (
frontend/app.js) and the standalone HTML export (backend/export.py) receive the same change so the two rendering paths stay in sync.Compatibility verified against GFM + GLFM specs
[!warning] Data deletion)>prefix>as paragraph separator inside body> [!TIP]inside top-level code block must NOT be converted>>> [!note] ... >>>)References:
End-to-end test
Ran the full pipeline (
preprocess → marked@12.0.0 → DOMPurify@3.0.8 → DOM assertions in jsdom) against 12 cases covering every row above. 12/12 pass. Sample assertions per case:.callout-tipcontains<pre><code>with the code,afterrenders as sibling<p>, no</div>leak.callout-warningcontains the yaml code.callout-{type}present.callout-title-textreadsData deletion.callout-tippresent with empty body.calloutproduced,<blockquote>present[!TIP][!TIP]<strong>,<a href>,<table>all render inside body>in body<p>inside.callout-body.calloutdivs<blockquote>(matches GFM spec — no nesting)The test rig loaded the exact same
markedandDOMPurifyversions that the export pins from CDN, so the assertions reflect real user render behavior in both preview and export.Changes
frontend/app.js— new Step 0 callout preprocessor (fence-aware) runs before code-block extraction. The old Step 2c block is removed.backend/export.py— mirrored change in the embedded JS pipeline used by the standalone export.Net:
+107 / -53lines across two files. No CSS changes, no MCP server changes, no dependency changes.Out of scope
>>> [!note] ... >>>, a GitLab-only extension) — should be tracked as a separate feature request.Checklist
backend/export.py) fixedfrontend/app.js) fixedmarked+DOMPurifyin jsdom