Skip to content

fix: allow a tab before the closing sequence of an ATX heading - #4084

Merged
UziTech merged 1 commit into
markedjs:masterfrom
giaBaoJS:fix/atx-heading-closing-sequence-tab
Sep 5, 2026
Merged

fix: allow a tab before the closing sequence of an ATX heading#4084
UziTech merged 1 commit into
markedjs:masterfrom
giaBaoJS:fix/atx-heading-closing-sequence-tab

Conversation

@giaBaoJS

@giaBaoJS giaBaoJS commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Marked version: 18.0.11 (master, df57534)

Markdown flavor: CommonMark

Description

No issue for this one.

src/Tokenizer.ts:141 decides whether a trailing run of # closes an ATX heading:

} else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) {
  // CommonMark requires space before trailing #s
  text = trimmed.trim();
}

endingSpaceChar is / $/ (src/rules.ts:50), so only U+0020 counts. A tab in that position does not, and the closing sequence stays in the heading text.

Expectation

<TAB> below stands for one literal tab character.

# foo<TAB>#
<h1>foo</h1>

Result

<h1>foo<TAB>#</h1>

Minimal repro: marked.parse('# foo\t#\n') returns '<h1>foo\t#</h1>\n'.

What was attempted

CommonMark 0.31.2, "ATX headings":

The opening sequence of # characters must be followed by spaces or tabs, or by the end of line. The optional closing sequence of #s must be preceded by spaces or tabs and may be followed by spaces or tabs only.

and again above example 75:

The closing sequence must be preceded by a space or tab:

(source: https://github.com/commonmark/commonmark-spec/blob/0.31.2/spec.txt, lines 1102 to 1104 and 1257)

marked already handles the other two halves of that sentence with tabs: the opening sequence uses (?=\s|$) in the heading rule, and "followed by spaces or tabs only" falls out of String.prototype.trim. Only "preceded by" was space-only.

endingSpaceChar cannot simply be widened, because its other caller is the code span normalization at src/Tokenizer.ts:848, and there the spec really does mean U+0020:

If the resulting string both begins and ends with a [space] character, but does not consist entirely of [space] characters, a single [space] character is removed from the front and back.

So the heading path gets its own endingSpaceTabChar rule and the code span path keeps endingSpaceChar.

Pedantic mode is untouched: it takes the earlier branch and strips trailing hashes regardless of what precedes them.

Blast radius

I generated 33,075 documents (3 opening sequences x 5 separators after it x 7 bodies x 7 separators before the closing sequence x 9 tails, over 5 wrappings: bare, in a blockquote, in a list item, after a paragraph, before a paragraph) and diffed master against this branch, using the commonmark 0.31.2 package already in devDependencies as the reference. Comparison ignores whitespace between tags.

agreement with commonmark 0.31.2
master 26,595 / 33,075 (80.41%)
this branch 33,075 / 33,075 (100%)

6,480 documents change output. All 6,480 go from disagreeing with the reference to matching it, and 0 go the other way.

CommonMark and GFM spec completion tables are identical before and after: no spec example covers a tab in this position, which is why the suite never caught it.

ReDoS

npm run test:redos covers the other rules, and the new one checks clean:

endingSpaceTabChar status = safe {"type":"safe","summary":"safe","isFuzz":false}

It is a single anchored character class with no quantifier.

Tests

One spec file in test/specs/new covering a tab before a one-hash and a multi-hash closing sequence, a hash that is part of the content followed by a tab and a real closing hash, a mixed space and tab run, and a control line (##### quux#) whose hash is not preceded by whitespace and must stay in the text. Its expected HTML is byte for byte what commonmark 0.31.2 produces.

On master the test fails with:

AssertionError [ERR_ASSERTION]: Expected: <h1>foo</h1><h2>bar</h2><h3>baz #</h3
  Actual: <h1>foo #</h1><h2>bar ###</h2><h3>baz

Local run: 1797 to 1799 spec tests, 191 unit tests, 0 failures. test:lint, test:types, test:umd and test:cjs pass.

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 0.31.2 says the optional closing sequence of #s must be
preceded by spaces or tabs, but the heading tokenizer tested for a
space only, so `# foo<tab>#` kept the trailing hashes as content.

The shared endingSpaceChar rule stays space only because code spans
need exactly that, so the heading path gets its own rule.
@vercel

vercel Bot commented Sep 4, 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 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
Image marked-website Ready Ready Preview Sep 4, 2026 3:06pm 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! 💯

@calculuschild calculuschild left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is endingSpaceChar used for anything now? If no we can remove that.

But otherwise looks good to me!

@UziTech

UziTech commented Sep 5, 2026

Copy link
Copy Markdown
Member

Ya it is still used in codespan

@UziTech
UziTech merged commit 4417582 into markedjs:master Sep 5, 2026
8 checks passed
github-actions Bot pushed a commit that referenced this pull request Sep 7, 2026
## [18.0.12](v18.0.11...v18.0.12) (2026-09-07)

### Bug Fixes

* allow a tab before the closing sequence of an ATX heading ([#4084](#4084)) ([4417582](4417582))
* allow one more level of nested brackets in a link label ([#4064](#4064)) ([37b28d8](37b28d8))
* do not add a newline to an empty code block ([#4073](#4073)) ([23b1706](23b1706))
* escape character references in autolink destinations ([#4053](#4053)) ([8f432f0](8f432f0))
* reject GFM email autolink when the domain ends in _ or - ([#4063](#4063)) ([df57534](df57534))
* reject invalid characters in HTML tag names ([#4083](#4083)) ([300bb1d](300bb1d))
* remove up to the fence indentation from each content line ([#4074](#4074)) ([0244f08](0244f08))
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.

3 participants