Skip to content

fix: do not nest a link inside a link - #4051

Open
hdimer wants to merge 1 commit into
markedjs:masterfrom
hdimer:fix-link-in-link-text
Open

fix: do not nest a link inside a link#4051
hdimer wants to merge 1 commit into
markedjs:masterfrom
hdimer:fix-link-in-link-text

Conversation

@hdimer

@hdimer hdimer commented Aug 15, 2026

Copy link
Copy Markdown

Marked version: 18.0.9 (current master, 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.

<p>[foo <a href="/uri">bar</a>](/uri)</p>

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

Result

marked.parse('[foo [bar](/uri)](/uri)');
// <p><a href="/uri">foo <a href="/uri">bar</a></a></p>

marked.parse('[foo [bar](/uri)][ref]\n\n[ref]: /uri');
// <p><a href="/uri">foo <a href="/uri">bar</a></a></p>

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 (shouldFail cleared in both test/specs/commonmark/ and test/specs/gfm/).

What was attempted

outputLink builds 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, return undefined instead of a link token. Lexer.inlineTokens already guards both call sites with if (token = this.tokenizer.link(src)) / reflink(...), and both tokenizers already declared | undefined, so the bail falls through to inlineText, which emits the [ literally and re-scans. The inner link is then tokenized normally.

Nesting is tracked with a linkEmitted lexer state flag saved and restored around the recursive inlineTokens call — the same shape blockquote() already uses for state.top. A flag rather than a walk of the returned tokens, because the rule has to see through em/strong (example 519 depends on it) while not firing on autolinks, and autolinks are type: 'link' too.

Three things I deliberately did not do:

  • Autolinks do not trigger the rule. [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.
  • Images are exempt — their text is flattened into an alt attribute, so ![[foo](uri1)](uri2) stays an image. Also pinned.
  • state.inLink still 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: _inlineLabel tolerates 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, because reflinkSearch masked 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 master and the patch side by side and diffed their output against commonmark 0.31.2.

  • 40,256 parses (structured nested-link templates × 8 wrappers × 2 depths, plus 20,000 pseudo-random documents over an alphabet of link/reflink/image/autolink/emphasis/code/bracket atoms, each under gfm:false and gfm: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 than master was.
  • Escaping oracle, 7,056 parses over link texts seeded with <pre>/<code>/<script>/<kbd> and &/</" filler: 0 cases where the patch emits fewer HTML entities than master. See the note below for why that oracle exists.
  • test:specs (1779), test:unit (191), test:umd, test:cjs, test:types and test:lint all 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 unpatched master build too, so it is this machine and not the patch.
  • Perf: nested-link-heavy input 1.06–1.13×, plain links within noise, pathological nested reflinks ~1.5× (the rejected label is tokenized once and thrown away). No blowup with depth — _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 runs tag() over the whole label, and tag() sets state.inRawBlock. Restoring linkEmitted alone left that set on the bail path, so the re-scan stamped escaped: true on 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 that master escapes into live markup. Hence state.inRawBlock = outerInRawBlock before the return, 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. outputLink unescapes cap[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:

![[\[q\](/e)][ref]](/i)   with   [ref]: /r
  master:  <img src="/i" alt="[q](/e)">   (matches commonmark)
  patched: <img src="/i" alt="[q]ref">

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 of text swaps these 14 for 220 worse ones (raw backslashes leak into alt), and gating on text === 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: state is public in lib/marked.d.ts, so linkEmitted is an additive field. Extensions that read state are unaffected; anything assigning a whole state object literal would need the new key.

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 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.

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.
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@hdimer
hdimer marked this pull request as ready for review August 15, 2026 03:51
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
marked-website Ready Ready Preview Aug 15, 2026 5:12am

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! 💯

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