1// Check a static site's internal links: every href and src in its HTML and2// every url() in its CSS must name a file inside the site, and every #anchor3// an id on the page it points to. Links must be relative, so the site works4// under any base path and on its own hostname. .forge/ci runs it on the guide.5// node lib/check-links.ts <dir>6import fs from 'node:fs';7import path from 'node:path';89const dir = path.resolve(process.argv[2] ?? '.');10const files = (fs.readdirSync(dir, { recursive: true }) as string[]).map((f) => path.join(dir, f)).filter((f) => fs.statSync(f).isFile());11const ids = new Map<string, Set<string>>();12const idsOf = (file: string): Set<string> => {13 let s = ids.get(file);14 if (!s) ids.set(file, (s = new Set([...fs.readFileSync(file, 'utf8').matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]))));15 return s;16};1718const errors: string[] = [];19if (!fs.existsSync(path.join(dir, 'index.html'))) errors.push(`${dir}: no index.html`);20for (const file of files) {21 const text = fs.readFileSync(file, 'utf8');22 const links = file.endsWith('.html') ? [...text.matchAll(/\s(?:href|src)="([^"]*)"/g)] : file.endsWith('.css') ? [...text.matchAll(/url\(["']?([^"')]+)/g)] : [];23 for (const [, raw] of links) {24 const at = `${path.relative(dir, file)}: ${raw}`;25 if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(raw)) continue;26 if (raw.startsWith('/')) {27 errors.push(`${at} is absolute; use a relative link`);28 continue;29 }30 const [p, frag] = raw.replace(/\?.*$/, '').split('#');31 let target = p ? path.resolve(path.dirname(file), decodeURIComponent(p)) : file;32 if (target !== dir && !target.startsWith(dir + path.sep)) {33 errors.push(`${at} leaves the site`);34 continue;35 }36 if (fs.existsSync(target) && fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');37 if (!fs.existsSync(target)) errors.push(`${at} is missing`);38 else if (frag && target.endsWith('.html') && !idsOf(target).has(frag)) errors.push(`${at} has no #${frag}`);39 }40}41for (const e of errors) console.error(e);42if (errors.length) process.exit(1);43console.log(`${path.relative(process.cwd(), dir) || '.'}: ${files.length} files, links ok`);