// Internal link check of the site: every href="..." and src="..." in site/*.html that is not an external URL, // an anchor, a mailto or a data URI must resolve to a file under site/ (clean URLs: /bench -> bench.html). // Fragment links (#id) inside a page must name an element id in that page. Template literals (${...}) in scripts are // skipped; /api/ resolves to the function file site/api/.mjs. // node tools/ci/link-check.mjs exit 1 on the first broken link, listing every one import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const site = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'site'); const pages = readdirSync(site).filter(f => f.endsWith('.html')); const broken = []; let checked = 0; function resolves(target) { const clean = target.replace(/[?#].*$/, ''); if (clean === '' || clean === '/') return true; const rel = clean.replace(/^\//, ''); // /api/ is a function: site/api/.mjs (Vercel routes the path to it) const api = /^api\/([a-z0-9_-]+)$/.exec(rel); if (api) return existsSync(join(site, 'api', `${api[1]}.mjs`)); const candidates = [join(site, rel), join(site, `${rel}.html`), join(site, rel, 'index.html')]; return candidates.some(c => existsSync(c) && statSync(c).isFile()); } for (const page of pages) { const html = readFileSync(join(site, page), 'utf8'); const ids = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map(m => m[1])); for (const m of html.matchAll(/\s(?:href|src)="([^"]*)"/g)) { const t = m[1]; if (/^(https?:|mailto:|data:|tel:|javascript:)/i.test(t) || t.startsWith('//')) continue; // a template literal inside a page's script builds the href at run time (the explorer pages: /block/${hash}) if (t.includes('${')) continue; checked++; if (t.startsWith('#')) { if (t.length > 1 && !ids.has(t.slice(1))) broken.push(`${page}: fragment ${t}`); continue; } const [path, frag] = t.split('#'); if (!resolves(path)) { broken.push(`${page}: ${t}`); continue; } if (frag) { const rel = path.replace(/[?].*$/, '').replace(/^\//, ''); const file = [join(site, rel), join(site, `${rel}.html`), join(site, rel, 'index.html')].find(c => existsSync(c) && statSync(c).isFile()); if (file && file.endsWith('.html') && !new Set([...readFileSync(file, 'utf8').matchAll(/\sid="([^"]+)"/g)].map(x => x[1])).has(frag)) broken.push(`${page}: ${t} (no id ${frag})`); } } } if (broken.length) { console.error(`link check: ${broken.length} broken internal link(s) of ${checked}:`); for (const b of broken) console.error(` ${b}`); process.exit(1); } console.log(`link check: ${checked} internal links across ${pages.length} pages, 0 broken`);