# Conflicts: # CLAUDE.md # docs/plans/counter-asic-3-status.md # docs/plans/release-0.3.21.md # docs/plans/release-0.3.22.md # tools/ci/merge-to-master.sh # tools/ci/pre-push.sh # tools/ci/red-watch.mjs
652 lines
52 KiB
JavaScript
652 lines
52 KiB
JavaScript
// Igneum site build: renders the engineering log from the repository's markdown into a branded
|
||
// page and merges the log's dated entries into the journey feed. 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 { scrubBench } from './scrub.mjs';
|
||
import { MARKS, VENDORS, tokensCss, markHtml } from './lib/marks.mjs';
|
||
import { osHtml } from './lib/os-marks.mjs';
|
||
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>');
|
||
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; journey on the homepage).
|
||
// 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();
|
||
const HEAD = partial('head.html'), NAV = partial('nav.html'), FOOT = partial('footer.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. Same set as the hand-written pages (index, litepaper, live); keep them in step.
|
||
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">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:site_name" content="Igneum">
|
||
<meta property="og:title" content="${t}">
|
||
<meta property="og:description" content="${d}">
|
||
<meta property="og:url" content="${url}">
|
||
<meta property="og:image" content="${ORIGIN}/og.png?v=3">
|
||
<meta property="og:image:width" content="1200">
|
||
<meta property="og:image:height" content="630">
|
||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${t}">
|
||
<meta name="twitter:description" content="${d}">
|
||
<meta name="twitter:image" content="${ORIGIN}/og.png?v=3">
|
||
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||
<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">`;
|
||
}
|
||
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=")/);
|
||
return parts.filter(p => p.trim()).map(p => `<section class="doc-section">${p}</section>`).join('\n');
|
||
}
|
||
function page(title, desc, bodyHtml, toc, note, { path = '/bench', 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 engineering log, 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 = [];
|
||
if (existsSync(join(docs, 'bench-log.md'))) {
|
||
// the public /bench page renders a SCRUBBED copy (site/scrub.mjs; the build fails on any private string, R4.6.1)
|
||
const src = scrubBench(readFileSync(join(docs, 'bench-log.md'), 'utf8'));
|
||
const { html, toc } = md(src);
|
||
writeFileSync(join(here, 'bench.html'), page('Igneum engineering log', 'Every Igneum benchmark and test, with the hardware and the commands that produced it. Prototype numbers are labelled as such.', html, toc,
|
||
'Every measurement the project has made, newest at the bottom, written by the people and agents who ran it, with the commands and hardware. Prototype numbers are not mining numbers and say so.',
|
||
{ path: '/bench', heading: 'Engineering log', crumb: 'Engineering log' }));
|
||
built.push('bench.html');
|
||
// journey feed: dated headings become log entries. `short` is the homepage line (one clause, under 72 characters);
|
||
// SHORT holds hand-written lines keyed by the start of the heading, the rule below covers the rest.
|
||
const SHORT = [
|
||
['Generator version 2 adopted', 'Generator v2 adopted: every hash does 128 distinct reads'],
|
||
['Sim/economy', 'Economy simulation: mining versus proving under stress'],
|
||
['Difficulty rule under attack', 'Difficulty rule attacked seven ways'],
|
||
['Difficulty rule: timestamp attack fixed', 'Timestamp attack on the difficulty rule fixed'],
|
||
['Devnet-v4 integration', 'Devnet v4: nine branches merged into one node'],
|
||
['Per-identity hash rate', 'RTX 5090 hash-rate decay diagnosed: a miner bug, fixed'],
|
||
['Igneum-node devnet v2', 'Finality rule v2 live on a four-miner test network'],
|
||
['Execution layer devnet v3', 'EVM execution layer: identical state on three nodes'],
|
||
['Weak-program census', 'Census of 400,000 programs: redundant loads found'],
|
||
['First machine in the United States', 'First machine in the United States: a laptop on an integrated GPU, synced and voting'],
|
||
['Shard proving on the RTX 5090', 'Shard layer on the RTX 5090: a full shard proven in 10.9 s, a block aggregated in 2.2 s'],
|
||
['Proving v0 on the RTX 5090', 'First GPU proof of an Igneum block: 1.4 s on an RTX 5090'],
|
||
['Proving v0', 'First SP1 proof of an Igneum block, on a laptop CPU'],
|
||
['Windows node package', 'Windows node package: cross-compiled, two-peer sync'],
|
||
['R3.26 / M15', 'Cache-build attack closed: 10.6 s of rebuilds to 14 ms'],
|
||
['Difficulty controller', 'Igneum dual-lane difficulty rule built and simulated'],
|
||
['Devnet started four months', 'Devnet started four months ahead of plan'],
|
||
['First blocks mined over the network', 'First blocks from a Windows PC: RTX 5090 and AMD'],
|
||
['First devnet blocks on the real lottery', 'First devnet blocks on the real lottery hash'],
|
||
['Igneum-node devnet v0', 'Devnet v0: three nodes at one block per second'],
|
||
['Proto-opencl', 'OpenCL worker built and proven without AMD silicon'],
|
||
['AMD gfx1036', 'AMD integrated GPU runs the hash through OpenCL'],
|
||
['RTX 5090 through NVIDIA OpenCL', 'RTX 5090 through NVIDIA OpenCL'],
|
||
['RTX 5090 first run', 'RTX 5090 first run'],
|
||
['RTX 5090, memory-hard', 'RTX 5090 on the memory-hard dataset'],
|
||
['RTX 5090 ran two generated programs', 'RTX 5090 matches Apple silicon hash for hash, 192 of 192'],
|
||
['Fuzzing', 'Fuzzing: 1.3 million hashes, zero GPU to CPU mismatches'],
|
||
['Second hostile review', 'Second hostile review: two fatal flaws found and fixed'],
|
||
['First prototype of the random-program', 'First prototype of the lottery hash on Apple silicon'],
|
||
['Igneum named', 'Igneum named. Litepaper, brand and wallet designs published'],
|
||
['Finality floor', 'Finality floor raised to two thirds of all weight'],
|
||
['First hourly program swap on the live devnet', 'First live hourly swap: no pause on Mac, NVIDIA or AMD'],
|
||
['First finality lock on the live devnet', 'First live finality lock: 77.4% of weight, 17 voters'],
|
||
['First machine on the Igneum Miner app', 'First machine on the one-click app: a 5090 at 118 MH/s'],
|
||
['Difficulty rule v2 activated on the live devnet', 'Difficulty rule v2 switched on the live devnet by height, no fresh chain'],
|
||
['First outside machine on the devnet', 'First outside machine: a friend\'s Mac mining through the app'],
|
||
['Devnet v4 cut-over', 'Devnet v4 live: generator v2, two-thirds floor, fresh chain'],
|
||
];
|
||
function shortTitle(t) {
|
||
for (const [k, v] of SHORT) if (t.startsWith(k)) return v;
|
||
let s = t.split(/[:;]/)[0].trim();
|
||
if (s.length < 16) s = t.split(/[,;]/).slice(0, 2).join(',').trim();
|
||
if (s.length > 72) s = s.slice(0, 69).replace(/\s+\S*$/, '') + '…';
|
||
return s.replace(/[.,]$/, '');
|
||
}
|
||
const entries = [];
|
||
for (const m of src.matchAll(/^##+\s+(\d{1,2}\s+\w+\s+\d{4})[,:]?\s*(.*)$/gm)) {
|
||
const d = new Date(m[1] + ' 12:00:00 UTC'); if (isNaN(d)) continue;
|
||
const iso = d.toISOString().slice(0, 10);
|
||
// "(night), ledger M30: ..." loses its bracket and then its leading comma
|
||
const text = m[2].replace(/\(.*?\)/g, '').replace(/^\s*[,:;]\s*/, '').replace(/\s+/g, ' ').trim();
|
||
if (text) { const t = text.charAt(0).toUpperCase() + text.slice(1); entries.push({ date: iso, text: t, short: shortTitle(t) }); }
|
||
}
|
||
const jp = join(here, 'journey.json');
|
||
const j = JSON.parse(readFileSync(jp, 'utf8'));
|
||
for (const e of j.log) { const t = scrubBench(e.text.replace(/^\s*[,:;]\s*/, '')); e.text = t.charAt(0).toUpperCase() + t.slice(1); } // entries stored before the leading-comma fix, and before a scrub rule was added, are re-scrubbed
|
||
const seen = new Set(j.log.map(e => e.date + '|' + e.text));
|
||
for (const e of entries.reverse()) { const k = e.date + '|' + e.text; if (!seen.has(k)) { j.log.unshift(e); seen.add(k); } }
|
||
for (const e of j.log) e.short = shortTitle(e.text);
|
||
j.log.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||
j.log = j.log.slice(0, 40);
|
||
j.updated = j.log[0] ? j.log[0].date : j.updated;
|
||
writeFileSync(jp, JSON.stringify(j, null, 2) + '\n');
|
||
built.push('journey.json (' + j.log.length + ' entries)');
|
||
}
|
||
|
||
// The hand-written pages: shared head, nav (with the active link marked) and footer injected in place; the homepage also
|
||
// gets journey.json inlined so the phases and the log render without a fetch and without a layout shift.
|
||
const journey = JSON.parse(readFileSync(join(here, 'journey.json'), 'utf8'));
|
||
// 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, testnet launch). 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">.
|
||
// TESTNET_OPEN: the one flag of the go. While false every download section keeps its "Public testnet: not yet open" line
|
||
// (elements marked data-testnet-notice); true removes them.
|
||
const TESTNET_OPEN = false;
|
||
const DL_HOST = 'https://dl.igneum.network';
|
||
const DL_SNAPSHOT = join(here, 'downloads.json');
|
||
// 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>`);
|
||
if (TESTNET_OPEN) 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();
|
||
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', '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 /bench: 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 · 5 labels · [^<]*<\/div>/, `<div class="eyebrow"><span class="line"></span>${rows.length} claims · 5 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;
|
||
}
|
||
|
||
const PAGES = [['index.html', ''], ['download.html', 'download'], ['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'], ['404.html', ''],
|
||
// the DAG explorer (5 Oct 2026): /explorer, /block/<hash> and /address/<addr> (vercel.json rewrites the last two)
|
||
['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live'],
|
||
// the scenes lab (6 Oct 2026): three animated views of the live feed, unlinked, noindex
|
||
['scenes.html', '']];
|
||
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 = inject(html, 'head', HEAD, file);
|
||
html = inject(html, 'nav', navFor(active), file);
|
||
html = inject(html, 'footer', FOOT, file);
|
||
html = stampProduct(html, file);
|
||
html = stampDownloads(html, file, downloads);
|
||
html = stampMarks(html);
|
||
if (file === 'evidence.html') html = renderEvidence(html);
|
||
if (file === 'index.html' && html.includes('<!-- journey:start -->')) html = inject(html, 'journey', `<script type="application/json" id="journey-data">${JSON.stringify(journey).replace(/</g, '\\u003c')}</script>`, file);
|
||
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 /bench.
|
||
// 7 October 2026 (the founder): the CURRENT class (rows measured on the class v4 program, or class v3 rows re-measured with their
|
||
// class v4 cost, 6 October on) is the table; the earlier classes (the genesis program, the hourly program, class v3 before
|
||
// the shadow) sit collapsed below so no reader compares a 228 MH/s genesis row with a 136 MH/s class v3 row as one thing.
|
||
// Every header sorts on a click (default MH per watt, high first); the sort is a few lines of script in the page.
|
||
{
|
||
const bj = JSON.parse(readFileSync(join(here, 'miner-bench.json'), 'utf8'));
|
||
const isCurrent = (r) => r.generator === 'v2' && r.date >= '2026-10-06';
|
||
const byMhW = (a, b) => ((b.mh_per_w ?? -1) - (a.mh_per_w ?? -1)) || (b.mh_s - a.mh_s) || (a.card < b.card ? -1 : 1);
|
||
const cur = bj.rows.filter(isCurrent).sort(byMhW);
|
||
const earlier = bj.rows.filter(r => !isCurrent(r)).sort((a, b) => (a.card < b.card ? -1 : a.card > b.card ? 1 : a.generator < b.generator ? -1 : a.generator > b.generator ? 1 : b.mh_s - a.mh_s));
|
||
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 heads = [
|
||
['Card', 'card', 'text'], ['Generator', 'generator', 'text'], ['Best MH/s', 'mh_s', 'num'], ['Watts', 'watts', 'num'], ['MH per watt', 'mh_per_w', 'num'],
|
||
['Class v4 cost', 'v4_cost', 'text'], ['Tuned', 'tuned', 'text'], ['Hive flight sheet (core, mem, PL)', 'hive', 'text'], ['Miner', 'miner', 'text'], ['Date', 'date', 'text'], ['Source', 'source', 'text'], ['Who measured it', 'by', 'text'],
|
||
];
|
||
const cells = (r) => [
|
||
[r.card, r.card], [r.generator, r.generator], [fmt(r.mh_s), r.mh_s], [r.watts == null ? 'not read' : fmt(r.watts), r.watts ?? -1], [r.mh_per_w == null ? 'not measured' : fmt3(r.mh_per_w), r.mh_per_w ?? -1],
|
||
[r.v4_cost || 'not measured', r.v4_cost || ''], [r.tuned || 'stock, mining', r.tuned || ''], [hiveCell(r), r.hive && r.hive.core_mhz ? 'a measured ' + r.hive.core_mhz : 'z stock'], [r.miner + (r.driver_os ? ' (' + r.driver_os + ')' : ''), r.miner], [r.date, r.date], [r.source, r.source], [r.by + (r.note ? '. ' + r.note : ''), r.by],
|
||
];
|
||
const hiveCell = (r) => {
|
||
const h = r.hive; if (!h || h.core_mhz == null) return 'stock' + (h && h.label ? ' (' + h.label.replace(/^stock \(|\)$/g, '') + ')' : '');
|
||
return 'core lock ' + fmt(h.core_mhz) + ' MHz, mem ' + fmt(h.mem_mhz) + ' MHz, PL ' + fmt(h.pl_w) + ' W (' + h.label + ')';
|
||
};
|
||
const render = (list, id) => '<div class="tbl"><table class="sortable" id="' + id + '"><thead><tr>' +
|
||
heads.map(([h, k, t], i) => `<th data-key="${k}" data-type="${t}" aria-sort="${k === 'mh_per_w' ? 'descending' : 'none'}"><button type="button" class="sort">${h}</button></th>`).join('') +
|
||
'</tr></thead><tbody>' + list.map(r => '<tr>' + cells(r).map(([c, v]) => `<td data-v="${esc(String(v))}">${esc(String(c))}</td>`).join('') + '</tr>').join('') + '</tbody></table></div>';
|
||
const table = render(cur, 'bench-current');
|
||
const earlierTable = render(earlier, 'bench-earlier');
|
||
const sortScript = `<script>
|
||
(function(){
|
||
// header sort on the bench tables: a click sorts by that column (numbers by value, text by locale), a second click flips it
|
||
document.querySelectorAll('table.sortable').forEach(function(t){
|
||
var ths=t.querySelectorAll('th'),tb=t.querySelector('tbody');
|
||
ths.forEach(function(th,i){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'));
|
||
rows.sort(function(a,b){var x=a.children[i].getAttribute('data-v'),y=b.children[i].getAttribute('data-v');var c=num?(parseFloat(x)-parseFloat(y)):x.localeCompare(y,'en');return dir==='descending'?-c:c;});
|
||
rows.forEach(function(r){tb.appendChild(r);});
|
||
});});
|
||
});
|
||
})();
|
||
</script>`;
|
||
const sortStyle = '<style>th .sort{all:unset;cursor:pointer;font:inherit;color:inherit}th .sort::after{content:" \\2195";opacity:.45}th[aria-sort="descending"] .sort::after{content:" \\2193";opacity:1}th[aria-sort="ascending"] .sort::after{content:" \\2191";opacity:1}details.earlier{margin:var(--s-4) 0}details.earlier summary{cursor:pointer;color:var(--bone)}</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>',
|
||
'<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. Click a column header to sort; the table opens by MH per watt. Integrated GPUs are not listed. The earlier classes sit below, collapsed.</p>',
|
||
'<p><strong>Why the rate fell from the first bench to today.</strong> The genesis program did 104 dependent random 4-byte loads per hash over a 1 GiB dataset; the hourly program and class v3 do 128, with the mixer between them; class v4 adds about 100,000 integer operations per hash that ride in the memory wait. So the hash is bound by random-read bandwidth by design, and a card\'s MH/s is a relative number: the difficulty follows it, and the same card earns the same share of blocks at 136 MH/s on class v3 as it did at 228 MH/s on the genesis program. What a miner compares is hash per watt, and what the chain cares about is the chip edge, which the shadow work is there to cut.</p>',
|
||
table,
|
||
`<p>Rows on the current class: ${cur.length}. Each row names the engineering log entry or the job it came from.</p>`,
|
||
'<p><strong>The Hive flight sheet column.</strong> Where a card has a measured tune point, the column gives the core clock lock, the memory clock and the power limit to copy into a HiveOS flight sheet, labelled measured with the date; stock means no tune point has been measured yet. The Hive package mines at these settings through Hive\'s own overclock controls; the desktop app\'s Ember Tune lands on them by itself.</p>',
|
||
'<details class="earlier"><summary>Earlier classes (the genesis program, the hourly program, class v3 before the shadow): ' + earlier.length + ' rows, not comparable with the table above</summary>',
|
||
'<p>These rows are the bench numbers of 3 and 4 October 2026: the genesis program (104 loads per hash), the hourly program and the first class v3 miner. A higher MH/s here is a different hash, not a faster card.</p>',
|
||
earlierTable,
|
||
'</details>',
|
||
'<h2 id="how">How a row gets here</h2>',
|
||
'<p>Every row names the engineering log entry or the job it came from. "Measured by the team" means our own hardware and our own log. "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: card, generator version, best MH/s, MH per watt where measured, miner version, date and the log entry each number came from.', body, toc,
|
||
'Measured hash rates per card on the Igneum lottery hash, with the generator version, the miner version, the date and the log entry behind each number.',
|
||
{ path: '/miners', heading: 'GPU bench table', eyebrow: `${rows.length} measured rows, ${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 /bench; /journey is journey.json (the phases and the log the bench entries feed). 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'], ['/bench', 'The engineering log']]),
|
||
}));
|
||
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)');
|
||
}
|
||
{
|
||
const j = JSON.parse(readFileSync(join(here, 'journey.json'), 'utf8'));
|
||
const STATUS = { active: ['current', 'Under way'], next: ['next', 'Next'], done: ['done', 'Done'] };
|
||
const phases = (j.phases || []).map((p, i) => { const [cls, word] = STATUS[p.status] || ['', p.status]; return `<div class="milestone ${cls}" id="${esc(p.id)}"><div class="when">${esc(p.when)}</div><div><span class="badge${cls === 'current' ? ' warm' : ''}">${word}</span><h3>${i + 1}. ${esc(p.name)}</h3><p>${esc(p.line)}</p>${p.gate ? `<p class="gate"><b>Gate:</b> ${esc(p.gate)}</p>` : ''}</div></div>`; }).join('\n');
|
||
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
const day = d => { const [y, m, dd] = d.split('-'); return `${Number(dd)} ${MON[Number(m) - 1]} ${y}`; };
|
||
const log = (j.log || []).map(e => `<article class="record"><div class="record-top"><time datetime="${esc(e.date)}">${day(e.date)}</time><span class="badge">log</span></div><h3>${esc(e.short || e.text)}</h3>${e.short && e.short !== e.text ? `<p>${esc(e.text)}</p>` : ''}</article>`).join('\n');
|
||
writeFileSync(join(here, 'journey.html'), shell({
|
||
title: 'The Igneum journey: six phases, four gates', desc: 'Where Igneum is: six phases from the specification to a fair launch, each closing at a published gate, and the engineering log’s latest entries. No calendar dates.',
|
||
path: '/journey', crumb: 'The journey', eyebrow: `Six phases · updated ${day(j.updated)}`, heading: 'The journey.',
|
||
lead: 'Six phases from the specification to a fair launch. Each closes at its gate, a measurement published whether it passes or fails, so there is no date to slip. Below the phases, the latest entries of the engineering log.',
|
||
body: `<div class="timeline">${phases}</div>
|
||
<div class="section-head" style="margin-top:100px"><div><div class="eyebrow"><span class="line"></span>The log, newest first</div><h2>Lately, in the log.</h2></div><div class="searchbar" style="min-width:min(100%,360px)"><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="log-q" class="visually-hidden">Filter the log</label><input id="log-q" data-filter-input="log" placeholder="Filter: a card, a date, a word" autocomplete="off" spellcheck="false"><span class="small" data-filter-count="log"></span></div></div>
|
||
<div class="record-count" data-filter-count="log"></div><div class="record-grid" data-filter-list="log">${log}</div>
|
||
<p class="empty-state" data-filter-empty="log" hidden>Nothing in the log matches that.</p>
|
||
<p class="note">Every entry is a dated heading of <a href="/bench">the engineering log</a>, where the commands and the hardware are. The phases and their gates are the litepaper’s <a href="/litepaper#roadmap">roadmap</a>.</p>`,
|
||
after: onward([['/bench', 'The engineering log'], ['/evidence', 'Every claim and its status'], ['/live', 'The live devnet']]),
|
||
}));
|
||
built.push('journey.html (' + (j.phases || []).length + ' phases, ' + (j.log || []).length + ' entries)');
|
||
}
|
||
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); } }
|