fix: do not nest a link inside a link - #4051
Open
hdimer wants to merge 1 commit into
Open
Conversation
CommonMark: "Links may not contain other links, at any level of nesting." marked emitted nested <a> elements instead, which is invalid HTML. outputLink now rejects a link whose text tokenized into a link, so the lexer falls through to inlineText and the inner link is the one kept. Images stay exempt, since their text is flattened into an alt attribute. Fixes CommonMark examples 518, 519 and 532.
|
@hdimer is attempting to deploy a commit to the MarkedJS Team on Vercel. A member of the Team first needs to authorize it. |
hdimer
marked this pull request as ready for review
August 15, 2026 03:51
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Marked version:
18.0.9(currentmaster, 9552b6b)Markdown flavor: CommonMark|GitHub Flavored Markdown
Description
No existing issue for this one. It is the largest of the groups @luantaraschi catalogued in #4050 — or rather, half of it: "nested brackets in link text" is really two unrelated root causes, and this PR is the one that does not need a lookup table or a regex-depth change.
Expectation
CommonMark §6.3: "Links may not contain other links, at any level of nesting." When a link's text contains a link, the outer brackets are literal text and the inner link wins.
Both bundled reference implementations agree —
commonmark0.31.2 andmarkdown-it15 (already dev-dependencies here) return exactly that.Result
The output is nested
<a>elements. That is not merely off-spec, it is invalid HTML:<a>has no permitted<a>descendant, so an HTML parser closes the outer anchor at the inner one and re-parents the rest. The visible result in a browser is a link whose clickable region and destination are both wrong, and there is no workaround from the caller's side because the trigger is ordinary document text.Fixes CommonMark examples 518, 519, 532 (
shouldFailcleared in bothtest/specs/commonmark/andtest/specs/gfm/).What was attempted
outputLinkbuilds the token and lexes the label in one expression, so nothing ever looked at what the label turned into. The fix makes it look: if the label tokenized into a link, returnundefinedinstead of a link token.Lexer.inlineTokensalready guards both call sites withif (token = this.tokenizer.link(src))/reflink(...), and both tokenizers already declared| undefined, so the bail falls through toinlineText, which emits the[literally and re-scans. The inner link is then tokenized normally.Nesting is tracked with a
linkEmittedlexer state flag saved and restored around the recursiveinlineTokenscall — the same shapeblockquote()already uses forstate.top. A flag rather than a walk of the returned tokens, because the rule has to see throughem/strong(example 519 depends on it) while not firing on autolinks, and autolinks aretype: 'link'too.Three things I deliberately did not do:
[foo <https://example.com/>](/uri)still produces nested anchors, because that is what both reference implementations do. Pinned as a test so it cannot drift.altattribute, so](uri2)stays an image. Also pinned.state.inLinkstill resets unconditionally, exactly as before. Making it save/restore would change unrelated behaviour and is not needed here.Not fixed, same neighbourhood
Examples 512, 520, 528 stay red. They are the other root cause in that #4050 group:
_inlineLabeltolerates only one level of bracket nesting, so[link [foo [bar]]](/uri)never matches in the first place. Nothing to do with link-in-link, and worth splitting out.Example 533 (
[foo *bar [baz][ref]*][ref]) goes from<a href="/uri">foo <em>bar <a href="/uri">baz</a></em></a>to[foo *bar <a href="/uri">baz</a>*]<a href="/uri">ref</a>— the nested anchor is gone, but the<em>does not form, becausereflinkSearchmasked the whole span before emphasis ran and the mask is now stale. That is the area #4040 and #4048 are already in, so I left it alone rather than collide.Verification
Built
masterand the patch side by side and diffed their output againstcommonmark0.31.2.gfm:falseandgfm:true): 154 fixed, 0 broken, 19 changed-but-still-wrong — and all 19 are the example-533 masking shape above, where the patch is strictly closer to the reference thanmasterwas.<pre>/<code>/<script>/<kbd>and&/</"filler: 0 cases where the patch emits fewer HTML entities thanmaster. See the note below for why that oracle exists.test:specs(1779),test:unit(191),test:umd,test:cjs,test:typesandtest:lintall green. Same caveat others have reported:quadratic_emstrong_delim[0]intermittently blows its 1s budget on my laptop, but it does so on a clean unpatchedmasterbuild too, so it is this machine and not the patch._inlineLabel's one-level nesting limit caps the discard-and-rescan at ~2 frames; measured flat from depth 4 to 22.The
<pre>unit test is not padding. The discarded tokenization pass runstag()over the whole label, andtag()setsstate.inRawBlock. RestoringlinkEmittedalone left that set on the bail path, so the re-scan stampedescaped: trueon text preceding the raw-block opener and[a & b <pre> [x](/1)](/2)came out with a bare&. With a malformed tag in the label it turned text thatmasterescapes into live markup. Hencestate.inRawBlock = outerInRawBlockbefore thereturn, and an exact-string unit test — the spec harness normalizes entities through cheerio, so it cannot see this class of bug at all.Two things to flag rather than bury
Escaped brackets in a label.
outputLinkunescapescap[1]before lexing it (outputLinkReplace), so a label containing\[q\](/e)is handed to the lexer as[q](/e)and tokenizes as a link that the source never contained. The rejection path turns that latent mis-tokenization into visible damage in one shape:On 2,560 parses from a generator built specifically to be adversarial here, it is 764 improved against 14 regressed, and all 14 are that one shape — a reflink inside an image label. I tried two local fixes: lexing
cap[1]instead oftextswaps these 14 for 220 worse ones (raw backslashes leak intoalt), and gating ontext === cap[1]is inert because the unescaping is lossy one frame up. It is a label-handling defect, not a nesting one, and it belongs with 512/520/528. Happy to be told it blocks this PR.Stateful inline extensions fire twice on a rejected link, since the label is tokenized once for the probe and again on the re-scan. Extensions that count things (footnote numbering, ID generation) will double-count inside
[foo @@ [x](/1)](/2). Inherent to probe-then-rescan; flagging it because the extension ecosystem is the constituency that eats it.Type note:
stateis public inlib/marked.d.ts, solinkEmittedis an additive field. Extensions that read state are unaffected; anything assigning a whole state object literal would need the new key.Contributor
Ticket type: L1 - broken by the CONTRIBUTING table, I think — wrong output against a supported spec, and no caller-side workaround. Happy to be corrected to L2.
Committer
In most cases, this should be a different person than the contributor.
Used AI assistance on this; I reviewed and tested the change myself.