Skip to content

fix: keep the em/strong mask the same length as the source - #4044

Open
Jaybhade wants to merge 1 commit into
markedjs:masterfrom
Jaybhade:fix/em-mask-length-astral-escape
Open

fix: keep the em/strong mask the same length as the source#4044
Jaybhade wants to merge 1 commit into
markedjs:masterfrom
Jaybhade:fix/em-mask-length-astral-escape

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 4, 2026

Copy link
Copy Markdown

Marked version: 18.0.9 (current master, 61a9442)

Markdown flavor: CommonMark|GitHub Flavored Markdown

Description

No existing issue that I could find, so here it is in the template's format.

Expectation

A backslash followed by a non-ASCII-punctuation character is literal text, and it should
not affect the emphasis around it:

<p><em>a</em>\🙂</p>

Both reference implementations agree — commonmark 0.31.2 and markdown-it 15 (both
already dev-dependencies here) return exactly that.

Result

marked.parse('*a*\\🙂');        // <p><em>a*</em>🙂</p>
marked.parse('*a* \\🙂 *b*');   // <p><em>a*</em>\🙂 <em>b</em></p>
marked.parse('x*ab*\\🙂y');     // <p>x<em>ab*</em>🙂y</p>
marked.parse('*ab*ab\\🙂*ab*'); // <p><em>ab*</em>b\🙂<em>ab</em></p>  ← an 'a' is gone

The closing delimiter is consumed one code unit late, so a stray * lands inside the
emphasis, the backslash is swallowed, and in the last case a character of the document is
silently deleted.

Any of the 4887 code points in \p{P}/\p{S} above U+FFFF triggers it — every
emoji is \p{So}, so \🙂, \💩, \🚀 all do, as do astral punctuation like
U+10100 𐄀.

What was attempted

Lexer.inlineTokens masks the source once and hands the mask to emStrong/del, which
align the two strings by slicing the mask from the end:

// Clip maskedSrc to same section of string as src (move to lexer?)
maskedSrc = maskedSrc.slice(-1 * src.length + lLength);

That is only correct while maskedSrc.length === src.length, and every mask is written to
hold that invariant — reflinkSearch and blockSkip both pad with 'a'.repeat(...)
computed from the match length, and the emStrongMask hook example in
docs/USING_PRO.md teaches extension authors to do the same:

src.replace(/\$([^$]+)\$/g, (match) => `[${'a'.repeat(match.length - 2)}]`);

The escape mask is the one that doesn't. It substitutes a fixed two-character '++':

maskedSrc = maskedSrc.replace(this.tokenizer.rules.inline.anyPunctuation, '++');

anyPunctuation is /\\([\p{P}\p{S}])/gu, and with the u flag that class matches an
astral code point — three UTF-16 code units, replaced by two. The mask then ends up
shorter than the source, slice(-1 * src.length + lLength) reaches one character too far
back, and the match.index it produces is applied straight to src:

const raw = src.slice(0, lLength + match.index + lastCharLength + rLength);

Fixed by making the substitution as long as what it replaces. + is punctuation, so the
delimiter-flanking classification of the masked region is unchanged, and for the ASCII
case '+'.repeat(2) === '++' — byte-identical to today.

This is not a regression from #4017; the fixed-width '++' dates back to the emphasis
rework in #1864, and anyPunctuation became Unicode-aware in #2841.

Verification

Built master and the patch side by side and diffed their output.

  • 61,134 generated inputs (delimiter × filler × escape templates, plus 60,000 pseudo-random
    strings over an alphabet of * _ ~ \ ` [ ] ( ) a, space and two astral symbols):
    303 outputs differ, and in 303/303 the patched build matches the
    commonmark + markdown-it consensus. 0 cases where master was right and the patch broke it.
  • Every astral \p{P}/\p{S} code point (4887 of them) in four templates × both gfm
    modes = 39,096 cases: master deviates from the reference in 34,209, the patch in 0.
  • BMP punctuation is untouched: every non-ASCII \p{P}/\p{S} code point below U+FFFF
    in the same templates produces byte-identical output before and after — the change only
    reaches the astral case.

test/specs/new/em_escaped_astral_punctuation.md covers em, strong, del, and the
inside-the-span case. It fails on unpatched lib/ and passes with the patch.

test:specs (1775), test:unit (190), test:umd, test:cjs, test:types and
test:lint are all green locally, with one caveat I should be upfront about: three of the
quadratic_* timing specs (quadratic_emstrong_delim[0], quadratic_inline_masking[1]
and [2]) exceed their budget on my machine — but they do so on a clean unpatched
master checkout too, so it is this laptop and not the patch. quadratic_inline_masking[0],
which is the 100k-escape input that exercises exactly the line I changed, passes in both.
I also benchmarked the two builds against all four quadratic_* masking inputs plus a
50k-astral-escape input, and the patched build is within noise of master on every one
(the callback is invoked once per escape, but String.prototype.replace was already
building a new string either way).

One thing I deliberately left alone: anyPunctuation masks \ + any Unicode
punctuation, while the escape tokenizer only consumes \ + ASCII punctuation, so
sequences that are not escapes at all still get masked. That is a pre-existing behavioural
question, length-preserving today, and changing it would alter output for BMP punctuation —
happy to look at it separately if you think it's worth it.

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.

Ticket type: L1 - broken by the table in CONTRIBUTING, I think — the output is wrong
against both supported specs and there is no workaround from the caller's side, since the
trigger is ordinary document text. Happy to be corrected to L2.

Committer

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

emStrong and del align maskedSrc with src by slicing the mask from the
end, so every mask must keep the length of the text it replaces. The
escape mask substituted a fixed two-character '++', but anyPunctuation is
/\\([\p{P}\p{S}])/gu and matches astral code points, so an escaped
character above U+FFFF is three code units masked as two.

The mask then ended up shorter than the source, the clip reached one code
unit too far back, and the resulting match.index was applied to src: the
closing delimiter was consumed a code unit late, leaving a stray
delimiter inside the emphasis and, for some inputs, dropping a character
of the document.

Substitute one '+' per code unit instead. '+' is punctuation, so the
delimiter flanking of the masked region is unchanged, and the ASCII case
still produces '++'.
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

@jayesh-keychain is attempting to deploy a commit to the MarkedJS Team on Vercel.

A member of the Team first needs to authorize it.

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