750 lines
66 KiB
JavaScript
750 lines
66 KiB
JavaScript
// Igneum site build: injects the shared head, nav and footer, renders the generated pages from the
|
||
// repository's markdown and data, and stamps the download buttons. The FUD ledger is internal and
|
||
// is never rendered here.
|
||
// Runs on every deploy (Vercel build command) and locally with `node build.mjs`.
|
||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { scrubBench } from './scrub.mjs';
|
||
import { MARKS, VENDORS, tokensCss, markHtml } from './lib/marks.mjs';
|
||
import { osHtml } from './lib/os-marks.mjs';
|
||
import { shareHtml, checkShare } from './og/pages.mjs'; // the share cards and metas, one source for every page (8 October 2026)
|
||
import { renderAcceptance } from './lib/acceptance-render.mjs'; // /acceptance, rendered from docs/plans/igneum-2.0-test-registry.json (8 October 2026)
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const repo = join(here, '..');
|
||
const docs = join(repo, 'docs');
|
||
|
||
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||
function inline(s) {
|
||
s = esc(s);
|
||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
s = s.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" rel="noopener">$1</a>');
|
||
s = s.replace(/\[([^\]]+)\]\((\/[^)\s]*)\)/g, '<a href="$2">$1</a>'); // a site-relative link (the builder pages, 8 October 2026)
|
||
return s;
|
||
}
|
||
function md(src) {
|
||
const lines = src.split('\n'); const out = []; let i = 0; let toc = [];
|
||
const slug = t => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
if (/^```/.test(l)) { const buf = []; i++; while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]); i++; out.push('<pre>' + esc(buf.join('\n')) + '</pre>'); continue; }
|
||
const h = /^(#{1,4})\s+(.*)$/.exec(l);
|
||
if (h) { const lvl = h[1].length; const t = h[2].trim(); const id = slug(t); if (lvl <= 2) toc.push({ lvl, t, id }); out.push(`<h${lvl} id="${id}">${inline(t)}</h${lvl}>`); i++; continue; }
|
||
if (/^\|/.test(l)) {
|
||
const rows = []; while (i < lines.length && /^\|/.test(lines[i])) rows.push(lines[i++]);
|
||
const cells = r => r.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
||
const head = cells(rows[0]); const body = rows.slice(1).filter(r => !/^\|\s*-{2,}/.test(r));
|
||
out.push('<div class="tbl"><table><thead><tr>' + head.map(c => `<th>${inline(c)}</th>`).join('') + '</tr></thead><tbody>' + body.map(r => '<tr>' + cells(r).map(c => `<td>${inline(c)}</td>`).join('') + '</tr>').join('') + '</tbody></table></div>');
|
||
continue;
|
||
}
|
||
if (/^\s*[-*]\s+/.test(l)) { const items = []; while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*[-*]\s+/, '')); out.push('<ul>' + items.map(x => `<li>${inline(x)}</li>`).join('') + '</ul>'); continue; }
|
||
if (/^\s*\d+\.\s+/.test(l)) { const items = []; while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*\d+\.\s+/, '')); out.push('<ol>' + items.map(x => `<li>${inline(x)}</li>`).join('') + '</ol>'); continue; }
|
||
if (/^\s*$/.test(l)) { i++; continue; }
|
||
const buf = []; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#|\||```|\s*[-*]\s|\s*\d+\.\s)/.test(lines[i])) buf.push(lines[i++]);
|
||
out.push('<p>' + inline(buf.join(' ')) + '</p>');
|
||
}
|
||
return { html: out.join('\n'), toc };
|
||
}
|
||
|
||
// Shared chrome: fonts and tokens (head), the nav bar and the footer live once in site/partials/ and are injected into every
|
||
// page between HTML comment markers (<!-- head:start --> ... <!-- head:end -->, nav, footer).
|
||
// The committed pages carry the injected copy, so they are right with or without a build; the build keeps them in step.
|
||
const partial = name => readFileSync(join(here, 'partials', name), 'utf8').trim();
|
||
// The devnet state (Igneum 2.0, 8 October 2026): ONE string in ONE place. 'starting' until the Igneum 2.0 devnet's first
|
||
// block; set 'live' on the landing that re-points the feeds. Every element carrying data-devnet-state (the nav pill, the
|
||
// devnet notices) is stamped with the state and its label, and the nav script only reads the live feed once the state is 'live'.
|
||
const DEVNET_STATE = 'live';
|
||
const DEVNET_LABEL = { starting: 'Igneum 2.0 devnet starting', live: 'Devnet live' }[DEVNET_STATE];
|
||
const stampDevnet = html => html.replace(/<(b|span)([^>]*?) data-devnet-state="[^"]*"([^>]*)>[^<]*<\/\1>/g, (m, tag, a, b) => `<${tag}${a} data-devnet-state="${DEVNET_STATE}"${b}>${DEVNET_LABEL}</${tag}>`);
|
||
const HEAD = partial('head.html'), NAV = stampDevnet(partial('nav.html')), FOOT = partial('footer.html');
|
||
// the reference apps' shared definitions (8 Oct 2026): included, executed, proven, finalised, one source for the three pages
|
||
const TERMS = partial('terms.html');
|
||
function navFor(active) {
|
||
// the slim bar (site-ui-5): the route's link carries aria-current in the panel and in the sheet, its group button data-active;
|
||
// a page outside the panels (the explorer's block and address views, the 404, the scenes lab) marks nothing
|
||
if (!active) return NAV;
|
||
const re = new RegExp(`(<a href="[^"]*" data-nav="${active}")`, 'g');
|
||
if (!re.test(NAV)) return NAV;
|
||
let out = NAV.replace(re, '$1 aria-current="page"');
|
||
for (const m of out.matchAll(/<div class="nav-panel" id="nav-panel-([a-z]+)"[\s\S]*?<\/div>\s*<\/div>/g)) {
|
||
if (new RegExp(`data-nav="${active}"`).test(m[0])) { out = out.replace(`id="nav-btn-${m[1]}" data-group="${m[1]}" aria-expanded`, `id="nav-btn-${m[1]}" data-group="${m[1]}" data-active aria-expanded`); break; }
|
||
}
|
||
return out;
|
||
}
|
||
// Marks (site-ui-5): <span data-os="windows|mac|linux|hive" class="mini|bare"></span> becomes the OS glyph in its well,
|
||
// <span data-gpu="nvidia|amd|intel|apple|gpu" class="mini"></span> the vendor badge, and a table cell that names a card
|
||
// ("NVIDIA RTX 5090", "AMD RX 9070 XT", "Apple M5 Max", "Intel Arc B580") gets the vendor's mini badge in front of the name.
|
||
// Pages with a card picker ([data-yourcard]) get the five badges as JSON for yourcard.js.
|
||
function vendorOfName(n) {
|
||
if (/^nvidia|geforce|\bRTX\b|\bGTX\b/i.test(n)) return 'nvidia';
|
||
if (/^amd\b|radeon|\bgfx\d/i.test(n)) return 'amd';
|
||
if (/^intel|\barc\b|iris|\buhd\b/i.test(n)) return 'intel';
|
||
if (/^apple|\bM\d (max|pro|ultra)\b/i.test(n)) return 'apple';
|
||
return null;
|
||
}
|
||
function stampMarks(html) {
|
||
html = html.replace(/<span data-os="([a-z]+)"(?: class="([a-z]+)")?><\/span>/g, (m, k, size) => osHtml(k, size));
|
||
html = html.replace(/<span data-gpu="([a-z]+)"(?: class="([a-z]+)")?><\/span>/g, (m, v, size) => markHtml(v, size));
|
||
html = html.replace(/<td([^>]*)>((?:NVIDIA|AMD|Apple|Intel)\b[^<]{0,60})<\/td>/g, (m, attrs, name) => { const v = vendorOfName(name); return v ? `<td${attrs}><span class="cardname">${markHtml(v, 'mini')}${name}</span></td>` : m; });
|
||
if (html.includes('data-yourcard') && !html.includes('id="gpu-marks"')) {
|
||
const json = JSON.stringify(Object.fromEntries(Object.keys(MARKS).map(v => [v, markHtml(v, 'mini')]))).replace(/<\//g, '<\\/');
|
||
html = html.replace('<script src="/yourcard.js"', `<script type="application/json" id="gpu-marks">${json}</script>\n<script src="/yourcard.js"`);
|
||
}
|
||
return html;
|
||
}
|
||
// the vendor tokens go into site.css once, between the mark-tokens markers, from the same module
|
||
{ const cssPath = join(here, 'site.css'); const css = readFileSync(cssPath, 'utf8');
|
||
const START = '/* mark-tokens:start (site/lib/marks.mjs tokensCss(), written by build.mjs; edit the module, never this block) */', END = '/* mark-tokens:end */';
|
||
const block = `${START}\n${tokensCss()}\n${END}`;
|
||
const re = new RegExp(START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]*?' + END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||
if (!re.test(css)) throw new Error('site.css: missing the mark-tokens markers');
|
||
const next = css.replace(re, () => block); if (next !== css) writeFileSync(cssPath, next); }
|
||
function inject(html, name, content, file) {
|
||
const re = new RegExp(`<!-- ${name}:start -->[\\s\\S]*?<!-- ${name}:end -->`);
|
||
if (!re.test(html)) throw new Error(`${file}: missing <!-- ${name}:start --> ... <!-- ${name}:end --> markers`);
|
||
return html.replace(re, () => `<!-- ${name}:start -->\n${content}\n<!-- ${name}:end -->`);
|
||
}
|
||
// The vendor marks (gpu-logos lane, 7 Oct 2026): brand/marks/vendor-marks.mjs is the source, site/lib/marks.mjs its byte-equal
|
||
// copy (the site deploys from site/ alone). A drifted copy fails the build here and the gate's own line (tools/ci/pre-push.sh).
|
||
{ const src = join(repo, 'brand', 'marks', 'vendor-marks.mjs'), copy = join(here, 'lib', 'marks.mjs');
|
||
if (existsSync(src) && readFileSync(src, 'utf8') !== readFileSync(copy, 'utf8')) throw new Error('site/lib/marks.mjs differs from brand/marks/vendor-marks.mjs: cp brand/marks/vendor-marks.mjs site/lib/marks.mjs'); }
|
||
const ORIGIN = 'https://igneum.network';
|
||
const svgIcon = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E";
|
||
// Search and share metadata. The share block (og: and twitter:) comes from site/og/pages.mjs for every page, generated or
|
||
// hand-written (the hand-written pages get it through injectShare below); nothing is pasted per page (8 October 2026).
|
||
{ const bad = checkShare(); if (bad.length) throw new Error('site/og/pages.mjs: ' + bad.join('; ')); }
|
||
function meta(title, desc, path) {
|
||
const url = ORIGIN + path; const t = esc(title); const d = esc(desc);
|
||
return `<title>${t}</title>
|
||
<meta name="description" content="${d}">
|
||
<link rel="canonical" href="${url}">
|
||
<meta name="theme-color" content="#0C0C0E">
|
||
<!-- share:start -->
|
||
${shareHtml(path, title, desc)}
|
||
<!-- share:end -->
|
||
<link rel="icon" href="${svgIcon}" type="image/svg+xml">
|
||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192">
|
||
<link rel="icon" href="/icon-512.png" type="image/png" sizes="512x512">
|
||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||
<link rel="manifest" href="/site.webmanifest">`;
|
||
}
|
||
// The release manifest (8 October 2026, an accepted external review): site/release-manifest.json is served at /release.json
|
||
// (vercel.json rewrite) and every number a status page carries from it sits in <span data-rm="a.b.c">...</span>, filled here
|
||
// at build; tools/ci/release-manifest-check.mjs holds the committed pages to the manifest and the manifest to its sources.
|
||
const RM = JSON.parse(readFileSync(join(here, 'release-manifest.json'), 'utf8'));
|
||
export function rmValue(path) {
|
||
const v = path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), RM);
|
||
if (v === undefined) throw new Error(`release-manifest.json: no value at ${path}`);
|
||
return typeof v === 'number' && !/_id$/.test(path) ? v.toLocaleString('en-GB') : String(v); // an id is never grouped
|
||
}
|
||
export function fillManifest(html) {
|
||
// the hand pages carry <span data-rm>; the markdown sources carry {{rm:path}} (their renderer escapes raw HTML)
|
||
html = html.replace(/\{\{rm:([a-z0-9_.-]+)\}\}/g, (m, p) => `<span data-rm="${p}">${rmValue(p).replace(/&/g, '&').replace(/</g, '<')}</span>`);
|
||
return html.replace(/<span data-rm="([a-z0-9_.-]+)">[^<]*<\/span>/g, (m, p) => `<span data-rm="${p}">${rmValue(p).replace(/&/g, '&').replace(/</g, '<')}</span>`);
|
||
}
|
||
function sectionise(bodyHtml) {
|
||
// the markdown renderer's flat stream, cut at every h2 into <section class="doc-section"> so the contents rail and the
|
||
// text filter work on sections; anything before the first h2 is its own section
|
||
const parts = bodyHtml.split(/(?=<h2 id=")/);
|
||
// a dated log entry is a historical record (8 October 2026): the label sits under its heading, the current state is /release.json
|
||
const hist = (p) => p.replace(/^(<h2 id="(\d{4}-\d{2}-\d{2})[^"]*">[\s\S]*?<\/h2>)/, (m, h, d) => `${h}<p class="src"><b>Historical record</b> of ${d}: the code and the chain as they stood that day. The chain's current state is the <a href="/release.json">release manifest</a>.</p>`);
|
||
return parts.filter(p => p.trim()).map(p => `<section class="doc-section">${hist(p)}</section>`).join('\n');
|
||
}
|
||
function page(title, desc, bodyHtml, toc, note, { path = '/', heading = title, eyebrow, crumb, filter = true } = {}) {
|
||
const active = path.replace(/^\//, '');
|
||
const h2s = toc.filter(t => t.lvl === 2);
|
||
const nav = h2s.map(t => `<a href="#${t.id}">${esc(t.t)}</a>`).join('');
|
||
const key = path.replace(/^\//, '') || 'doc';
|
||
return stampMarks(`<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
${meta(title, desc, path)}
|
||
<!-- head:start -->
|
||
${HEAD}
|
||
<!-- head:end -->
|
||
<style>
|
||
/* generated-page block (the bench table, the provenance table): the page hero, the contents rail, the sections; tokens, type and components are in /site.css */
|
||
[id]{scroll-margin-top:120px}
|
||
article h1{display:none}
|
||
article{color:var(--ink-2)}
|
||
article h2{font-size:var(--t-h3);margin:0 0 12px}
|
||
article h3{font-size:16px;margin:26px 0 8px}
|
||
article h4{font:600 15px/1.4 var(--f-sans);margin:20px 0 6px;color:var(--bone)}
|
||
article p{margin:0 0 14px}article ul,article ol{margin:0 0 14px;padding-left:22px}article li{margin-bottom:6px}
|
||
article p,article li,article h2,article h3{overflow-wrap:anywhere}
|
||
article pre{margin:0 0 var(--s-4)}
|
||
.doc-section:last-child{border-bottom:0}
|
||
.doc-section[hidden]{display:none}
|
||
.tbl{margin:var(--s-4) 0 var(--s-5)}
|
||
table{min-width:560px}
|
||
.gen{color:var(--ash);font-size:13px;margin:40px 0 0;max-width:72ch}
|
||
.doc-tools{margin:0 0 var(--s-5)}
|
||
.doc-tools .searchbar{max-width:560px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- nav:start -->
|
||
${navFor(active)}
|
||
<!-- nav:end -->
|
||
<main id="main">
|
||
<section class="page-hero">
|
||
<div class="container">
|
||
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><span>${esc(crumb || heading)}</span></div>
|
||
<div class="page-heading"><div><div class="eyebrow"><span class="line"></span>${eyebrow || `${h2s.length} entries, newest at the bottom`}</div>
|
||
<h1>${esc(heading)}</h1>
|
||
<p class="lead">${note}</p></div></div>
|
||
</div>
|
||
</section>
|
||
<section class="section compact"><div class="container">
|
||
${filter ? `<div class="doc-tools"><div class="searchbar"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><label for="doc-q" class="visually-hidden">Filter the sections</label><input id="doc-q" data-filter-input="${key}" placeholder="Filter the sections: a card, a date, a word" autocomplete="off" spellcheck="false"><span class="small" data-filter-count="${key}"></span></div></div>` : ''}
|
||
<div class="docs-layout">
|
||
<nav class="doc-nav" aria-label="Contents"><div class="eyebrow">Contents</div>${nav}</nav>
|
||
<article class="doc-body" data-filter-list="${key}">${sectionise(bodyHtml)}</article>
|
||
</div>
|
||
<p class="gen">Generated from the repository at build time. Times are UTC. Machine names are model names.</p>
|
||
</div></section>
|
||
</main>
|
||
<!-- footer:start -->
|
||
${FOOT}
|
||
<!-- footer:end -->
|
||
<script src="/ui.js" defer></script>
|
||
</body>
|
||
</html>`);
|
||
}
|
||
|
||
let built = [];
|
||
// Igneum 2.0 site reset (8 October 2026): the engineering log (/bench) and the journey (/journey) are history and are not
|
||
// served; docs/bench-log.md stays in the repository as the measurement record.
|
||
|
||
// The builder programme (8 October 2026): /build and /grants are rendered from docs/build/build.md and docs/build/grants.md
|
||
// through the same generated-page shell, so the markdown is the source and the served page follows it. The other
|
||
// files of docs/build/ (the walkthrough, the RPC list, the faucet, the verifier) are linked from /build on the git host.
|
||
// The site audit (8 October 2026): /audit is rendered the same way from docs/site/audit-2.0.md. Each source is a path under docs/.
|
||
for (const [src, file, title, desc, heading, lead, eyebrow] of [
|
||
['build/build.md', 'build.html', 'Build on Igneum: chain ids, RPC, a contract in five minutes', 'The developer entry page for Igneum: what is different, the chain ids and RPC endpoints, the wallet, the explorer, the faucet, a five-minute contract deploy and a block you verify in your browser.', 'Build on Igneum', 'Built to prove every block. A lock in minutes. Nothing to stake. The EVM you already know: Solidity deploys unchanged. Here are the chain ids, the endpoints and a contract in five minutes.', 'Developers'],
|
||
['build/grants.md', 'grants.html', 'Igneum grants: tooling, reference apps, infrastructure, research', 'The Igneum grants programme: four tiers, what a grant is, how to apply with an issue on the git host, the weekly review, and the lines that stay honest about value, amounts and dates.', 'Grants', 'Paid in IGN from the dev fee fund, on delivery, on the Igneum 2.0 devnet first. Four tiers. One issue to apply. A weekly review.', 'The programme'],
|
||
['build/compatibility.md', 'compatibility.html', 'Igneum compatibility, measured: every row a test run against the devnet', 'Compatibility as a product deliverable: representative contracts, wallet fee estimation, indexing, failed transactions, receipts, application assumptions and the measured differences (block context, randomness, two-dimensional fees), each a test with its evidence. Devnet, no value.', 'Compatibility, measured', 'Every row is a test that ran against the devnet, with its evidence; a row that could not run says why.', 'Build · compatibility'],
|
||
['site/audit-2.0.md', 'audit.html', 'Igneum site audit, 2.0', 'Every served page of igneum.network read against the Igneum 2.0 reset: what changed, what was rewritten, what still carries pre-2.0 copy, the label of each claim and what is open.', 'Site audit', 'Every served page, what the reset changed, what was rewritten, what still carries pre-2.0 copy, the label of each claim, and what is open. Main reads it against the live site.', 'Learn · audit'],
|
||
['build/tuning.md', 'tuning.html', 'Igneum tuning: how Ember reaches a good operating point', 'The knee rule with its measured rows, the ladders and priors, the safe defaults per class, the worker flags and their costs, the compiler settings and the fingerprint check: the published optimisation work.', 'Tuning', 'How Ember reaches a good operating point without third-party software, every figure a dated measured row or marked approximate.', 'Learn · audit'],
|
||
]) {
|
||
const mdp = join(docs, src);
|
||
if (!existsSync(mdp)) continue; // the gate builds a copy of site/ alone: the committed page stands
|
||
const { html: mdHtml, toc } = md(readFileSync(mdp, 'utf8').replace(/\bPC [12]\b/g, 'the project’s own rig')); // a rig is described, never numbered, on a served page
|
||
const html = fillManifest(mdHtml);
|
||
const path = '/' + file.replace(/\.html$/, '');
|
||
writeFileSync(join(here, file), page(title, desc, html, toc, lead, { path, heading, crumb: heading, eyebrow, filter: false }).replace('<p class="gen">Generated from the repository at build time. Times are UTC. Machine names are model names.</p>', `<p class="gen">Generated from docs/${src} in the repository at build time. Times are UTC.</p>`));
|
||
built.push(file);
|
||
}
|
||
|
||
// The hand-written pages: shared head, nav (with the active link marked) and footer injected in place.
|
||
// The miner's product name lives HERE and nowhere else (the founder, 5 October 2026: Ember). Pages carry
|
||
// <span data-product="full|name|app|caption">…</span> and the build stamps every span from this table, so a rename is one
|
||
// edit. `full` is the first mention on a page and the headings where the brand matters, `name` every mention after that,
|
||
// `app` what the bundle, the installer and the window title still say (renaming them touches the over-the-air path),
|
||
// `caption` the line under a screenshot of the app window; drop the caption text once the app itself is relabelled.
|
||
const PRODUCT = {
|
||
full: 'Igneum Ember',
|
||
name: 'Ember',
|
||
app: 'Igneum Miner',
|
||
caption: 'Igneum Ember (the app window still says Igneum Miner)',
|
||
};
|
||
function stampProduct(html, file) {
|
||
return html.replace(/<span data-product="([a-z]+)">[^<]*<\/span>/g, (m, k) => {
|
||
if (!(k in PRODUCT)) throw new Error(`${file}: unknown data-product="${k}"`);
|
||
return `<span data-product="${k}">${esc(PRODUCT[k])}</span>`;
|
||
});
|
||
}
|
||
// Downloads (5 October 2026). The buttons link the PUBLIC downloads path, which carries no token:
|
||
// https://dl.igneum.network/public/<alias>, four aliases that packaging/ota/publish-public.sh rewrites to the current
|
||
// files. The version and the size on every button come from the index that script writes next to the signed manifests
|
||
// (dl/public/igneum-downloads.json), read here at build time; site/downloads.json is the last copy that was read, so a
|
||
// build without network (CI, a laptop offline) still stamps real numbers, and a successful read refreshes it.
|
||
// Pages carry: <a data-dl="miner-windows|miner-mac|miner-hive|wallet-mac"> (href stamped), <span data-dl-meta="key">
|
||
// (version and size), <code data-dl-url="key"> (the versioned URL, for the HiveOS Flight Sheet) and <span data-dl-sha="key">.
|
||
// Igneum 2.0 (8 October 2026): no testnet is served; any leftover paragraph marked data-testnet-notice is removed at build.
|
||
const DL_HOST = 'https://dl.igneum.network';
|
||
const DL_SNAPSHOT = join(here, 'downloads.json');
|
||
const SERVED_GUARD = join(here, '..', 'tools', 'ci', 'served-identity-guard.sh');
|
||
function guardServed(files) {
|
||
const present = files.filter((f) => existsSync(f));
|
||
if (!existsSync(SERVED_GUARD)) { console.log(`served-identity guard: tools/ci/served-identity-guard.sh is not in the tree yet; unguarded this build: ${present.map((f) => f.replace(here + '/', '')).join(', ')}`); return; }
|
||
const r = spawnSync('bash', [SERVED_GUARD, ...present], { stdio: 'inherit' });
|
||
if (r.status !== 0) throw new Error(`served-identity guard refused (exit ${r.status}: 1 = a hit, 2 = no private list) on ${present.map((f) => f.replace(here + '/', '')).join(', ')}; nothing is written`);
|
||
console.log(`served-identity guard: clean on ${present.map((f) => f.replace(here + '/', '')).join(', ')}`);
|
||
}
|
||
|
||
// The snapshot is a tracked file: the build rewrites it only on an explicit refresh (SITE_DOWNLOADS_REFRESH=1 or
|
||
// --refresh-downloads, the ship pipeline's step, committed with the release), or in CI (a throwaway checkout). A plain
|
||
// local build, and the pre-push hook, read live but never write: on 6 October 2026 the hook's build stamped 0.3.14's rows
|
||
// into five worktrees that had nothing to do with the release, as uncommitted edits to tracked files.
|
||
const DL_REFRESH = process.env.SITE_DOWNLOADS_REFRESH === '1' || process.argv.includes('--refresh-downloads') || !!process.env.CI;
|
||
async function loadDownloads() {
|
||
const snapshot = existsSync(DL_SNAPSHOT) ? JSON.parse(readFileSync(DL_SNAPSHOT, 'utf8')) : { files: {} };
|
||
if (process.env.SITE_DOWNLOADS_OFFLINE) return { ...snapshot, source: 'snapshot (offline)' };
|
||
try {
|
||
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 8000);
|
||
const r = await fetch(`${DL_HOST}/dl/public/igneum-downloads.json`, { signal: ctl.signal, headers: { 'Cache-Control': 'no-cache' } });
|
||
clearTimeout(t);
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const live = await r.json();
|
||
if (!live || typeof live.files !== 'object') throw new Error('no files object');
|
||
for (const [k, f] of Object.entries(live.files)) if (!/^\/public\/[a-z0-9.-]+$/.test(f.alias) || !/^\d+\.\d+\.\d+$/.test(String(f.version)) || !(f.size > 0)) throw new Error(`bad entry ${k}`);
|
||
if (DL_REFRESH) writeFileSync(DL_SNAPSHOT, JSON.stringify(live, null, 2) + '\n');
|
||
else if (JSON.stringify(live) !== JSON.stringify(snapshot)) console.warn('downloads: the live index differs from site/downloads.json; the release step refreshes it (SITE_DOWNLOADS_REFRESH=1 node site/build.mjs)');
|
||
return { ...live, source: 'live' };
|
||
} catch (e) {
|
||
console.warn(`downloads: using the snapshot (${e.message})`);
|
||
return { ...snapshot, source: 'snapshot' };
|
||
}
|
||
}
|
||
const fmtMB = n => (n / 1e6).toLocaleString('en-GB', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' MB';
|
||
function stampDownloads(html, file, dl) {
|
||
const f = dl.files || {};
|
||
html = html.replace(/<a([^>]*)\sdata-dl="([a-z-]+)"([^>]*)>/g, (m, pre, key, post) => {
|
||
const attrs = (pre + post).replace(/\shref="[^"]*"/, '').replace(/\sdata-dl-missing(="[^"]*")?/, '').replace(/\saria-disabled="[^"]*"/, '');
|
||
if (!f[key]) return `<a data-dl="${key}" href="#get" data-dl-missing aria-disabled="true"${attrs}>`;
|
||
return `<a data-dl="${key}" href="${DL_HOST}${f[key].alias}"${attrs}>`;
|
||
});
|
||
// the stamped elements keep their other attributes (class, style); only the text between the tags is replaced
|
||
html = html.replace(/<span([^>]*\sdata-dl-meta="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `<span${attrs}>${f[key] ? `v${esc(String(f[key].version))} · ${fmtMB(f[key].size)}` : 'not published yet'}</span>`);
|
||
html = html.replace(/<code([^>]*\sdata-dl-url="([a-z-]+)"[^>]*)>[^<]*<\/code>/g, (m, attrs, key) => `<code${attrs}>${f[key] ? `${DL_HOST}${esc(f[key].path)}` : '(not published yet)'}</code>`);
|
||
html = html.replace(/<span([^>]*\sdata-dl-sha="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `<span${attrs}>${f[key] ? esc(String(f[key].sha256)) : 'not published yet'}</span>`);
|
||
html = html.replace(/\n?[ \t]*<p data-testnet-notice[^>]*>[\s\S]*?<\/p>/g, '');
|
||
for (const key of ['miner-windows', 'miner-mac', 'miner-hive', 'wallet-mac']) if (html.includes(`data-dl="${key}"`) && !f[key]) console.warn(`${file}: ${key} has no published file yet; its button points at #get`);
|
||
return html;
|
||
}
|
||
const downloads = await loadDownloads();
|
||
// the served-identity guard (the coordinator's 09:15 class, 9 October 2026: a served file generated from reports or merges
|
||
// runs the identity grep at its publish step and refuses on a hit): the downloads snapshot and the release manifest (/release.json)
|
||
// go through tools/ci/served-identity-guard.sh before the served tree is final; exit 1 is a hit, exit 2 no private list, both refuse.
|
||
// Until the shared guard is in the tree the build says so and goes on, so a landing before it is not held by it.
|
||
guardServed([DL_SNAPSHOT, join(here, 'release-manifest.json')]);
|
||
|
||
built.push(`downloads (${downloads.source}: ${Object.keys(downloads.files || {}).join(', ') || 'none'})`);
|
||
|
||
// the evidence page (/evidence): the claims table, the counts and the date are rendered from docs/evidence.md (6 October 2026);
|
||
// the page's prose stays in site/evidence.html, the rows are judgement edited in the markdown and follow from there.
|
||
function renderEvidence(html) {
|
||
const mdp = join(docs, 'evidence.md');
|
||
if (!existsSync(mdp)) return html;
|
||
const src = readFileSync(mdp, 'utf8');
|
||
const start = src.indexOf('\n| # | Claim |'); const end = src.indexOf('\n## Count by status');
|
||
if (start < 0 || end < 0) throw new Error('evidence.md: claims table not found');
|
||
const rows = src.slice(start, end).split('\n').filter(l => /^\| \d+ \|/.test(l));
|
||
const LABELS = ['designed', 'implemented', 'activated', 'tested by the team', 'reproduced externally', 'reviewed independently'];
|
||
const inl = t => esc(t.trim()).replace(/`([^`]+)`/g, (m, c) => `<code>${c}</code>`);
|
||
const cells = l => l.replace(/^\| /, '').replace(/ \|$/, '').split(' | ');
|
||
const counts = Object.fromEntries(LABELS.map(k => [k, 0]));
|
||
const tr = rows.map(l => {
|
||
const c = cells(l); if (c.length !== 8) throw new Error(`evidence.md: row ${c[0]} has ${c.length} cells`);
|
||
const [n, claim, where, status, ver, test, result, iv] = c;
|
||
const idx = LABELS.findIndex(k => status.toLowerCase().includes(k)); if (idx < 0) throw new Error(`evidence.md: row ${n} status "${status}"`);
|
||
counts[LABELS[idx]]++;
|
||
return `<tr data-status="${LABELS[idx]}"><td class="n">${n}</td><td class="claim">${inl(claim)}<div class="where">${inl(where)}</div></td><td><span class="st st-${idx}">${inl(status)}</span></td><td class="mono">${inl(ver)}</td><td>${inl(test)}</td><td>${inl(result)}</td><td class="iv">${inl(iv)}</td></tr>`;
|
||
}).join('\n');
|
||
// the same scrub as the generated pages: local-time stamps to UTC and the private-string check (the build fails on any hit)
|
||
const trs = scrubBench(tr);
|
||
html = html.replace(/(<table id="claims">[\s\S]*?<tbody>)[\s\S]*?(<\/tbody>)/, (m, a, b) => `${a}\n${trs}\n${b}`);
|
||
for (const k of LABELS) {
|
||
html = html.replace(new RegExp(`(<div class="label"><div class="k">${k}</div><div class="v">)\\d+(</div>)`), `$1${counts[k]}$2`);
|
||
html = html.replace(new RegExp(`(data-filter="${k}"><b>)\\d+(</b>)`), `$1${counts[k]}$2`);
|
||
}
|
||
html = html.replace(/(data-filter=""><b>)\d+(<\/b>)/, `$1${rows.length}$2`);
|
||
const d = new Date(); const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
const day = `${d.getUTCDate()} ${MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
||
const dayLong = `${d.getUTCDate()} ${['January','February','March','April','May','June','July','August','September','October','November','December'][d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
||
html = html.replace(/<div class="eyebrow">(?:<span class="line"><\/span>)?\d+ claims · \d+ labels · [^<]*<\/div>/, `<div class="eyebrow"><span class="line"></span>${rows.length} claims · ${LABELS.length} labels · ${day}</div>`);
|
||
html = html.replace(/<p class="asof">Statuses are honest as of [^<]*<\/p>/, `<p class="asof">Statuses are honest as of ${dayLong}, the day this page was generated from docs/evidence.md, and change only through that file.</p>`);
|
||
return html;
|
||
}
|
||
|
||
// The share block of a hand-written page (8 October 2026): written between <!-- share:start --> and <!-- share:end --> from
|
||
// site/og/pages.mjs; a page that still carries hand-pasted og: and twitter: metas has them replaced by the marked block once,
|
||
// and a page with neither gets the block ahead of the head partial. The committed page carries the result, as with the head.
|
||
function injectShare(html, file) {
|
||
const path = file === 'index.html' ? '/' : '/' + file.replace(/\.html$/, '');
|
||
const fallbackTitle = (/<title>([^<]*)<\/title>/.exec(html) || [, 'Igneum'])[1].replace(/&/g, '&');
|
||
const fallbackDesc = (/<meta name="description" content="([^"]*)"/.exec(html) || [, ''])[1].replace(/&/g, '&').replace(/"/g, '"');
|
||
const block = `<!-- share:start -->\n${shareHtml(path, fallbackTitle, fallbackDesc)}\n<!-- share:end -->`;
|
||
const marked = /<!-- share:start -->[\s\S]*?<!-- share:end -->/;
|
||
if (marked.test(html)) return html.replace(marked, () => block);
|
||
const loose = /(?:<meta (?:property="og:|name="twitter:)[^>]*>\s*\n?)+/;
|
||
if (loose.test(html)) return html.replace(loose, () => block + '\n');
|
||
if (!/<!-- head:start -->/.test(html)) throw new Error(`${file}: no share block, no og metas and no head marker to put one before`);
|
||
return html.replace('<!-- head:start -->', () => block + '\n<!-- head:start -->');
|
||
}
|
||
|
||
const PAGES = [['index.html', ''], ['download.html', 'download'], ['income.html', 'income'], ['economics.html', 'economics'], ['scorecard.html', 'scorecard'], ['prize.html', 'prize'], ['docs.html', 'docs'], ['acceptance.html', 'acceptance'], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['app.html', 'app'], ['wallet.html', 'wallet'], ['ledger.html', 'ledger'], ['metamask.html', 'metamask'], ['faucet.html', 'faucet'], ['swap.html', 'swap'], ['404.html', ''],
|
||
// the Devnet 3 explorer (5 Oct 2026, extended 8 Oct 2026): /explorer, /block/<hash>, /address/<addr>, /tx/<hash> (vercel.json rewrites
|
||
// the three) and /proving; the Explorer entry of the Network panel is the current one on all five
|
||
['explorer.html', 'explorer'], ['block.html', 'explorer'], ['address.html', 'explorer'], ['tx.html', 'explorer'], ['proving.html', 'explorer'],
|
||
// the scenes lab (6 Oct 2026): three animated views of the live feed, unlinked, noindex
|
||
['scenes.html', ''],
|
||
// the reference apps (8 Oct 2026): the light wallet, the payment receipt, the Sepolia state oracle
|
||
['light.html', 'light'], ['receipt.html', 'receipt'], ['oracle.html', 'oracle']];
|
||
for (const [file, active] of PAGES) {
|
||
const p = join(here, file);
|
||
if (!existsSync(p)) throw new Error(`missing page ${file}`);
|
||
let html = readFileSync(p, 'utf8');
|
||
html = injectShare(html, file);
|
||
html = inject(html, 'head', HEAD, file);
|
||
html = inject(html, 'nav', navFor(active), file);
|
||
html = inject(html, 'footer', FOOT, file);
|
||
if (html.includes('<!-- terms:start -->')) html = inject(html, 'terms', TERMS, file);
|
||
html = stampProduct(html, file);
|
||
html = stampDownloads(html, file, downloads);
|
||
html = stampMarks(html);
|
||
if (file === 'evidence.html') html = renderEvidence(html);
|
||
// /economics, "Who pays for proving" (8 October 2026, an accepted external review): the model table is computed here from
|
||
// site/lib/emission.mjs (the node's constants) and the release manifest, never typed; the measured rows on the page are the
|
||
// observer's proving view as read that day. Labels: the per-day pool is modelled from the schedule; the per-shard and per-key
|
||
// lines hold today's measured shard and key counts constant; nothing here is a price.
|
||
if (file === 'economics.html' && html.includes('<!-- proving-model:start -->')) {
|
||
const em = await import('./lib/emission.mjs');
|
||
const ign = (s) => Number(em.sompiToIgn(s));
|
||
const fmt = (n, d = 2) => n.toLocaleString('en-GB', { maximumFractionDigits: d, minimumFractionDigits: d });
|
||
const PV = RM.proving_view; // the measured 24-hour read on the page (shards paid, IGN paid, keys, planned)
|
||
const daa = Number(PV.read_daa);
|
||
const poolShare = Number(RM.fees.subsidy_split.proving_pool_percent) / 100;
|
||
const rows = [];
|
||
const line = (label, subsidyIgn, when, note) => {
|
||
const poolBlock = subsidyIgn * poolShare, poolDay = poolBlock * 86400;
|
||
rows.push(`<tr><td>${label}</td><td class="num">${fmt(subsidyIgn, 3)}</td><td class="num">${fmt(poolBlock, 3)}</td><td class="num">${fmt(poolDay, 0)}</td><td class="num">${fmt(poolDay / PV.shards_planned_24h, 2)}</td><td class="num">${fmt(poolDay / 24 / PV.prover_keys_24h, 1)}</td><td>${when}${note ? `; ${note}` : ''}</td></tr>`);
|
||
};
|
||
line('Today, inside the launch ramp', ign(em.blockSubsidy(daa)), `DAA ${daa.toLocaleString('en-GB')}, ${(em.rampFactor(daa) * 100).toFixed(1)} percent of the full rate`, 'modelled; the measured payout is in the row above');
|
||
const names = ['Full rate (years 1 to 2)', 'After the first halving (years 3 to 4)', 'After the second (years 5 to 6)', 'After the third (years 7 to 8)', 'After the fourth (years 9 to 10)', 'After the fifth (years 11 to 12)'];
|
||
names.forEach((n, p) => line(n, ign(em.subsidyPerSecond(p)), `period ${p} of the schedule`, p === 0 ? 'from day 30' : ''));
|
||
const table = `<div class="tbl"><table><thead><tr><th>When</th><th>Subsidy per block, IGN</th><th>Pool per block, IGN</th><th>Pool per day, IGN</th><th>Per planned shard, IGN</th><th>Per prover key per hour, IGN</th><th>Basis</th></tr></thead><tbody>${rows.join('')}</tbody></table></div>
|
||
<p class="src"><b>Modelled</b> from <code>emission.rs</code> (the schedule as <code>site/lib/emission.mjs</code> carries it) and the release manifest at build time, with today's measured shard count (${PV.shards_planned_24h.toLocaleString('en-GB')} planned in 24 hours) and key count (${PV.prover_keys_24h}) held constant; the per-shard figure is the pool's credit per planned shard, the per-key figure the pool per day divided by the keys and by 24. More shards or more keys lower both; a block's unproven shards leave their credit in the escrow.</p>`;
|
||
html = inject(html, 'proving-model', table, file);
|
||
}
|
||
html = stampDevnet(fillManifest(html));
|
||
// /income (8 October 2026): the calculator's card list is the bench table's current rows, stock and tuned apart, inlined
|
||
// at build so the page needs no fetch; a row's watts carry their class when they were read under another (watts_class)
|
||
if (file === 'income.html' && html.includes('<!-- income-data:start -->')) {
|
||
const bj = JSON.parse(readFileSync(join(here, 'miner-bench.json'), 'utf8'));
|
||
const tunedOf = (r) => /Ember Tune|core lock/.test(r.tuned || '') ? 'tuned' : 'stock';
|
||
const list = bj.rows.filter(r => r.generator === 'v2' && r.date >= '2026-10-06' && r.group !== 'datacentre' && r.mh_s > 0)
|
||
// a tuned row's rate is the tune point's (the display reads "136.1 stock (127.7 tuned)"), never the stock best beside tuned watts
|
||
.map(r => { const t = tunedOf(r) === 'tuned' && /\(([\d.]+) tuned\)/.exec(r.mh_s_display || ''); const mhs = t ? Number(t[1]) : r.mh_s; return { key: r.card + '|' + r.date + '|' + tunedOf(r), card: r.card.replace(/ \(.*$/, ''), mh_s: mhs, mh_s_display: t ? `${mhs} MH/s tuned` : (r.mh_s_display || null), watts: r.watts ?? null, watts_class: r.watts_class || null, state: tunedOf(r), by: r.by, date: r.date }; })
|
||
.sort((a, b) => b.mh_s - a.mh_s);
|
||
html = inject(html, 'income-data', `<script type="application/json" id="income-data">${JSON.stringify(list).replace(/</g, '\\u003c')}</script>`, file);
|
||
}
|
||
// /acceptance (8 October 2026): the Test and Acceptance Standard, case by case, rendered from the registry by
|
||
// site/lib/acceptance-render.mjs (the same functions the page prints into its runtime script, which re-reads the registry on
|
||
// the public git host through /api/acceptance every 60 seconds); the gate builds a copy of site/ alone: the committed page stands
|
||
if (file === 'acceptance.html' && html.includes('<!-- acceptance:start -->')) {
|
||
const rp = join(docs, 'plans', 'igneum-2.0-test-registry.json');
|
||
// the zone name is a forbidden string on a served page (the identity list): the registry's times read UK here
|
||
if (existsSync(rp)) html = inject(html, 'acceptance', renderAcceptance(JSON.parse(readFileSync(rp, 'utf8').replace(/\bBST\b/g, 'UK').replace(/\bPC [12]\b/g, 'the project’s own rig').replace(/Devnet 3\b/g, 'the earlier devnet'))), file); // the identity list: no zone name, no numbered rig, no numbered devnet on a served page
|
||
}
|
||
writeFileSync(p, html);
|
||
built.push(file);
|
||
}
|
||
|
||
// the public bench table (/miners): one row per card, generator version and miner version, from site/miner-bench.json
|
||
// (the bench-log numbers that exist today, and rows later jobs append); rendered through the same scrub as the generated pages.
|
||
// 7 October 2026 (the founder): the CURRENT class is the table (class v4, or class v3 re-measured with its v4 cost, 6 October
|
||
// on); cards you can buy first by hash rate, the datacentre rows collapsed, the earlier classes collapsed; one row per
|
||
// card; the card's vendor mark and name lead, the rate is the big tabular number, MH per wall watt beside it in the ember
|
||
// tint for the best desktop card, watts and the class v4 cost quiet, the tuned state a pill, the date muted mono; the
|
||
// Hive flight-sheet values, the source and the note in a row that opens on a tap; headers sort with an arrow glyph, the
|
||
// active sort underlined in ember; 56 px rows on desktop, a two-line card per row on a phone.
|
||
{
|
||
const bj = JSON.parse(readFileSync(join(here, 'miner-bench.json'), 'utf8'));
|
||
const isCurrent = (r) => r.generator === 'v2' && r.date >= '2026-10-06';
|
||
const byRate = (a, b) => (b.mh_s - a.mh_s) || (a.card < b.card ? -1 : 1);
|
||
const curAll = bj.rows.filter(isCurrent).sort(byRate);
|
||
const cur = curAll.filter(r => r.group !== 'datacentre');
|
||
const dcRows = curAll.filter(r => r.group === 'datacentre');
|
||
const rows = bj.rows;
|
||
const fmt = (n) => Number(n).toLocaleString('en-GB', { maximumFractionDigits: 1 });
|
||
const fmt3 = (n) => Number(n).toLocaleString('en-GB', { maximumFractionDigits: 3 });
|
||
const best = cur[0];
|
||
const shortV4 = (r) => { if (r.v4_short) return r.v4_short; const v = r.v4_cost || 'not measured'; if (/^not measured/.test(v)) return 'not measured'; const m = v.match(/^([+-]?[\d.,]+\s*(?:W|percent)(?:\s+(?:unlocked|of rate))?)/); return m ? m[1].replace(' of rate', ' rate') : v.split(/[,;(]| for | at /)[0].trim(); };
|
||
const tunedState = (r) => { const v = r.tuned || 'stock, mining'; if (/Ember Tune|core lock/.test(v)) return ['tuned', 'Tuned']; if (/no lever/.test(v)) return ['nolever', 'No lever']; return ['stock', 'Stock']; };
|
||
const effCell = (r) => {
|
||
if (r.mh_per_w == null) return ['not measured', -1];
|
||
if (r.watts_class === 'v3') return ['<span class="hollow" title="class v3 watts: the fleet\'s bench pack ran the class v3 program; the class v4 watts land with the rerun; not ranked">○ ' + fmt3(r.mh_per_w) + '</span>', -1];
|
||
if (r.watt_basis === 'chip') return ['<span class="hollow" title="chip watts (GPU plus DRAM), not wall; not ranked">○ ' + fmt3(r.mh_per_w) + '</span>', -1];
|
||
return [(best && r === best ? '<span class="eff best">' : '<span class="eff">') + fmt3(r.mh_per_w) + '</span>', r.mh_per_w];
|
||
};
|
||
const detail = (r) => {
|
||
const parts = [];
|
||
if (r.hive && r.hive.core_mhz != null) parts.push('<b>Hive flight sheet</b> core lock ' + esc(fmt(r.hive.core_mhz)) + ' MHz, mem ' + esc(fmt(r.hive.mem_mhz)) + ' MHz, PL ' + esc(fmt(r.hive.pl_w)) + ' W (' + esc(r.hive.label) + ')');
|
||
else parts.push('<b>Hive flight sheet</b> stock' + (r.hive && r.hive.label ? ' (' + esc(r.hive.label.replace(/^stock \(|\)$/g, '')) + ')' : ''));
|
||
parts.push('<b>Class v4 cost</b> ' + esc(r.v4_cost || 'not measured'));
|
||
parts.push('<b>Tuned</b> ' + esc(r.tuned || 'stock, mining'));
|
||
parts.push('<b>Generator</b> ' + esc(r.generator) + (r.driver_os ? ' · <b>Driver</b> ' + esc(r.driver_os) : ''));
|
||
parts.push('<b>Source</b> ' + esc(r.source) + ' · <b>Who</b> ' + esc(r.by));
|
||
if (r.note) parts.push('<b>Note</b> ' + esc(r.note));
|
||
return parts.map(x => '<span class="d">' + x + '</span>').join('');
|
||
};
|
||
const heads = [['Card', 'card', 'text'], ['MH/s', 'mh_s', 'num'], ['MH per wall watt', 'mh_per_w', 'num'], ['Watts', 'watts', 'num'], ['Tuned', 'tuned', 'text'], ['', 'more', 'none']];
|
||
const render = (list, id) => {
|
||
const body = list.map((r, i) => {
|
||
const [effHtml, effV] = effCell(r); const [tcls, tlabel] = tunedState(r);
|
||
const sub = '<span class="gen">class v4 ' + esc(shortV4(r)) + ' <span class="dot">\u00B7</span> <span class="date">' + esc(r.date) + '</span></span>';
|
||
const cells = [
|
||
['lead', r.card, esc(r.card) + sub],
|
||
['big tnum', r.mh_s, esc(r.mh_s_display || fmt(r.mh_s))],
|
||
['tnum effc', effV, effHtml],
|
||
['quiet tnum', r.watts ?? -1, r.watts_display ? esc(r.watts_display) + ' W' : (r.watts == null ? 'not read' : esc(fmt(r.watts)) + ' W' + (r.watt_basis === 'chip' ? ' <span class="basis">chip, not wall</span>' : ''))],
|
||
['pillc', tlabel, '<span class="pill state ' + tcls + '">' + tlabel + '</span>'],
|
||
['morec', '', '<button type="button" class="more" aria-expanded="false" aria-controls="' + id + '-d' + i + '">Details</button>'],
|
||
];
|
||
return '<tr class="row">' + cells.map(([c, v, h]) => `<td class="${c}" data-v="${esc(String(v))}">${h}</td>`).join('') + '</tr>' +
|
||
`<tr class="detail" id="${id}-d${i}" hidden><td colspan="${heads.length}">${detail(r)}</td></tr>`;
|
||
}).join('');
|
||
return '<div class="tbl bench2wrap"><table class="bench2" id="' + id + '"><thead><tr>' +
|
||
heads.map(([h, k, t]) => t === 'none' ? '<th></th>' : `<th data-key="${k}" data-type="${t}" aria-sort="${k === 'mh_s' ? 'descending' : 'none'}"><button type="button" class="sort">${h}</button></th>`).join('') +
|
||
'</tr></thead><tbody>' + body + '</tbody></table></div>';
|
||
};
|
||
const table = render(cur, 'bench-current');
|
||
const dcTable = render(dcRows, 'bench-datacentre');
|
||
// the lede recomputes from the data (8 October 2026): the best desktop card by rate, then the best stock and the best tuned
|
||
// MH per wall watt as separate claims, so a rented stock row never reads as a tuned one and a tuned lock never reads as stock
|
||
// a row whose watts were read under another class (watts_class 'v3': the fleet's bench pack, 7 and 8 October 2026) is ranked by rate,
|
||
// never by MH per watt; the per-watt claims come from rows whose watts are class v4
|
||
const ranked = (list) => list.filter(r => r.mh_per_w != null && r.watt_basis !== 'chip' && !r.watts_class).sort((a, b) => b.mh_per_w - a.mh_per_w);
|
||
const bestStock = ranked(cur.filter(r => tunedState(r)[0] === 'stock'))[0];
|
||
const bestTuned = ranked(cur.filter(r => tunedState(r)[0] === 'tuned'))[0];
|
||
const name = (r) => esc(r.card.replace(/ \(.*$/, ''));
|
||
// the rented model sweep of 8 October 2026 (23 rows on 22 models, every class v5 fingerprint matched): its two page-level findings
|
||
const sweepLine = `<p class="lede sweep">The rented model sweep of 8 October 2026 (23 rows, 22 models, every row untuned) found the rate cap-proof: a card on a host power cap gives the same MH/s as an uncapped one. Class v5 held the rate on every uncapped card and read 17 to 18 percent under it on the two power-capped hosts (an A4000 at 114 W, a GTX 1080 Ti at 130 W), so a class v5 rate needs the card's power state beside it. The sweep's watts, and the 7 October fleet rows' watts, are class v3 figures: the fleet's bench pack ran the class v3 program, and class v4 draws more for the same rate (a 5070 Ti measured 78.7 MH/s at 141 W on class v3 and 78.8 at 224 W on class v4, 8 October 2026). Those rows are ranked by rate alone; the per-watt claims above come from rows measured under class v4; the class v4 watts rerun lands on each row with its date, the v3 watts kept beside it as the shadow premium per card.</p>`;
|
||
const bestLine = best ? `<p class="lede"><strong>Best desktop card:</strong> ${name(best)}, ${esc(fmt(best.mh_s))} MH/s (measured, ${esc(best.date)}).`
|
||
+ (bestStock ? ` <strong>Best stock MH per wall watt:</strong> ${name(bestStock)}, ${esc(fmt3(bestStock.mh_per_w))} at ${esc(fmt(bestStock.watts))} W, untuned (${esc(bestStock.by)}, ${esc(bestStock.date)}).` : '')
|
||
+ (bestTuned ? ` <strong>Best tuned:</strong> ${name(bestTuned)}, ${esc(fmt3(bestTuned.mh_per_w))} at ${esc(fmt(bestTuned.watts))} W (measured, ${esc(bestTuned.date)}).` : '') + '</p>' : '';
|
||
const sortScript = `<script>
|
||
(function(){
|
||
document.querySelectorAll('table.bench2').forEach(function(t){
|
||
var ths=t.querySelectorAll('th[data-key]'),tb=t.querySelector('tbody');
|
||
ths.forEach(function(th){var i=Array.prototype.indexOf.call(th.parentNode.children,th);th.querySelector('button').addEventListener('click',function(){
|
||
var cur=th.getAttribute('aria-sort'),dir=cur==='descending'?'ascending':'descending',num=th.getAttribute('data-type')==='num';
|
||
ths.forEach(function(o){o.setAttribute('aria-sort','none');});th.setAttribute('aria-sort',dir);
|
||
var rows=Array.prototype.slice.call(tb.querySelectorAll('tr.row'));
|
||
var pairs=rows.map(function(r){return [r, r.nextElementSibling && r.nextElementSibling.classList.contains('detail') ? r.nextElementSibling : null];});
|
||
pairs.sort(function(a,b){var x=a[0].children[i].getAttribute('data-v'),y=b[0].children[i].getAttribute('data-v');var c=num?(parseFloat(x)-parseFloat(y)):x.localeCompare(y,'en');return dir==='descending'?-c:c;});
|
||
pairs.forEach(function(p){tb.appendChild(p[0]); if(p[1]) tb.appendChild(p[1]);});
|
||
});});
|
||
t.querySelectorAll('button.more').forEach(function(b){b.addEventListener('click',function(){var d=document.getElementById(b.getAttribute('aria-controls'));if(!d)return;var open=d.hasAttribute('hidden');if(open)d.removeAttribute('hidden');else d.setAttribute('hidden','');b.setAttribute('aria-expanded',open?'true':'false');b.textContent=open?'Close':'Details';});});
|
||
});
|
||
})();
|
||
</script>`;
|
||
const sortStyle = `<style>
|
||
.lede{font-size:18px;line-height:1.5;color:var(--bone);margin:0 0 var(--s-4)}
|
||
.grp{font:500 10px/1.4 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);margin:var(--s-5) 0 10px;padding-bottom:8px;border-bottom:1px solid var(--line)}
|
||
details.grp summary{cursor:pointer;list-style:none}details.grp summary::-webkit-details-marker{display:none}details.grp summary::after{content:" \\u25BE";opacity:.6}details.grp[open] summary::after{content:" \\u25B4"}
|
||
.tbl.bench2wrap{overflow-x:auto;border:0;background:none;border-radius:0;max-width:100%}
|
||
table.bench2{min-width:0;width:100%;border-collapse:collapse;font-size:14px;line-height:1.4}
|
||
table.bench2 th{font:500 10px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);text-align:left;padding:0 12px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
|
||
table.bench2 th .sort{all:unset;cursor:pointer;font:inherit;color:inherit;letter-spacing:inherit;text-transform:inherit;padding-bottom:3px;border-bottom:2px solid transparent}
|
||
table.bench2 th .sort::after{content:" \\u2195";opacity:.4}
|
||
table.bench2 th[aria-sort="descending"] .sort,table.bench2 th[aria-sort="ascending"] .sort{color:var(--bone);border-bottom-color:var(--ember)}
|
||
table.bench2 th[aria-sort="descending"] .sort::after{content:" \\u2193";opacity:1;color:var(--ember)}table.bench2 th[aria-sort="ascending"] .sort::after{content:" \\u2191";opacity:1;color:var(--ember)}
|
||
table.bench2 tr.row td{height:56px;padding:0 12px;border-bottom:1px solid var(--line);background:none;vertical-align:middle;white-space:nowrap;color:var(--ink-2)}
|
||
table.bench2 tr.row:hover td{background:var(--hover)}
|
||
table.bench2 td.lead{color:var(--bone)}@media(min-width:1101px){table.bench2 tr.row td.lead{white-space:normal;min-width:220px;max-width:360px;padding-top:8px;padding-bottom:8px}}
|
||
table.bench2 td.lead .cardname{display:inline-flex;align-items:center;gap:10px}
|
||
table.bench2 td.lead .gen{display:block;font:12px var(--mono);color:var(--ash);letter-spacing:.02em;margin-top:3px;white-space:normal;overflow-wrap:anywhere}table.bench2 td.lead .gen .dot{color:var(--line-2);margin:0 4px}
|
||
table.bench2 td.big{font:600 22px/1 var(--f-display,var(--f-sans));color:var(--bone);font-variant-numeric:tabular-nums}
|
||
table.bench2 td.tnum{font-variant-numeric:tabular-nums}
|
||
table.bench2 .eff{font:500 15px/1 var(--f-sans);color:var(--ink-2)}table.bench2 .eff.best{color:var(--ember-text,var(--ember));font-weight:600}
|
||
table.bench2 .hollow{color:var(--ash)}
|
||
table.bench2 td.quiet{color:var(--ash);font-size:13px}table.bench2 .basis{font:10px var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--ash);margin-left:6px}
|
||
table.bench2 .pill.state{padding:5px 8px;font-size:9px}table.bench2 .pill.state.tuned{color:var(--ember-text,var(--ember));border-color:color-mix(in srgb,var(--ember) 35%,transparent);background:color-mix(in srgb,var(--ember) 8%,transparent)}table.bench2 .pill.state.stock{color:var(--ash)}table.bench2 .pill.state.nolever{color:var(--ash);border-style:dashed}
|
||
table.bench2 button.more{all:unset;cursor:pointer;font:500 10px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);padding:6px 0;border-bottom:1px solid var(--line-2)}table.bench2 button.more:hover{color:var(--bone)}
|
||
table.bench2 tr.detail td{padding:12px 12px 16px;border-bottom:1px solid var(--line);font-size:13px;line-height:1.55;color:var(--ash);white-space:normal;overflow-wrap:anywhere}
|
||
table.bench2 tr.detail .d{display:block;margin:0 0 4px}table.bench2 tr.detail b{color:var(--ink-2);font-weight:600;margin-right:6px}
|
||
@media(max-width:1100px){
|
||
table.bench2,table.bench2 tbody,table.bench2 tr.detail,table.bench2 tr.detail td{display:block}
|
||
table.bench2 thead{display:none}
|
||
table.bench2 tr.row{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"lead big" "eff eff" "w pill" "more more";gap:4px 12px;padding:10px 0;min-height:44px;border-bottom:1px solid var(--line);align-items:center}
|
||
table.bench2 tr.row td{display:block;height:auto;padding:0;border:0;white-space:normal;min-width:0}
|
||
table.bench2 tr.row td.lead{grid-area:lead;max-width:none;min-width:0}table.bench2 td.lead .cardname{display:flex;align-items:center;gap:8px}table.bench2 td.lead .cardname .badge{flex:none}
|
||
table.bench2 td.big{grid-area:big;font-size:20px;text-align:right;white-space:nowrap}
|
||
table.bench2 td.effc{grid-area:eff;font-size:13px}table.bench2 td.effc .eff::before,table.bench2 td.effc .hollow::before{content:"MH per wall watt ";color:var(--ash);font-weight:400}
|
||
table.bench2 td.quiet.tnum{grid-area:w}table.bench2 td.pillc{grid-area:pill;text-align:right}
|
||
table.bench2 td.morec{grid-area:more;text-align:right}
|
||
table.bench2 tr.detail td{padding:8px 0 14px}
|
||
}
|
||
</style>`;
|
||
// Ember Tune's fleet priors (site/miner-priors.json, tools/tuning.mjs --priors --site): one row per card model,
|
||
// driver major and program class; a row under the sample floor shows its count and no point
|
||
const pj = JSON.parse(readFileSync(join(here, 'miner-priors.json'), 'utf8'));
|
||
const prows = (pj.rows || []).slice().sort((a, b) => (b.samples - a.samples) || (a.card < b.card ? -1 : 1));
|
||
const pcell = r => [
|
||
r.card, r.driver_major, r.class, r.samples + (r.machines ? ' from ' + r.machines + ' machine' + (r.machines === 1 ? '' : 's') : ''),
|
||
r.prior ? (r.clock_mhz ? fmt(r.clock_mhz) + ' MHz at ' + r.power_pct + '%' : r.power_pct + '%, clock unlocked') : 'under the floor (' + pj.min_samples + ' needed)',
|
||
r.mh_per_w == null ? 'not yet' : Number(r.mh_per_w).toFixed(3) + (r.spread_pct != null ? ' (spread ' + r.spread_pct + '%)' : ''),
|
||
r.mh_s == null ? '' : fmt(r.mh_s) + ' MH/s at ' + fmt(r.watts) + ' W',
|
||
r.untuned_mh_per_w == null ? 'not measured' : Number(r.untuned_mh_per_w).toFixed(3) + (r.gain_pct != null ? ' (' + (r.gain_pct >= 0 ? '+' : '') + r.gain_pct + '%)' : ''),
|
||
];
|
||
const ptable = prows.length
|
||
? '<div class="tbl"><table><thead><tr>' + ['Card', 'Driver', 'Program class', 'Samples', 'Tuned point', 'MH per watt', 'Rate and draw', 'Untuned MH per watt (gain)'].map(h => `<th>${h}</th>`).join('') + '</tr></thead><tbody>' +
|
||
prows.map(r => '<tr>' + pcell(r).map(c => `<td>${esc(String(c))}</td>`).join('') + '</tr>').join('') + '</tbody></table></div>'
|
||
: '<p>No tune reports yet. The first rows appear once five machines with the same card model have reported.</p>';
|
||
const body = scrubBench([
|
||
sortStyle,
|
||
'<h2 id="table">The table</h2>',
|
||
bestLine,
|
||
sweepLine,
|
||
'<p>One row per card on the current class: the class v4 program (the latency-shadow block over the class v3 hash), or a class v3 row re-measured with its class v4 cost on 6 October 2026 or later. MH per wall watt uses board or wall power; a row whose watts are the chip\'s (Apple silicon: GPU plus DRAM from IOReport) says so and is not ranked on that column. Integrated GPUs are not listed.</p>',
|
||
'<h3 class="grp" id="buy">Cards you can buy</h3>',
|
||
table,
|
||
`<p class="gen">${cur.length} cards. Details opens a card\'s Hive flight-sheet values, its full class v4 cost, source and note.</p>`,
|
||
'<details class="grp"><summary>Datacentre (' + dcRows.length + ' cards, rented for the measurement; about three times the rented dollars per hash of a desktop card)</summary>',
|
||
dcTable,
|
||
'</details>',
|
||
'<h2 id="how">How a row gets here</h2>',
|
||
'<p>Every row names its entry in the measurement record in the repository (docs/bench-log.md) or the job it came from. "Measured by the team" means our own hardware and our own record. "Reported by the fleet" or "measured by the fleet" means a machine we rent or do not own, read from the status lines its miner uploads or from a bench run on it.</p>',
|
||
'<p>MH per watt needs the card\'s power draw during the run. The app reads it on NVIDIA cards through the driver. Rows get the figure when a run records it. The class v4 cost column is what the latency-shadow work costs that card against the class v3 control, in watts and rate, where it was measured. The tuned column is the card\'s state at the row: a full Ember Tune names its point; stock means the card as it came, bench only or mining.</p>',
|
||
'<p>There is no other Igneum miner to compare with yet, so this table compares cards, not miners. The app that produces these rows: <a href="/miner">the miner page</a>.</p>',
|
||
sortScript,
|
||
'<h2 id="priors">Fleet tuning priors</h2>',
|
||
'<p>Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet\'s median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs ' + pj.min_samples + ' reports before its prior is used.</p>',
|
||
ptable,
|
||
// the team's own measured rows (site/miner-priors.json "team"): the per-user sentence per card, with its job
|
||
...((pj.team || []).length ? ['<h3 id="team">Measured by the team</h3>', '<ul>' + pj.team.map(t => `<li><b>${esc(t.card)}</b> (driver ${esc(t.driver_major)}, class ${esc(t.class)}, ${esc(t.date)}, job <code>${esc(t.job)}</code>): ${esc(t.point)}. ${esc(t.sentence)}${t.note ? ' ' + esc(t.note) : ''}</li>`).join('') + '</ul>'] : []),
|
||
`<p>Rows: ${prows.length}${pj.generated ? ', generated ' + pj.generated : ''}. Written from the fleet's tune records at build time.</p>`,
|
||
].join('\n'));
|
||
const toc = [{ lvl: 2, t: 'The table', id: 'table' }, { lvl: 2, t: 'How a row gets here', id: 'how' }, { lvl: 2, t: 'Fleet tuning priors', id: 'priors' }];
|
||
writeFileSync(join(here, 'miners.html'), page('Igneum GPU bench table', 'Measured Igneum hash rates per GPU on the current program: card, best MH/s, MH per wall watt where measured, date and the record each number came from.', body, toc,
|
||
'Measured hash rates per card on the Igneum lottery hash, on the current program, with the date and the record behind each number.',
|
||
{ path: '/miners', heading: 'GPU bench table', eyebrow: `${cur.length} cards you can buy, ${dcRows.length} datacentre, ${prows.length} fleet tuning models`, crumb: 'GPU bench table' }));
|
||
built.push('miners.html (' + rows.length + ' rows)');
|
||
}
|
||
|
||
// The secondary pages (7 Oct 2026, the redesign): each one is rendered from ONE source so no sentence is kept twice by hand.
|
||
// /claims and /randomx are the litepaper's own sections (their ledger sentences stay on the litepaper, which is what the
|
||
// ledger text check reads); /dev-fee is the miner page's fee section; /provenance is docs/provenance.md through the same
|
||
// scrub as the measurement record. A page with no source is not written.
|
||
function shell({ title, desc, path, crumb, eyebrow, heading, lead, body, style = '', after = '' }) {
|
||
const active = path.replace(/^\//, '');
|
||
return stampMarks(`<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
${meta(title, desc, path)}
|
||
<!-- head:start -->
|
||
${HEAD}
|
||
<!-- head:end -->
|
||
<style>
|
||
/* derived-page block (rendered by the build from its source page or document); tokens, type and components are in /site.css */
|
||
[id]{scroll-margin-top:calc(var(--nav-h) + 16px)}
|
||
.page-hero .lead{max-width:72ch}
|
||
.derived{max-width:78ch;color:var(--ink-2)}
|
||
.derived h2{font-size:var(--t-h3);margin:var(--s-6) 0 var(--s-3)}
|
||
.derived h3{font-size:16px;margin:var(--s-5) 0 var(--s-2)}
|
||
.derived ul,.derived ol{margin:0 0 var(--s-4);padding-left:22px}
|
||
.derived li{margin-bottom:8px}
|
||
.derived strong{color:var(--bone);font-weight:600}
|
||
.derived p,.derived li{overflow-wrap:anywhere}
|
||
.derived .tbl{margin:var(--s-4) 0 var(--s-5)}
|
||
.derived .pull{margin:var(--s-5) 0;padding:var(--s-4) var(--s-5);border-left:2px solid var(--ember);background:var(--row);border-radius:0 var(--r-row) var(--r-row) 0;font:500 var(--t-lg)/1.45 var(--f-sans);color:var(--bone)}
|
||
.derived .src{margin:calc(-1 * var(--s-2)) 0 var(--s-5);font:400 13px/1.5 var(--f-mono);color:var(--ash)}
|
||
.derived .src b{font-weight:500;color:var(--ink-2)}
|
||
.onward{display:flex;flex-wrap:wrap;gap:var(--s-3);margin-top:var(--s-6);padding-top:var(--s-5);border-top:1px solid var(--line)}
|
||
${style}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- nav:start -->
|
||
${navFor(active)}
|
||
<!-- nav:end -->
|
||
<main id="main">
|
||
<section class="page-hero">
|
||
<div class="container">
|
||
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><span>${esc(crumb)}</span></div>
|
||
<div class="page-heading"><div><div class="eyebrow"><span class="line"></span>${eyebrow}</div>
|
||
<h1>${heading}</h1>
|
||
<p class="lead">${lead}</p></div></div>
|
||
</div>
|
||
</section>
|
||
<section class="section compact">
|
||
<div class="container">
|
||
${body}
|
||
${after}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
<!-- footer:start -->
|
||
${FOOT}
|
||
<!-- footer:end -->
|
||
<script src="/ui.js" defer></script>
|
||
</body>
|
||
</html>`);
|
||
}
|
||
// a section of a built page, its own h2 and section head removed, its in-page anchors pointed back at the source page
|
||
function sectionOf(file, id, base) {
|
||
const html = readFileSync(join(here, file), 'utf8');
|
||
const m = new RegExp(`<section id="${id}"[^>]*>([\\s\\S]*?)</section>`).exec(html);
|
||
if (!m) throw new Error(`${file}: no <section id="${id}">`);
|
||
let inner = m[1];
|
||
// the section head (eyebrow, h2, an optional lead) is removed by a balanced walk over its divs, never a lazy regex
|
||
// (7 Oct 2026: a lazy match ran on to the first </div></div> pair inside the fee card and emptied /dev-fee)
|
||
const hs = inner.indexOf('<div class="section-head">');
|
||
let lead = '';
|
||
if (hs >= 0) {
|
||
let depth = 0, i = hs; const re = /<div\b|<\/div>/g; re.lastIndex = hs; let m;
|
||
while ((m = re.exec(inner))) { depth += m[0] === '</div>' ? -1 : 1; if (depth === 0) { i = m.index + m[0].length; break; } }
|
||
if (depth !== 0) throw new Error(`${file}#${id}: unbalanced section head`);
|
||
const head = inner.slice(hs, i); const pm = /<p>([\s\S]*?)<\/p>\s*<\/div>\s*$/.exec(head); lead = pm ? pm[1] : '';
|
||
inner = inner.slice(0, hs) + inner.slice(i);
|
||
}
|
||
inner = inner.replace(/^\s*<h2>[\s\S]*?<\/h2>/, '');
|
||
const ids = new Set([...inner.matchAll(/\sid="([^"]+)"/g)].map(x => x[1]));
|
||
inner = inner.replace(/href="#([^"]+)"/g, (all, frag) => ids.has(frag) ? all : `href="${base}#${frag}"`);
|
||
return { html: inner.trim(), lead };
|
||
}
|
||
function onward(links) { return `<div class="onward">${links.map(([href, label]) => `<a href="${href}" class="btn">${esc(label)}</a>`).join('')}</div>`; }
|
||
{
|
||
const lp = sectionOf('litepaper.html', 'limits', '/litepaper');
|
||
writeFileSync(join(here, 'claims.html'), shell({
|
||
title: 'What Igneum does not claim', desc: 'The limits of Igneum, stated first: proof times, chips, income, finality in the first month, the review it has not had yet. From the litepaper, with the ledger behind it.',
|
||
path: '/claims', crumb: 'What Igneum does not claim', eyebrow: 'The limits, stated first', heading: 'What Igneum does not claim.',
|
||
lead: 'Every limit we know of, written down here before anyone else writes it. The same text as the litepaper’s last section; the ledger carries every criticism in full.',
|
||
body: `<div class="derived">${lp.html}</div>`,
|
||
after: onward([['/ledger', 'The ledger: every criticism'], ['/evidence', 'The evidence page'], ['/litepaper#limits', 'Read it in the litepaper']]),
|
||
}));
|
||
built.push('claims.html (from litepaper#limits)');
|
||
const rx = sectionOf('litepaper.html', 'randomx', '/litepaper');
|
||
writeFileSync(join(here, 'randomx.html'), shell({
|
||
title: 'Igneum and RandomX', desc: 'Monero’s random-program idea, rebuilt for graphics cards: a program per hour instead of a program per hash, a dataset that grows, and what RandomX’s record does and does not say about Igneum.',
|
||
path: '/randomx', crumb: 'Igneum vs RandomX', eyebrow: 'Monero’s idea, finished for GPUs', heading: 'RandomX, rebuilt for GPUs.',
|
||
lead: 'Random-program mining is the idea Igneum borrowed. Here is what changed when it was rebuilt for graphics cards, and what RandomX’s seven years say and do not say about this chain. The same text as the litepaper’s section.',
|
||
body: `<div class="derived">${rx.html}</div>`,
|
||
after: onward([['/litepaper#mining', 'How mining works'], ['/litepaper#chip-model', 'The chip model'], ['/provenance', 'Built on the shoulders']]),
|
||
}));
|
||
built.push('randomx.html (from litepaper#randomx)');
|
||
const fee = sectionOf('miner.html', 'fee', '/miner');
|
||
writeFileSync(join(here, 'dev-fee.html'), shell({
|
||
title: 'The Ember dev fee, in full view', desc: 'The protocol carries no fee. The Ember software takes an optional 1% dev fee, the norm for GPU miners, visible in the app and off with one flag. Where it is, how it was measured, how to turn it off.',
|
||
path: '/dev-fee', crumb: 'The dev fee', eyebrow: 'The dev fee', heading: 'The fee, in full view.',
|
||
lead: fee.lead || 'The protocol carries no fee. The software fee is the miner app’s, like every other GPU miner’s, and one flag turns it off.',
|
||
body: fee.html,
|
||
style: '.feecard{display:grid;grid-template-columns:1fr;gap:var(--s-4)}@media (min-width:900px){.feecard{grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);align-items:start}}pre b{color:var(--molten-text);font-weight:500}',
|
||
after: onward([['/miner#get', 'Get the miner'], ['/litepaper#economics', 'The economics'], ['/evidence', 'Every claim and its status']]),
|
||
}));
|
||
built.push('dev-fee.html (from miner#fee)');
|
||
}
|
||
if (existsSync(join(docs, 'provenance.md'))) {
|
||
const src = scrubBench(readFileSync(join(docs, 'provenance.md'), 'utf8'));
|
||
const { html, toc } = md(src);
|
||
writeFileSync(join(here, 'provenance.html'), page('Igneum provenance: built on the shoulders', 'Every borrowed component of Igneum credited in public: the origin and licence, what changed, why the design needed it, and how the change is measured. What is new in Igneum, and what is adopted from upstream.', html, toc,
|
||
'Igneum credits every borrowed component in public, replaces only what its own design requires, and measures every change. This table is the record: the origin and licence, what changed, why, and the measurement behind it.',
|
||
{ path: '/provenance', heading: 'Built on the shoulders', eyebrow: 'Provenance · the decision of 3 October 2026', crumb: 'Built on the shoulders', filter: false }));
|
||
built.push('provenance.html (from docs/provenance.md)');
|
||
}
|
||
console.log('built: ' + built.join(', '));
|
||
// the header rule: every served page carries the full nav (tools/ci/site-nav-check.mjs); the build fails on a shorter one
|
||
// (the gate builds a copy of site/ alone and runs the check as its own line, so a missing tools/ is not a failure here)
|
||
{ const check = join(here, '..', 'tools', 'ci', 'site-nav-check.mjs'); if (existsSync(check)) { const { spawnSync } = await import('node:child_process'); const r = spawnSync(process.execPath, [check, here], { stdio: 'inherit' }); if (r.status !== 0) process.exit(r.status || 1); } }
|