#!/usr/bin/env node // Every relative link in this repo's markdown must resolve to a file that exists, // and every in-page anchor must match a heading in the file it points at. // // WHAT THIS DOES NOT DO: fetch anything. External URLs are not checked, on // purpose. This kit points at a self-hosted Gitea, so an HTTP check would fail on // a runner without credentials, flake when the host is busy, and teach us to // ignore red. What breaks in practice is a relative path after a file moves, and // that is answerable offline with certainty. // // It DOES check that every http(s) link into the RunicGateway host names a // branch, because `.../src/branch/main/...` survives and `.../src/commit//...` // pins a reader to a snapshot of a document we want them reading the current // version of. // // Usage: node scripts/checkLinks.js (repo root) // node scripts/checkLinks.js --quiet const fs = require('fs') const path = require('path') const { stripFences } = require('./lib/markdown') const ROOT = path.resolve(__dirname, '..') const QUIET = process.argv.includes('--quiet') // Directories that hold no prose we own. const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist']) /** Every markdown file in the repo, repo-relative, sorted. */ function markdownFiles(dir = ROOT, out = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue markdownFiles(path.join(dir, entry.name), out) } else if (entry.name.toLowerCase().endsWith('.md')) { out.push(path.relative(ROOT, path.join(dir, entry.name)).split(path.sep).join('/')) } } return out.sort() } // Fenced code blocks are stripped before links are read (`lib/markdown.js`): a // fence can legitimately contain a path that does not exist — a directory listing // of a project the reader has not created yet — and flagging those would make the // check useless in exactly the document type this repo is made of. /** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */ function linksIn(text) { const found = [] const lines = stripFences(text).split(/\r?\n/) lines.forEach((line, i) => { // Skip inline code spans: `[a](b)` inside backticks is an example, not a link. const bare = line.replace(/`[^`]*`/g, '') for (const m of bare.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { found.push({ target: m[1], line: i + 1 }) } const def = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/.exec(bare) if (def) found.push({ target: def[1], line: i + 1 }) }) return found } /** GitHub/Gitea-style heading slugs, for anchor checking. */ function anchorsIn(text) { const slugs = new Set() for (const line of stripFences(text).split(/\r?\n/)) { const m = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line) if (!m) continue const slug = m[1] .replace(/`/g, '') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .toLowerCase() .replace(/[^\w\s-]/gu, '') .trim() .replace(/\s+/g, '-') if (slug) slugs.add(slug) } return slugs } const files = markdownFiles() const anchorCache = new Map() function anchorsOf(relPath) { if (!anchorCache.has(relPath)) { anchorCache.set(relPath, anchorsIn(fs.readFileSync(path.join(ROOT, relPath), 'utf8'))) } return anchorCache.get(relPath) } const problems = [] let checked = 0 for (const file of files) { const text = fs.readFileSync(path.join(ROOT, file), 'utf8') const selfAnchors = anchorsIn(text) for (const { target, line } of linksIn(text)) { const where = `${file}:${line}` if (/^(mailto:|tel:)/i.test(target)) continue if (/^https?:\/\//i.test(target)) { checked++ // Not fetched — but a permalink to a moving document is still wrong. if (/gitea\.whitlocktech\.com\/.*\/src\/commit\//.test(target)) { problems.push(`${where}: links to a commit snapshot, not a branch — ${target}`) } continue } if (target.startsWith('#')) { checked++ const slug = decodeURIComponent(target.slice(1)).toLowerCase() if (!selfAnchors.has(slug)) problems.push(`${where}: no heading matches ${target}`) continue } checked++ const [rawPath, rawAnchor] = target.split('#') // A PR/issue template's text is INLINED into a pull request or issue body, and // Gitea resolves relative links in those against the repo root — not against // `.gitea/`, where the file itself lives. So `[CONTRIBUTING.md](CONTRIBUTING.md)` // is correct in a template and would be wrong anywhere else. Resolve those from // the root, or this check reports every template link as broken and gets muted. const base = file.startsWith('.gitea/') ? ROOT : path.dirname(path.join(ROOT, file)) const resolved = path.resolve(base, decodeURIComponent(rawPath)) const rel = path.relative(ROOT, resolved).split(path.sep).join('/') if (rel.startsWith('..')) { problems.push(`${where}: points outside the repo — ${target}`) continue } if (!fs.existsSync(resolved)) { problems.push(`${where}: no such file — ${target}`) continue } if (rawAnchor && resolved.toLowerCase().endsWith('.md')) { const slug = decodeURIComponent(rawAnchor).toLowerCase() if (!anchorsOf(rel).has(slug)) { problems.push(`${where}: ${rawPath} has no heading matching #${rawAnchor}`) } } } } if (problems.length) { console.error(`checkLinks: ${problems.length} problem(s) in ${files.length} file(s):\n`) for (const p of problems) console.error(` ${p}`) process.exit(1) } if (!QUIET) { console.log(`checkLinks: ${checked} link(s) across ${files.length} markdown file(s) — OK`) }