igneum/tools/ci/link-check.mjs
igneum-labs dda5dedf44 Site reset to Igneum 2.0, first pass: /bench and /journey gone, no testnet, no 0.x history, the Igneum 2.0 devnet, the positioning line
- build.mjs no longer writes bench.html, journey.html or journey.json; the three files are deleted; /bench -> /evidence and
  /journey -> /live redirects (permanent: false); nav, footer, sitemap, capture routes and the site-nav-check groups drop them;
  link-check resolves literal vercel.json redirects and lists every link that leans on one (evidence.html: /bench, another hand)
- every testnet card, column, row, notice and link removed from the pages this lane owns; release manifest: the testnet entry
  gone, network igneum-devnet-4 "Igneum 2.0 devnet", the Devnet 3 genesis, digest, vote domain and floor lines dropped
- "Devnet 3" leaves every owned page; the devnet state is one constant in build.mjs (DEVNET_STATE, 'starting' now: the nav
  pill and the homepage badge read "Igneum 2.0 devnet starting"; 'live' flips both and re-enables the nav's live read)
- no 0.x release history: screenshot alt texts say the current build, the miner page's "Current build:" keeps the data-rm
  spans without the node version; miner-bench.json keeps the current-program rows only, no Counter ASIC names, no versions;
  /miners drops the earlier-classes table, the history note and the miner-version column
- /income keeps the calculator; the testnet-era tier tables (docs/analysis/income-tiers.md) are not served
- the positioning line is the first line of the footer partial (every page), the homepage meta description's first sentence
  and the hero's sub-line; share metas (site/og/pages.mjs) carry no Devnet 3 or zkEVM
- ledger-text-check is red on two index.html pins (X2, X31: the testnet sentences), owned by the ledger hand

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:40:33 +00:00

58 lines
4.1 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: /miners -> miners.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());
}
// a literal vercel.json rewrite (no :param) resolves to its destination (/release.json -> /release-manifest.json, 8 October 2026)
const rewrites = Object.fromEntries((JSON.parse(readFileSync(join(site, 'vercel.json'), 'utf8')).rewrites || []).filter(r => !r.source.includes(':')).map(r => [r.source, r.destination]));
const resolvesOrRewritten = (p) => resolves(p) || (rewrites[p.replace(/[?].*$/, '')] != null && resolves(rewrites[p.replace(/[?].*$/, '')]));
// a literal vercel.json redirect (Igneum 2.0 reset, 8 October 2026: /bench -> /evidence, /journey -> /live) resolves to its
// destination, and every link that leans on one is listed below the result so the page that carries it is fixed at source
const vercelRedirects = Object.fromEntries((JSON.parse(readFileSync(join(site, 'vercel.json'), 'utf8')).redirects || []).filter(r => !r.source.includes(':') && !r.has).map(r => [r.source, r.destination]));
const viaRedirect = [];
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 (!resolvesOrRewritten(path)) {
const r = vercelRedirects[path.replace(/[?].*$/, '')];
if (r != null && resolvesOrRewritten(r.split('#')[0])) { viaRedirect.push(`${page}: ${t} -> ${r}`); continue; }
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`);
if (viaRedirect.length) { console.log(`link check: ${viaRedirect.length} link(s) resolve only through a vercel.json redirect (fix them at source):`); for (const v of viaRedirect) console.log(` ${v}`); }