Skip to content

fix: allow indented lines in setext heading text - #4095

Merged
UziTech merged 1 commit into
markedjs:masterfrom
giaBaoJS:fix/setext-indented-continuation
Sep 21, 2026
Merged

UziTech merged 1 commit into
markedjs:masterfrom
giaBaoJS:fix/setext-indented-continuation

Conversation

@giaBaoJS

Copy link
Copy Markdown
Contributor

Marked version: 18.0.13 (634f810)

Markdown flavor: CommonMark

Description

The setext heading rule refuses to let the heading text span a line that looks like an indented code block, and it lets a thematic break line be swallowed into the heading text. Both come from the same list of interrupters in lheadingCore.

Expectation

CommonMark 0.31.2, section 4.3 Setext headings:

A setext heading consists of one or more lines of text, not interrupted by a blank line, of which the first line does not have more than 3 spaces of indentation, followed by a setext heading underline. The lines of text must be such that, were they not followed by the setext heading underline, they would be interpreted as a paragraph: they cannot be interpretable as a code fence, ATX heading, block quote, thematic break, list item, or HTML block.

Indented code is deliberately absent from that list, because (4.4 Indented code blocks) an indented code block cannot interrupt a paragraph, and (4.8 Paragraphs) "lines after the first may be indented any amount, since indented code blocks cannot interrupt paragraphs". Thematic break is on the list, but was missing from the rule.

src/Lexer.ts already says the same thing in words for paragraphs (// An indented code block cannot interrupt a paragraph.), and _paragraph correctly omits indented code from its own continuation interrupters.

Result

commonmark@0.31.2 (already a devDependency here) is the reference:

input marked 18.0.13 commonmark 0.31.2
a\n b\n=== <p>a\n b\n===</p> <h1>a\nb</h1>
a\n b\n--- <p>a\n b</p>\n<hr> <h2>a\nb</h2>
a\n\tb\n=== <p>a\n\tb\n===</p> <h1>a\nb</h1>
a\n***\n=== <h1>a\n***</h1> <p>a</p>\n<hr>\n<p>===</p>

What was attempted

src/rules.ts:115 uses one placeholder list in two positions:

const lheadingCore = /^(?!bull |blockCode|fences|...)((?:.|\n(?!\s*?\n|bull |blockCode|fences|...))+?)\n {0,3}(=+|-+) *(?:\n+|$)/;

The leading (?!...) guard is correct and stays: at the start of a block an indented line really does open a code block ( Foo\n=== is a code block plus a paragraph, and marked already gets that right). Only the continuation lookahead is wrong, so blockCode is dropped from it and hr is added, leaving the two positions with the lists the spec describes.

The hr half is not optional. Without it, allowing indented continuation lines exposes the pre-existing gap for thematic breaks on many more inputs, and the change is a net regression; with it, the differential fuzz below is clean.

Measurements

Suites, before and after (node --test):

suite before after
test:specs 1819 pass / 0 fail / 0 skip 1823 pass / 0 fail / 0 skip
test:unit 191 pass / 0 fail / 0 skip 191 pass / 0 fail / 0 skip

The 4 added spec tests are the two new fixture pairs (gfm and gfm: false).

Conformance, measured by running every example in test/specs/commonmark and test/specs/gfm through marked and comparing with the same html-differ the harness uses, rather than reading the completion table (which counts shouldFail annotations and is static):

suite before after
CommonMark 0.31.2 (gfm: false) 649/652 = 99.54% 649/652 = 99.54%
CommonMark 0.31.2 (gfm: true) 645/652 = 98.93% 645/652 = 98.93%
GFM extensions 24/28 = 85.71% 24/28 = 85.71%

Unchanged: no example in the CommonMark suite covers a setext heading with an indented continuation line, which is why this went unnoticed. The same failing examples fail before and after (32, 33, 503, and 602/608/611/612 under gfm).

Differential fuzz over 220,000 generated block-structure documents (random line sequences drawn from indented lines, tabs, underlines, fences, bullets, quotes, ATX headings, thematic breaks, table rows, html tags), comparing old marked / new marked / commonmark@0.31.2 with the harness's html-differ, across 5 seeds and both gfm: false and gfm: true:

seed A gfm:false  60000 docs  1173 fixed  0 regressions
seed B gfm:false  40000 docs   839 fixed  0 regressions
seed C gfm:false  40000 docs   791 fixed  0 regressions
seed D gfm:true   40000 docs   694 fixed  0 regressions
seed E gfm:true   40000 docs   698 fixed  0 regressions

The handful of remaining documents where both versions differ from the reference differ only on unrelated pre-existing points (link reference definitions, <br> formatting).

Two things worth flagging:

  • marked keeps the continuation line's leading indentation inside the heading text (<h1>a\n b</h1> where the reference emits <h1>a\nb</h1>). That is pre-existing and identical to what marked already does for paragraph continuation lines (<p>a\n b</p>), it is whitespace-insignificant in the rendered HTML, and the spec suite's differ treats the two as equal. The fixtures are therefore written without renderExact. Happy to fix the whitespace separately if you want it.
  • blockPedantic.paragraph embeds lheading in its continuation lookahead, so pedantic mode inherits the change. test/specs/original (pedantic) stays 100% green.

recheck reports block.normal.lheading, block.gfm.lheading and block.pedantic.lheading as safe after the change.

Contributor

  • Test(s) exist to ensure functionality and minimize regression (if no tests added, list tests covering this PR); or,
  • no tests required for this PR.
  • If submitting new feature, it has been documented in the appropriate places.

Committer

In most cases, this should be a different person than the contributor.

CommonMark says the text of a setext heading is what "would be
interpreted as a paragraph", and lists the blocks that disqualify a
line: code fence, ATX heading, block quote, thematic break, list item,
HTML block. Indented code is not on that list, because it cannot
interrupt a paragraph; thematic break is on it but was missing from the
rule.

So `a\n    b\n===` produced `<p>a\n    b\n===</p>` instead of an `<h1>`,
and `a\n***\n===` swallowed the thematic break into the heading.

Drop blockCode from the continuation lookahead in lheadingCore (the
leading guard still needs it, since an indented line does start a code
block there) and add hr, so the list matches the specification.
@vercel

vercel Bot commented Sep 13, 2026

Copy link
Copy Markdown

@giaBaoJS is attempting to deploy a commit to the MarkedJS Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Sep 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marked-website Ready Ready Preview Sep 13, 2026 3:10pm UTC

Request Review

@UziTech UziTech left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! 💯

@UziTech
UziTech merged commit 7d05530 into markedjs:master Sep 21, 2026
8 checks passed

This branch was successfully deployed

1 active deployment
Preview d3480809 Deployed Sep 13, 2026 by vercel[bot]
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