Would you accept an opt-in Options flag (e.g. ENABLE_NESTED_FENCES, off by default) that pairs same-length fenced code blocks by nesting depth instead of closing at the first bare fence? I'd like to check the idea is in scope before writing a PR, per CONTRIBUTING.
Markdown written by LLMs very often nests fenced blocks using the same fence length. A typical case is a plan or a README draft wrapped in ```markdown that itself contains ```rust snippets. CommonMark correctly closes the outer block at the inner snippet's bare closing fence, which splits the document: the rest of the outer block renders as prose, and its real closing fence opens a stray empty block.
With pulldown-cmark 0.13.4 and default options:
```markdown
# Plan
```rust
fn main() {}
```
Done.
```
renders as:
<pre><code class="language-markdown"># Plan
```rust
fn main() {}
</code></pre>
<p>Done.</p>
<pre><code></code></pre>
The "right" fix is for the author to use a longer outer fence ( ````), but applications that render model output (chat UIs, coding-agent frontends) don't control the author. Several such projects currently work around it with a text pre-pass that detects nesting and lengthens the outer fences before parsing. We do the same, but a pre-pass is fragile:
- it has to re-implement container handling (list items, block quotes, lazy continuation) to know where a fence line really is, or give up on those cases;
- to stay safe it has to re-parse after each rewrite and check the block really ends where it expected.
Inside the block parser, the container context is already known, so the rule would be simple and exact there. As far as I can tell, it's a counter local to the fenced-block loop (see the last question below).
Proposed semantics (only when the option is enabled)
While inside a fenced code block opened with fence character c and length n:
- A line that would be a valid opening fence of the same character
c with length ≥ n and a non-empty info string increments a nesting depth. The line stays part of the code block's content.
- A line that would be a valid closing fence (same
c, length ≥ n, no info string):
- if depth > 0, decrements the depth, and the line stays content;
- if depth == 0, closes the block as today.
- If the block reaches the end of its container or the document with depth > 0, it ends there as it would today for an unclosed fence.
Notes:
- Inner fences with a different character, or shorter than
n, are ignored, exactly as today.
- A nested opener without an info string is indistinguishable from a closer, so it keeps today's behaviour. The heuristic only uses the one signal CommonMark already relies on: closing fences can't carry an info string.
- With the option off, output is unchanged and the spec tests are unaffected.
- The one known trade-off: a document that has a literal info-string fence line inside a code block, with no matching bare fence after it, would keep the block open until the end of its container. That is the same outcome as an unclosed fence today.
Questions for maintainers
- Is this in scope as an opt-in option, or would you prefer it to live outside the crate? There is no spec or prior art in other CommonMark parsers that I'm aware of, so it's a pragmatic extension rather than a standard one.
- If in scope, is this implementation shape what you'd expect? From reading
parse_fenced_code_block in firstpass.rs, it looks like a local depth counter in its body loop is enough:
- The container check at the top of each iteration already ends the block when a line leaves its list item or block quote. Containers therefore need no special handling.
- A line that
scan_code_fence accepts as the same character and length ≥ n, but scan_closing_code_fence rejects (because it has an info string), increments the depth.
- A closer at depth > 0 decrements it. Either way, the line falls through to
append_code_text.
- The one subtlety:
scan_closing_code_fence returns Some(0) on empty input, which is how the loop ends at end of input, so that case must still close regardless of depth.
- The new flag would be
ENABLE_NESTED_FENCES = 1 << 16.
I'm happy to write the PR with spec-style tests (including list, block-quote and unclosed cases) if you're open to it.
Would you accept an opt-in
Optionsflag (e.g.ENABLE_NESTED_FENCES, off by default) that pairs same-length fenced code blocks by nesting depth instead of closing at the first bare fence? I'd like to check the idea is in scope before writing a PR, per CONTRIBUTING.Markdown written by LLMs very often nests fenced blocks using the same fence length. A typical case is a plan or a README draft wrapped in
```markdownthat itself contains```rustsnippets. CommonMark correctly closes the outer block at the inner snippet's bare closing fence, which splits the document: the rest of the outer block renders as prose, and its real closing fence opens a stray empty block.With pulldown-cmark 0.13.4 and default options:
renders as:
The "right" fix is for the author to use a longer outer fence (
````), but applications that render model output (chat UIs, coding-agent frontends) don't control the author. Several such projects currently work around it with a text pre-pass that detects nesting and lengthens the outer fences before parsing. We do the same, but a pre-pass is fragile:Inside the block parser, the container context is already known, so the rule would be simple and exact there. As far as I can tell, it's a counter local to the fenced-block loop (see the last question below).
Proposed semantics (only when the option is enabled)
While inside a fenced code block opened with fence character
cand lengthn:cwith length ≥nand a non-empty info string increments a nesting depth. The line stays part of the code block's content.c, length ≥n, no info string):Notes:
n, are ignored, exactly as today.Questions for maintainers
parse_fenced_code_blockinfirstpass.rs, it looks like a local depth counter in its body loop is enough:scan_code_fenceaccepts as the same character and length ≥n, butscan_closing_code_fencerejects (because it has an info string), increments the depth.append_code_text.scan_closing_code_fencereturnsSome(0)on empty input, which is how the loop ends at end of input, so that case must still close regardless of depth.ENABLE_NESTED_FENCES = 1 << 16.I'm happy to write the PR with spec-style tests (including list, block-quote and unclosed cases) if you're open to it.