Files
paperclip-docs/scripts/sync/lint-links.mjs
Aron Prins 7e25158fca Release docs for paperclip v2026.707.0 (#49)
Promotes v2026.707.0 docs from nightly: Custom sandbox images + Ramp skill (new), Work Timeline (#48) and Secret scopes (#47) content, screenshot capture targets, environments nav reconcile, sync-state bump to v2026.707.0, and accuracy fixes from adversarial verification. 0 drift.
2026-07-08 11:11:32 +02:00

111 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
// Internal-link checker for paperclip-docs.
// Scans docs/**/*.md for relative markdown links and image refs, verifies the targets exist.
// External (http/https) links are ignored. Anchors (#foo) are stripped before file resolution.
// Exits 1 if any broken links found.
//
// Usage: node scripts/sync/lint-links.mjs [--json]
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
import { join, dirname, resolve, relative } from "node:path";
const ROOT = resolve(new URL("../..", import.meta.url).pathname);
const DOCS = join(ROOT, "docs");
const LINK_RE = /(?<!\!)\[([^\]]*)\]\(([^)]+)\)/g;
const IMAGE_RE = /!\[([^\]]*)\]\(([^)]+)\)/g;
function walk(dir, acc = []) {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const s = statSync(p);
if (s.isDirectory()) walk(p, acc);
else if (entry.endsWith(".md")) acc.push(p);
}
return acc;
}
function stripFencedCodeBlocks(src) {
const output = [];
let fence = null;
for (const line of src.split(/\r?\n/)) {
const match = line.match(/^([`~]{3,})/);
if (fence) {
if (match && match[1][0] === fence.char && match[1].length >= fence.length) {
fence = null;
}
output.push("");
continue;
}
if (match) {
fence = {
char: match[1][0],
length: match[1].length,
};
output.push("");
continue;
}
output.push(line);
}
return output.join("\n");
}
// Blank out inline-code spans (`code`, ``a`b``, …) so markdown-looking text
// documented *inside* code — e.g. prose explaining the `[name](url)` syntax —
// isn't parsed as a real link. Matches per line (inline code doesn't span
// lines) and preserves length so line structure is untouched. Fenced blocks
// are already stripped upstream by stripFencedCodeBlocks.
function stripInlineCode(src) {
return src.replace(/(`+)(?:(?!\1)[^\n])*?\1/g, (m) => " ".repeat(m.length));
}
function checkFile(file) {
const issues = [];
const src = stripInlineCode(stripFencedCodeBlocks(readFileSync(file, "utf8")));
const seen = new Set();
for (const re of [LINK_RE, IMAGE_RE]) {
re.lastIndex = 0;
let m;
while ((m = re.exec(src))) {
const target = m[2].trim();
if (!target) continue;
// Skip anchors, absolute SPA routes, and any URI scheme (http://, mailto:, agent://, etc.).
if (target.startsWith("#")) continue;
if (target.startsWith("/")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue;
// Skip template placeholders that aren't real paths.
if (target.includes("${") || target.includes("{{") || /\{[a-z_]/i.test(target)) continue;
const [pathPart] = target.split("#");
if (!pathPart) continue;
const key = `${file}::${pathPart}`;
if (seen.has(key)) continue;
seen.add(key);
const resolved = resolve(dirname(file), pathPart);
if (!existsSync(resolved)) {
issues.push({ file: relative(ROOT, file), target, resolved: relative(ROOT, resolved) });
}
}
}
return issues;
}
const files = walk(DOCS);
const all = [];
for (const f of files) all.push(...checkFile(f));
const json = process.argv.includes("--json");
if (json) {
console.log(JSON.stringify({ checked: files.length, broken: all }, null, 2));
} else {
if (all.length === 0) {
console.log(`OK — ${files.length} markdown files, no broken internal links.`);
} else {
console.error(`Broken internal links (${all.length}):`);
for (const { file, target, resolved } of all) {
console.error(` ${file}\n -> ${target}\n (resolved: ${resolved})`);
}
}
}
process.exit(all.length === 0 ? 0 : 1);