From 53e9fa7c7866eaf754d9df79e0c5c4657dad017e Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Mon, 5 Oct 2026 21:23:12 -0500 Subject: [PATCH 01/12] fix(deps): marked 18.1.0, which stops a link destination backtracking cubically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package-lock.json | 8 ++++---- package.json | 2 +- test/markdown-backtracking.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 test/markdown-backtracking.test.ts diff --git a/package-lock.json b/package-lock.json index ee8f01c..f9ed089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@nuxt/ui": "^4.11.2", "bcryptjs": "^3.0.3", - "marked": "^18.0.14", + "marked": "^18.1.0", "nuxt": "^4.5.2", "shiki": "^4.4.3", "vue": "^3.5.43", @@ -8956,9 +8956,9 @@ } }, "node_modules/marked": { - "version": "18.0.14", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz", - "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz", + "integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" diff --git a/package.json b/package.json index e889b23..42b1295 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dependencies": { "@nuxt/ui": "^4.11.2", "bcryptjs": "^3.0.3", - "marked": "^18.0.14", + "marked": "^18.1.0", "nuxt": "^4.5.2", "shiki": "^4.4.3", "vue": "^3.5.43", diff --git a/test/markdown-backtracking.test.ts b/test/markdown-backtracking.test.ts new file mode 100644 index 0000000..c57dc69 --- /dev/null +++ b/test/markdown-backtracking.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { renderMarkdown } from '~~/shared/markdown/render' + +/** + * The README renderer runs on the server's one thread, on markdown fetched + * from repos at request time. Before marked 18.1.0 a link destination opening + * on a run of unicode whitespace backtracked cubically + * (https://github.com/markedjs/marked/pull/4106): `[](` and 4 KB of U+00A0 + * took 10.3 s to render here, so one README edit could stall every page the + * process serves. 18.1.0 renders it in well under a millisecond. This holds + * the renderer to that, so a downgrade or a regression upstream fails CI + * rather than the site. + */ +describe('renderMarkdown backtracking', () => { + for (const space of [' ', ' ', ' ']) { + it(`renders a link destination of 8 KB of U+${space.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')} in linear time`, async () => { + const started = performance.now() + for (const kind of ['readme', 'post'] as const) { + await renderMarkdown('[](' + space.repeat(8192), kind) + } + expect(performance.now() - started).toBeLessThan(500) + }) + } +}) From fd89785e0530cd971b1b08825af6b5bb7211d221 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Mon, 5 Oct 2026 21:43:11 -0500 Subject: [PATCH 02/12] chore(a11y): axe-core 4.14, and its new default rule judged in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 3 ++- package-lock.json | 8 ++++---- package.json | 2 +- scripts/check-a11y-browser.mjs | 4 ++++ scripts/check-a11y.mjs | 1 + 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bbee90d..a3a5cd4 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,8 @@ npm run font # re-cut the preloaded core of Fira Code actually served. The rules that need a layout engine are named and skipped rather than silently failing — jsdom has none — so this is "every axe rule that can be judged from markup". `npm run a11y:browser` runs those skipped rules — -touch-target size, keyboard access to scrollable regions, the viewport meta — +touch-target size, keyboard access to scrollable regions, the viewport meta, +whether each control's visible label is part of its accessible name — in headless Chromium at a desktop and a phone width (set `CHROME_PATH` if Chromium is not on `PATH`), reading each page to the bottom so lazy content loads, and fails on any console error, uncaught exception or CSP refusal the diff --git a/package-lock.json b/package-lock.json index f9ed089..d364f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", - "axe-core": "^4.13.0", + "axe-core": "^4.14.0", "fontverter": "^2.0.0", "harfbuzzjs": "^1.6.2", "jsdom": "^30.1.2", @@ -5344,9 +5344,9 @@ } }, "node_modules/axe-core": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", - "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", "dev": true, "license": "MPL-2.0", "engines": { diff --git a/package.json b/package.json index 42b1295..9f49689 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.6.4", - "axe-core": "^4.13.0", + "axe-core": "^4.14.0", "fontverter": "^2.0.0", "harfbuzzjs": "^1.6.2", "jsdom": "^30.1.2", diff --git a/scripts/check-a11y-browser.mjs b/scripts/check-a11y-browser.mjs index 5ea7671..bda80ee 100644 --- a/scripts/check-a11y-browser.mjs +++ b/scripts/check-a11y-browser.mjs @@ -49,6 +49,10 @@ const RULES = [ 'scrollable-region-focusable', 'meta-viewport', 'meta-viewport-large', + // On by default from axe 4.14. It compares a control's accessible name with + // its *visible* text, and jsdom cannot say what is visible, so there it only + // ever came back undecided — on every copy button — and nothing judged it. + 'label-content-name-mismatch', ] const VIEWPORTS = [ diff --git a/scripts/check-a11y.mjs b/scripts/check-a11y.mjs index 96855d2..e507507 100644 --- a/scripts/check-a11y.mjs +++ b/scripts/check-a11y.mjs @@ -58,6 +58,7 @@ const NEEDS_LAYOUT = [ 'scrollable-region-focusable', 'meta-viewport', // judged against zoom behaviour a browser applies 'meta-viewport-large', + 'label-content-name-mismatch', // needs to know what text is visible ] const html = new Map() From 31d878e29608770ad620a5d63bdb3b5258a2d310 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Mon, 5 Oct 2026 21:54:30 -0500 Subject: [PATCH 03/12] fix(markdown): the README patterns run in linear time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, ` --- ROADMAP.md | 12 +++++++ shared/markdown/readme.ts | Bin 4756 -> 5654 bytes shared/markdown/slug.ts | 2 +- test/markdown-backtracking.test.ts | 54 +++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 765b607..778b956 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -764,6 +764,18 @@ record. There is no run log. +- [x] A README cannot stall the server. READMEs are fetched and rendered at + request time, on the server's one thread, so a pattern that backtracks + over one stalls every page the process serves. Two sources, both closed + 2026-10-05: marked 18.0.14's link-destination rule, cubic on a run of + unicode whitespace (4 KB of U+00A0 after `[](`: 10.3 s here; 0.1 ms on + 18.1.0), and seven of this site's own patterns in + `shared/markdown/readme.ts` and `slug.ts`, quadratic on input that opens + and never closes (100 KB of `[`: 5.2 s; a 100 KB heading of ` - [x] A real 404 check: every internal link, every render, on every route — `npm run check` - [x] …and the two XML documents nobody looks at, checked the same way. A feed breaks silently for every subscriber at once, and both documents carry diff --git a/shared/markdown/readme.ts b/shared/markdown/readme.ts index dee5e25abcded682a2070f58742f1b0974c104e2..da8833d5e78ce0cff8996a80ad45d6ea46562ade 100644 GIT binary patch delta 1000 zcmaJ=!EVz)5LH_tK@Z`KxV%GA;?#9Y1(!B}f*NsvawtVYG(p}ZYkL)YH{D(1Myd$^ zkcyAsgoK3n1rGcKU&4%2P`PkfuV!cFy?JkZdGAyBh*332)AGW4aE!*HL5X>lNlO$LnZ(Qfn-iu(DU~Vn2`P{gAwoJU#l#A) z#@9wvdREfl8L6Nd9^;%49e#bh#Qoh}Jbwz4AU=(0NHfL5Xb&!g(4?D2sq5@X&BG*{ zShNv$RgogbZu9^SEzXR)0&&Qq!ij>8Ow35@lh?;DP>97Gk|V2Xz7lzEv>LM&Z^c1)CDuqLhBQ+iiBLHiKzX*d z-ROJ{k+S^cT z-LIcFey-7{y7BF1yVJo5jnC@P0*uAXmzH;plwl%#ozbT3BVN93+&zj(sLfkc#Eky_ zL4W99+aQsXMD$0PGR@dJL!vN9rF;f$5C?Iz+-`IZd4yp*7|* zi`BA^Ny;cnO`BZLCb2n diff --git a/shared/markdown/slug.ts b/shared/markdown/slug.ts index 16f01bf..e0f767a 100644 --- a/shared/markdown/slug.ts +++ b/shared/markdown/slug.ts @@ -19,7 +19,7 @@ const PUNCTUATION = /[ -⸀-⹿\\'!"#$%&()*+,.\/:;<=>?@\[\]^`{|}~]/g export function slugify(text: string): string { return text - .replace(/<[^>]*>/g, '') + .replace(/<[^<>]*>/g, '') // not `[^>]*`: quadratic on a run of `<` .trim() .toLowerCase() .replace(PUNCTUATION, '') diff --git a/test/markdown-backtracking.test.ts b/test/markdown-backtracking.test.ts index c57dc69..e289c3c 100644 --- a/test/markdown-backtracking.test.ts +++ b/test/markdown-backtracking.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { renderMarkdown } from '~~/shared/markdown/render' +import { absolutize, lazyImages, stripLeadingLogo } from '~~/shared/markdown/readme' +import { slugify } from '~~/shared/markdown/slug' /** * The README renderer runs on the server's one thread, on markdown fetched @@ -22,3 +24,55 @@ describe('renderMarkdown backtracking', () => { }) } }) + +/** + * …and this site's own patterns over the same README. Each input below never + * closes what it opens, which is what made a pattern re-scan the rest of the + * README from every start: at 100 KB they took 0.4–5.2 s each before + * `shared/markdown/readme.ts` and `slug.ts` were made linear, and take about a + * millisecond after. + */ +describe('README preparation backtracking', () => { + const KB100 = (s: string) => s.repeat(Math.floor(100_000 / s.length)) + const cases: [string, () => unknown][] = [ + ['absolutize, a run of `![`', () => absolutize(KB100('!['), 'o', 'r', 'main')], + ['absolutize, a run of `[`', () => absolutize(KB100('['), 'o', 'r', 'main')], + ['absolutize, a run of `[a](`', () => absolutize(KB100('[a]('), 'o', 'r', 'main')], + ['absolutize, a run of ` absolutize(KB100(' absolutize(KB100(' stripLeadingLogo('![](' + KB100('logo'))], + ['lazyImages, a run of ` lazyImages(KB100(' slugify(KB100('<'))], + ] + for (const [name, run] of cases) { + it(name, () => { + const started = performance.now() + run() + expect(performance.now() - started).toBeLessThan(100) + }) + } + + it('renders a 100 KB heading of unclosed tags in linear time', async () => { + const started = performance.now() + await renderMarkdown('# ' + KB100(' { + const rewrite = (md: string) => absolutize(md, 'o', 'r', 'main') + + it('still rewrites a link or image whose URL ends the README', () => { + expect(rewrite('[docs](./docs.md')).toBe('[docs](https://github.com/o/r/blob/main/docs.md') + expect(rewrite('![x](shot.png')).toBe('![x](https://raw.githubusercontent.com/o/r/main/shot.png') + }) + + it('reads `![a [b](x)` as text and a link, the way marked renders it', () => { + expect(rewrite('![a [b](x.md)')).toBe('![a [b](https://github.com/o/r/blob/main/x.md)') + }) + + it('keeps a stray `<` out of the tag after it — GitHub slugs `a < bold` from the text "a < bold"', () => { + expect(slugify('a < bold')).toBe('a--bold') + expect(lazyImages('<')).toBe('<') + }) +}) From 9acb7373f312de654ae7cdc5cc79438489983c99 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Mon, 5 Oct 2026 22:05:55 -0500 Subject: [PATCH 04/12] fix(a11y): a copy button announces that it copied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing [copy] changed the visible text to [copied], but the button's accessible name is its aria-label ("Copy to clipboard: …", "Copy