/** * doc-links — relative links and #anchors in Markdown resolve (the docs lane's link check, R81; docs/pregate.md#docs-only). * Offline: links with a scheme (https:, mailto:) or a protocol-relative // are not checked. Links inside fenced code * blocks and inline code are ignored. An anchor into a Markdown file must match a heading slug (GitHub style, with -1, * -2 suffixes for repeats) or an explicit / in that file. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; export interface BrokenLink { file: string; line: number; target: string; reason: string } export function githubSlug(heading: string): string { return heading.trim().toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-'); } function unfenced(text: string): string[] { let fence: string | null = null; return text.split('\n').map(line => { const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line); if (m) { if (!fence) { fence = m[1]![0]!; return ''; } if (m[1]![0] === fence) { fence = null; return ''; } } return fence ? '' : line; }); } export function anchorsOf(text: string): Set { const out = new Set(); const seen = new Map(); for (const line of unfenced(text)) { const h = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line); if (h) { const base = githubSlug(h[1]!); const n = seen.get(base) ?? 0; seen.set(base, n + 1); out.add(n ? `${base}-${n}` : base); } for (const a of line.matchAll(/>): BrokenLink[] { const broken: BrokenLink[] = []; const cache = new Map>(); const anchorsAt = (abs: string) => { if (!cache.has(abs)) cache.set(abs, anchorsOf(fs.readFileSync(abs, 'utf8'))); return cache.get(abs)!; }; for (const file of files) { const abs = path.join(root, file); if (!fs.existsSync(abs)) continue; unfenced(fs.readFileSync(abs, 'utf8')).forEach((raw, i) => { if (added && !added.get(file)?.has(i + 1)) return; const line = raw.replace(/(`+)[^`]*?\1/g, ''); for (const m of line.matchAll(/!?\[[^\]]*\]\(\s*]+)>?(?:\s+["'(][^)]*)?\)/g)) { const target = m[1]!; if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) continue; const [withLine, anchor] = target.split('#', 2) as [string, string | undefined]; const rel = /\.md$/i.test(withLine) ? withLine : withLine.replace(/:\d+(?:-\d+)?$/, ''); let dest: string; try { dest = rel ? (rel.startsWith('/') ? path.join(root, decodeURIComponent(rel)) : path.resolve(path.dirname(abs), decodeURIComponent(rel))) : abs; } catch { broken.push({ file, line: i + 1, target, reason: 'undecodable path' }); continue; } if (path.relative(root, dest).startsWith('..')) { broken.push({ file, line: i + 1, target, reason: 'points outside the repository' }); continue; } if (!fs.existsSync(dest)) { broken.push({ file, line: i + 1, target, reason: 'no such file' }); continue; } if (!anchor || !/\.md$/i.test(dest) || !fs.statSync(dest).isFile()) continue; let wanted: string; try { wanted = decodeURIComponent(anchor).toLowerCase(); } catch { broken.push({ file, line: i + 1, target, reason: 'undecodable anchor' }); continue; } if (!anchorsAt(dest).has(wanted) && !anchorsAt(dest).has(anchor)) broken.push({ file, line: i + 1, target, reason: `no heading or for #${anchor}` }); } }); } return broken; } /** * 1-based lines each file added since `base` (diffed against the working tree). A file absent at `base`, tracked or * not, is all added. Each file is diffed on its own, so no path is parsed back out of patch headers (quoted, prefixed * or tab-suffixed names). Throws when `base` does not resolve or git fails. */ export function addedLines(root: string, base: string, files: readonly string[]): Map> { const added = new Map>(); const fail = (what: string, r: ReturnType) => new Error(`${what}: ${(String(r.stderr ?? '') || r.error?.message || '').trim().split('\n')[0]}`); if (!files.length) return added; const ref = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: root, encoding: 'utf8', timeout: 30_000 }); if (ref.status !== 0) throw fail(`git diff ${base}: unknown revision`, ref); for (const f of files) { const lines = new Set(); added.set(f, lines); if (spawnSync('git', ['cat-file', '-e', `${base}:${f}`], { cwd: root, timeout: 30_000 }).status !== 0) { const abs = path.join(root, f); if (fs.existsSync(abs)) fs.readFileSync(abs, 'utf8').split('\n').forEach((_, i) => lines.add(i + 1)); continue; } const d = spawnSync('git', ['diff', '-U0', '--no-ext-diff', '--no-color', base, '--', f], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); if (d.status !== 0) throw fail(`git diff ${base}`, d); for (const line of d.stdout.split('\n')) { const h = /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/.exec(line); if (h) for (let n = 0, start = Number(h[1]); n < Number(h[2] ?? 1); n++) lines.add(start + n); } } return added; }