Public: anyone can read this; the crew can change it.
Every in-page link in the README lands on a heading that exists, and a test keeps it so #227
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/215-every-in-page-link-in-the-readme-lands-o → main 4 commits fork 3 threads · 3 unresolved
1 file
changed +55
−0
src/readme.test.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { readFileSync } from "node:fs"; | |
| 3 | ||
| 4 | // GitHub-style heading slug: lowercase, punctuation dropped, spaces to hyphens. | |
| 5 | function slug(heading: string): string { | |
| 6 | return heading | |
| 7 | .trim() | |
| 8 | .toLowerCase() | |
| 9 | .replace(/[^\p{L}\p{N}\s_-]/gu, "") | |
| 10 | .replace(/\s/g, "-"); | |
| 11 | } | |
| 12 | ||
| 13 | function anchors(markdown: string): Set<string> { | |
| 14 | const seen = new Map<string, number>(); | |
| 15 | const out = new Set<string>(); | |
| 16 | let fenced = false; | |
| 17 | for (const line of markdown.split("\n")) { | |
| 18 | if (/^\s*(```|~~~)/.test(line)) fenced = !fenced; | |
| 19 | if (fenced) continue; | |
| 20 | const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line); | |
| 21 | if (!m) continue; | |
| 22 | const base = slug(m[1]!.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*]/g, "")); | |
| 23 | const n = seen.get(base) ?? 0; | |
| 24 | seen.set(base, n + 1); | |
| 25 | out.add(n === 0 ? base : `${base}-${n}`); | |
| 26 | } | |
| 27 | return out; | |
| 28 | } | |
| 29 | ||
| 30 | export function brokenAnchors(markdown: string): string[] { | |
| 31 | const have = anchors(markdown); | |
| 32 | const broken: string[] = []; | |
| 33 | for (const m of markdown.matchAll(/\]\(#([^)\s]*)\)/g)) { | |
| 34 | let a: string; | |
| 35 | try { | |
| 36 | a = decodeURIComponent(m[1]!).toLowerCase(); | |
| 37 | } catch { | |
| 38 | a = m[1]!.toLowerCase(); | |
| 39 | } | |
| 40 | if (!have.has(a) && !broken.includes(a)) broken.push(a); | |
| 41 | } | |
| 42 | return broken; | |
| 43 | } | |
| 44 | ||
| 45 | test("every in-page link in README.md lands on a heading", () => { | |
| 46 | const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8"); | |
| 47 | expect(brokenAnchors(readme)).toEqual([]); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a broken anchor is reported, a good one and repeated headings are not", () => { | |
| 51 | const doc = "# Top\n\n## Make it yours!\n\n## Same\n\n## Same\n\n" + | |
| 52 | "## See [the docs](http://x.y) now\n\n" + | |
| 53 | "[a](#make-it-yours) [b](#same-1) [c](#nowhere) [d](#top) [e](#%zz) [f](#see-the-docs-now)\n"; | |
| 54 | expect(brokenAnchors(doc)).toEqual(["nowhere", "%zz"]); | |
| 55 | }); |
decodeURIComponent throws on malformed escapes like %zz. Catch it and treat the anchor as broken.