igneum/tools/ci/link-check.mjs
igneum-josh 658daf698a Explorer pages: /explorer, /block/<hash|number>, /address/<addr>, the search router, a local preview server, CI
Fed by /api/explorer from the observer's tables. Latest blocks (hash, number, DAA, blue score, miner, txs, proof
records, time); a block's header, parents, children, mergeset, coinbase outputs, EVM transactions, shards, checkpoint
and certificate; an address's blocks, what they earned, vote keys and balance (eth_getBalance when EXPLORER_EVM_RPC is
set). Same tokens as live.html. vercel.json rewrites /block/:id and /address/:addr; the footer links the explorer; the
link checker skips template literals and resolves /api/<name> to its function. node tools/site-serve.mjs previews the
site with the functions in-process.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 20:35:40 +01:00

46 lines
2.7 KiB
JavaScript

// 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/<name> resolves to the function file site/api/<name>.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/<name> is a function: site/api/<name>.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`);