Skip to content

fix: avoid cubic backtracking on unicode whitespace in a link destination - #4106

Merged
UziTech merged 1 commit into
markedjs:masterfrom
spokodev:fix/link-gap-unicode-whitespace
Oct 5, 2026
Merged

UziTech merged 1 commit into
markedjs:masterfrom
spokodev:fix/link-gap-unicode-whitespace

Conversation

@spokodev

@spokodev spokodev commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Marked version: 18.0.14 (master, ef0704c)

Markdown flavor: CommonMark

Description

Follow-up to your note on #4070 — here's the fix.

The gap before a link destination was \s*, which matches unicode whitespace. The destination class next to it, [^ \t\n\x00-\x1f]+, excludes only ASCII space, tab, newline and the C0 controls. A run of U+00A0 (or U+2003, U+202F, U+2028, U+FEFF …) matches both, so it can be divided between them in n ways, and the anchored rule is retried at every offset.

marked.parse('[](' + ' '.repeat(n)), default options, Node 26. Each point is the minimum of several runs, taken with a fixed reference workload in the same process and accepted only within 8% of its idle baseline; a second sweep reproduced every row within 10%.

input master this PR
250 B 5 ms 0.02 ms
500 B 33 ms 0.01 ms
1 KB 253 ms 0.02 ms
2 KB 2.0 s 0.05 ms
4 KB 16.1 s 0.08 ms
8 KB 125 s 0.18 ms

×6.3, ×7.6, ×7.9, ×8.0, ×7.7 per doubling on master; flat here. 8.6 KB of ordinary prose parses in 0.14 ms on both builds.

test/specs/redos/quadratic_link_empty_href.cjs is '[](' + ' '.repeat(50000). The new guard is that input with the space replaced by U+00A0: master does not finish it inside 120 s, this branch renders it in 10 ms.

The change

\s* → [ \t\n]*, both occurrences in the link rule. Changing only one of them takes the growth from ×7.2 to ×3.3 (leading gap) or ×3.9 (trailing gap) — better, but still super-linear. Both together make it flat.

Narrowing the other side instead — [^\s\x00-\x1f]+ for the destination, which is what the neighbouring autolink rule already uses — also flattens it (×1.6), but it makes [a](a b) stop being a link, and the reference implementation accepts that destination. So I left the destination alone.

What it changes

Nothing unless unicode whitespace sits in one of the two gaps:

  • 14,651 generated link forms with no unicode whitespace × 5 option sets ({}, gfm, no-gfm, pedantic, breaks) — 0 rendered differences
  • unicode whitespace inside the destination, 48 forms — 0 differences
  • 765 README/CHANGELOG files from npm, including the 23 that contain unicode whitespace — 0 differences

Where it does change, commonmark@0.31.2 never sides with master. Over the gap grid, 464 forms render differently; the reference agrees with this branch in 208 of them and with master in 0. The rest are cases where marked and the reference already disagreed for unrelated reasons, such as ' escaping.

[a]( /p "t" )        master: <a href="/p" title="t">
                          this PR and reference: not a link

[a]( /p "t" )   master: <a href="/p" title="t">
                          this PR and reference: not a link

[a](  "t" )          master:    <a href="%22t%22">      (the title is taken as the destination)
                          this PR:   <a href="" title="t">
                          reference: <a href="%C2%A0" title="t">

The third is an honest "neither" case: all three differ, and this branch is the closer of the two to the reference.

Test suite

test:specs 1863/1863, test:unit 192/192, test:types, test:umd, test:cjs, eslint clean.

Contributor

  • Test(s) exist to ensure functionality and minimize regression — test/specs/redos/cubic_link_unicode_space.cjs
  • no tests required for this PR.
  • If submitting new feature, it has been documented in the appropriate places.

@vercel

vercel Bot commented Sep 21, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@spokodev
spokodev force-pushed the fix/link-gap-unicode-whitespace branch from 813f869 to fb3532a Compare September 22, 2026 20:45
…tion

The gap before a link destination was \s*, which matches unicode whitespace,
while the destination class only excluded ASCII space, tab and newline. A run
of U+00A0 or its relatives could therefore be divided between the two in n
ways, and the anchored rule is retried at every offset, so the cost grows by
roughly a factor of eight for every doubling of the run.

CommonMark defines that gap as spaces or tabs plus up to one line ending, so
narrowing it to [ \t\n]* is what the spec asks for. It also settles the split:
the reference implementation treats unicode whitespace there as part of the
destination, which is what marked now does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@spokodev
spokodev force-pushed the fix/link-gap-unicode-whitespace branch from fb3532a to dc20a38 Compare September 22, 2026 23:50

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

Rebase on master to get tests to pass.

edit: Sorry didn't see you already did.

@vercel

vercel Bot commented Sep 24, 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 24, 2026 1:47pm 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 0d20220 into markedjs:master Oct 5, 2026
8 checks passed
github-actions Bot pushed a commit that referenced this pull request Oct 5, 2026
# [18.1.0](v18.0.14...v18.1.0) (2026-10-05)

### Bug Fixes

* avoid cubic backtracking on unicode whitespace in a link destination ([#4106](#4106)) ([0d20220](0d20220))
* load CLI configs with top-level await ([#4100](#4100)) ([c61543e](c61543e))
* reject unbalanced parentheses in link destinations ([#4107](#4107)) ([3363c99](3363c99))

### Features

* add linkParenPossible to lexer state to fail fast for link token generation ([#4070](#4070)) ([4ee44f7](4ee44f7))
physics515 added a commit to basic-automation/basicautomation.io that referenced this pull request Oct 8, 2026
… buttons that speak and fail loudly, client-bundle audit, header hardening, 404s that say who asked (#15)

* fix(deps): marked 18.1.0, which stops a link destination backtracking cubically

marked 18.0.14 matched the gap before a link destination with `\s*`, which
overlaps the destination class on unicode whitespace, so `[](` followed by a
run of U+00A0 backtracked cubically (markedjs/marked#4106). Through this site's
own renderMarkdown, 4 KB of it took 10.3 s, all on the server's one thread —
one README edit in any of the seven repos could stall every page.
On 18.1.0 the same input renders in 0.1 ms.

All 87 documents the site renders today (7 READMEs, the 76 live posts, the
4 test fixtures) come out byte-identical on both versions.

test/markdown-backtracking.test.ts renders 8 KB of three unicode spaces
through both the README and post paths in under 500 ms; on 18.0.14 it does
not finish inside 60 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(a11y): axe-core 4.14, and its new default rule judged in the browser

axe-core 4.14.0 turns `label-content-name-mismatch` on by default (WCAG 2.5.3,
label in name). It compares a control's accessible name with its visible
text, which jsdom cannot determine, so in `npm run a11y` it only ever came back
undecided — on the copy buttons of every project page — and no pass judged it.

It moves to the layout list: skipped by name in scripts/check-a11y.mjs, run
in scripts/check-a11y-browser.mjs at 1280 and 390 px. Today's pages pass
("[copy]" sits at the start of "Copy …"). Proved it bites: with CodeBlock's
label planted as "Download …", the browser pass fails with 12 violations,
one per project page per width.

Both passes clean over 95 pages, including the 76 live posts. README
names the new rule beside the other browser-only ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(markdown): the README patterns run in linear time

READMEs are fetched and prepared at request time on the server's one
thread. Seven patterns in shared/markdown/readme.ts and slug.ts were
quadratic on input that opens something and never closes it: a global
regex is retried at every offset, and each of these could scan from one
start past where the next one began. At 100 KB:

  absolutize, a run of `[`            5.2 s   → 0.5 ms
  absolutize, a run of `![`           2.7 s   → 0.5 ms
  absolutize, a run of `[a](`         1.9 s   → 0.2 ms
  absolutize / lazyImages, `<img `×   0.4/1.0 s → 0.2 ms
  stripLeadingLogo, logo URL, no `)`  1.0 s   → 0.2 ms
  slugify, a run of `<`               5.0 s   → 0.9 ms
  renderMarkdown, 100 KB heading      2.5 s   → 34 ms

A link label now stops at the next `[`, a tag at the next `<`, a URL may
end at the end of the input, and the logo test moved out of the pattern
into a callback. All 7 READMEs (prepared markdown and HTML) and the 76
live posts are byte-identical before and after. The edges that move are
pinned in the test: `![a [b](x)` is now read as text and a link, the way
marked renders it, and a stray `<` before a tag stays out of it, which
is what GitHub's slug of that heading is made from.

test/markdown-backtracking.test.ts times each input: 11 cases, every one
failing on the old patterns. ROADMAP gains the item, with the marked fix
from the previous commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(a11y): a copy button announces that it copied

Pressing [copy] changed the visible text to [copied], but the button's
accessible name is its aria-label ("Copy to clipboard: …", "Copy <label>"),
so a screen reader announced nothing (WCAG 4.1.3, status messages).

app/components/CodeLine.vue and CodeBlock.vue each render an empty
`role="status"` sr-only region on the server, so it exists before it
changes, and it reads "Copied to clipboard" for the same 1.6 s as the
visible label. CodeBlock's sits beside its figcaption, not in it, so it
is never part of the figure's name.

Checked in headless Chromium over CDP with clipboard permission: after
activation the polite live region's text in the accessibility tree is
"Copied to clipboard", the clipboard holds the command (and the
config.toml block for CodeBlock), and both reset after 1.6 s. Pages are
unchanged to the eye: the pixel difference against the previous build is
in the masthead's animated glass only, and the same size as between two
earlier builds. check, a11y and a11y:browser clean over 95 pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(headers): drop x-powered-by, add Cross-Origin-Opener-Policy

Every live response carried `x-powered-by: Nuxt`. Nuxt's renderer, payload
and island handlers set it themselves after route rules apply, so no rule
can take it off; server/plugins/powered-by.ts removes it in Nitro's
`beforeResponse`, which runs after any handler. It tells a scanner which
framework's advisories to try first and nothing else.

`Cross-Origin-Opener-Policy: same-origin` joins the `/**` headers in
nuxt.config.ts: no page keeps a handle on a window it opens, and a
cross-origin page that opens this site gets none into it.

scripts/check-site.mjs fails any response with x-powered-by and any page
without the policy. Against the previous build: 94 failures for each.
After: gone from pages, the 404 page, /healthz, /api, the XML routes,
static files and HEAD; check, a11y and a11y:browser clean over 95 pages;
routes unchanged; client navigation home → onyums → its blog in the
browser with no console errors; screenshots differ only in the
masthead's animated glass, as between any two builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(copy): a refused clipboard selects the text and says so

When `navigator.clipboard.writeText` was refused (denied permission,
insecure context, a frame that forbids it) the copy buttons did nothing:
still `[copy]`, nothing announced, and the visitor pasted whatever their
clipboard held before. Found when the built-in browser pane refused it
while the previous commit was being tested.

app/composables/useCopy.ts is now the one copy of the logic CodeLine and
CodeBlock each carried. On refusal it selects the code element's
contents, so the platform's copy shortcut finishes the job, shows
`[selected]` for 4 s and announces "Could not copy. The text is
selected; copy it with your keyboard." in the status region.

Checked in headless Chromium over CDP:
- permission granted: `[copied]`, "Copied to clipboard", the clipboard
  holds the command / the config.toml block, reset after 1.6 s
- permission denied (`NotAllowedError`): `[selected]`, the message
  above, the selection is exactly the command without its `$` sigil
  (and the whole config.toml block), reset after 4 s, no overflow at
  390 px (screenshot in the run's scratch dir)
check, a11y, a11y:browser clean over 95 pages; 226 tests; routes
unchanged; page screenshots unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(roadmap): Nuxt 4.6, trialled and deliberately not landed yet

Nuxt 4.6.0 was published an hour into tonight's run. A trial in a
scratch worktree was clean on every gate (typecheck, 226 tests, build,
audit:runtime, check, a11y, a11y:browser; 23 routes as on 4.5.2;
byte-identical page sizes; one JSON log line per request) and changes
nothing visible: one CSS line height rounds to 16 px instead of 15.98.
It stays a `[ ]` with that evidence: a 420-commit minor with a CLI
major, an hour old, should not go out in an unattended deploy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(log): a failed request's line says who asked

The request log has two writers: `afterResponse` for answered requests,
and the `error` hook for requests that threw, every 404 and 500. Its
comment promised "same shape as the line above", but the error line
stopped at `via`: no `ip`, `ua` or `ref`. In fifteen hours of live log
that was 627 lines, nearly all scanners probing `.env`, `.php` and
`.git`, and the only lines with no address or user agent.

server/plugins/request-log.ts builds both from one `requester(event)`.
Checked on a built server with a forwarded address, user agent and
referer: a 200, a page 404, an API 404 and a HEAD 404 all carry the
three fields; an onion-marked request keeps `ip: null` and `via: onion`;
each request is still exactly one line. Gate: typecheck, 226 tests,
build, check, a11y, a11y:browser; routes and pages unchanged. README
says the fields are on every line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(audit): audit:runtime covers the client bundle too

The runtime audit asked the registry about what the server ships and
said plainly that the client bundle was not covered: it is built without
sourcemaps (rightly; they would be served), so there was nothing to read
its packages from.

scripts/lib/client-packages.ts is a Vite plugin applied only to the
client environment. In generateBundle it maps every module with rendered
code to its package and that package's own version, and a Nitro
`compiled` hook in nuxt.config.ts writes the list to
.output/server/client-packages.json: inside the image, never under
public/, and a 404 from the server at every spelling tried.
scripts/audit-runtime.mjs adds those packages to its registry query
and fails when the file is missing or empty, rather than passing with
the browser half unasked.

Today: 32 client packages, 14 of which the server audit never saw;
79 asked in all; clean. Proved it bites: a planted marked@4.0.9 fails
with its two advisories; a missing and an empty inventory both fail.
Gate: typecheck, 226 tests, build, check, a11y, a11y:browser (95 pages,
both widths); routes and pages unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(readme): audit:runtime covers the client bundle

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(data): refresh the fallback snapshot

npm run sync, authenticated, all 8 projects including splimes: issue
counts and one push date moved. test/snapshot.test.ts passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(roadmap): the linear-patterns item counts the <a href> rule from the splimes merge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — dc20a386 Deployed Sep 24, 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