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>
46 lines
2.7 KiB
JavaScript
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`);
|