Skip to content

fix(callouts): render nested code blocks inside GFM/GLFM alerts#251

Merged
gamosoft merged 1 commit into
gamosoft:mainfrom
pablon:fix/callouts-nested-codeblock
Jul 10, 2026
Merged

fix(callouts): render nested code blocks inside GFM/GLFM alerts#251
gamosoft merged 1 commit into
gamosoft:mainfrom
pablon:fix/callouts-nested-codeblock

Conversation

@pablon

@pablon pablon commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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:

> [!TIP]
> Add the following block **at the beginning** of your `~/.gitconfig` if you
> want to get advantage of all pre-cooked git configs, aliases and functions:
>
> ```gitconfig
> [include]
>     path = ~/dotfiles/.gitconfig
> ```

**Required** shell config files sourced automatically by `.zshrc`:

Before this PR:

  • The Tip callout 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.
  • Everything after the callout (including subsequent headings and callouts) is swallowed inside an unclosed code block.

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:

extract fenced code → placeholders
extract inline code → placeholders
… other prep …
convert callouts     # ← ran here
restore placeholders
marked.parse

The extraction regex /```[\s\S]*?```/g captures the whole span from the opening to 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

Feature GFM (github.com) GLFM (gitlab.com) Handled
5 alert types: NOTE, TIP, IMPORTANT, WARNING, CAUTION
Case-insensitive marker UPPERCASE docs lowercase docs
Custom title on marker line ([!warning] Data deletion) Undocumented
Multi-line body with > prefix
Empty > as paragraph separator inside body
Rich markdown inside body (lists, bold, tables, links)
Nested fenced code block (backticks) — this bug ✔ fixed
Nested fenced code block (tildes) — same root cause ✔ fixed
Alerts cannot be nested (inner renders as plain blockquote) Explicit Explicit
Literal > [!TIP] inside top-level code block must NOT be converted Implicit Implicit
Adjacent callouts separated by blank line
Multiline blockquote alerts (>>> [!note] ... >>>) ✔ (GitLab only) Out of scope — separate feature

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:

# Case Assertion
A Reported bug (nested backtick fence) .callout-tip contains <pre><code> with the code, after renders as sibling <p>, no </div> leak
B Nested tilde fence .callout-warning contains the yaml code
C All 5 types in GFM UPPERCASE 5 distinct .callout-{type} present
D GitLab lowercase + custom title .callout-title-text reads Data deletion
E Empty callout .callout-tip present with empty body
F Plain blockquote No .callout produced, <blockquote> present
G Top-level backtick fence with literal [!TIP] Zero callouts, literal text preserved in code
H Top-level tilde fence with literal [!TIP] Zero callouts, literal text preserved
I Rich markdown in body <strong>, <a href>, <table> all render inside body
J Empty > in body ≥ 2 <p> inside .callout-body
K Two adjacent callouts Exactly 2 .callout divs
L Attempted nested callout Outer OK, inner renders as <blockquote> (matches GFM spec — no nesting)

The test rig loaded the exact same marked and DOMPurify versions 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 / -53 lines across two files. No CSS changes, no MCP server changes, no dependency changes.

Out of scope

  • Multiline blockquote alerts (>>> [!note] ... >>>, a GitLab-only extension) — should be tracked as a separate feature request.

Checklist

  • Export (backend/export.py) fixed
  • Preview (frontend/app.js) fixed
  • Behavior verified end-to-end with real marked + DOMPurify in jsdom
  • 12-case matrix covering GFM + GLFM compatibility passes
  • No dependencies added
  • Signed-off commit

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>
@gamosoft

Copy link
Copy Markdown
Owner

Just tested locally and it works, approved and merging, awesome job! 😎
I also like that the export path got simpler as a side effect.
Tiny nit-pick: > [!TIPO] in the comments should be [!TIP].
But not important at all, I'll fix it afterwards (with a small dockerfile change I did to troubleshoot something).

Thanks @pablon! 🙏🙏🙏

@gamosoft gamosoft merged commit a4342cf into gamosoft:main Jul 10, 2026
@gamosoft

Copy link
Copy Markdown
Owner

It's already released:
https://github.com/gamosoft/NoteDiscovery/releases/tag/v0.28.2

Thanks again for the attention to detail!

@pablon pablon deleted the fix/callouts-nested-codeblock branch July 10, 2026 09:45
@pablon

pablon commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Just tested locally and it works, approved and merging, awesome job! 😎 I also like that the export path got simpler as a side effect. Tiny nit-pick: > [!TIPO] in the comments should be [!TIP]. But not important at all, I'll fix it afterwards (with a small dockerfile change I did to troubleshoot something).

Thanks @pablon! 🙏🙏🙏

Oh I missed that one! ("tipo" = "type" in spanish 😅)

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