#!/usr/bin/env node // The chain scene has ONE source: scene/ (live-dag.js, proof-core.js, tokens.css). The site and the miner app serve copies, // because the site is a static folder Vercel builds from site/ and the app embeds ui/ with include_str!. This tool keeps the // copies honest without symlinks (Windows checkouts, Vercel's root directory, cargo's include paths): // // node tools/scene/sync.mjs [--adopt] write the copies that exist (--adopt creates a missing script copy next to an existing // target, the first time a tree takes the scene): the two scripts byte-for-byte, the palette block into each CSS file // between the scene-tokens markers (and, on first adoption, strip the same token names // from the file's other :root blocks, so the block is the only definition) // node tools/scene/sync.mjs --check the gate: every copy present in this tree is byte-equal to its source, every CSS // target's block equals the generated one and defines those names nowhere else; // exit 1 with one line per difference ("which side": the source is always right) // node tools/scene/sync.mjs --self-test known-failed first: a palette token changed on one side, one byte changed in a // copy, a stray token definition; then the clean tree passes // // Scope: a side (site or app) takes part only once it has adopted the scene, which is when its live-dag.js copy exists // (master carries no app/igneum-app/ui/live-dag.js; the app lives on the release branches, whose site tree is frozen). On a release-* branch (the branch name, or SCENE_SYNC_SCOPE=app) only the app's copies are checked: // the release branches carry the site tree as it was when they were cut, and the site deploys from master alone. import { readFileSync, writeFileSync, existsSync, mkdtempSync, cpSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; const here = dirname(fileURLToPath(import.meta.url)); const repoDefault = join(here, '..', '..'); export const TOKEN_NAMES = ['--obsidian', '--graphite', '--row', '--line', '--line-2', '--ember', '--ember-hi', '--molten', '--bone', '--ink-2', '--ash', '--included', '--excluded', '--ember-ink']; const START = '/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */'; const END = '/* scene-tokens:end */'; const TARGETS = [ { from: 'scene/live-dag.js', to: 'site/live-dag.js', scope: 'site', kind: 'copy' }, { from: 'scene/proof-core.js', to: 'site/proof-core.js', scope: 'site', kind: 'copy' }, { from: 'scene/tokens.css', to: 'site/site.css', scope: 'site', kind: 'tokens' }, { from: 'scene/live-dag.js', to: 'app/igneum-app/ui/live-dag.js', scope: 'app', kind: 'copy' }, { from: 'scene/proof-core.js', to: 'app/igneum-app/ui/proof-core.js', scope: 'app', kind: 'copy' }, { from: 'scene/tokens.css', to: 'app/igneum-app/ui/app.css', scope: 'app', kind: 'tokens' }, ]; // tokens.css -> { dark: {name: value}, light: {name: value} }; every name must be in both, no extras export function parseTokens(css) { const block = sel => { const m = css.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\{([^}]*)\\}')); if (!m) throw new Error('tokens.css: no ' + sel + ' block'); return m[1]; }; const map = body => Object.fromEntries(body.split(';').map(s => s.trim()).filter(Boolean).map(s => { const i = s.indexOf(':'); return [s.slice(0, i).trim(), s.slice(i + 1).trim()]; })); const dark = map(block(':root')), light = map(block(':root[data-theme="light"]')); for (const set of [dark, light]) { const names = Object.keys(set).sort().join(','), want = TOKEN_NAMES.slice().sort().join(','); if (names !== want) throw new Error('tokens.css defines ' + names + ', expected exactly ' + want); } return { dark, light }; } const decl = m => TOKEN_NAMES.map(n => n + ':' + m[n]).join(';'); export function tokenBlock(tokens) { const light = decl(tokens.light); return [START, ':root{' + decl(tokens.dark) + '}', ':root[data-theme="light"]{' + light + '}', '@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){' + light + '}}', END].join('\n'); } const blockRe = new RegExp(START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]*?' + END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // @media print blocks keep their own overrides (a printed page is white); everything else defines the names only in the block export function withoutExempt(css) { let out = css.replace(blockRe, ''), i; while ((i = out.indexOf('@media print{')) >= 0) { let depth = 0, j = i + '@media print'.length; for (; j < out.length; j++) { if (out[j] === '{') depth++; else if (out[j] === '}' && --depth === 0) { j++; break; } } out = out.slice(0, i) + out.slice(j); } return out; } // the token names defined outside the block (any "--name:" inside a declaration list; a var(--name) read is not a definition) export function strayDefinitions(css) { const outside = withoutExempt(css); const found = []; for (const n of TOKEN_NAMES) { const re = new RegExp('(^|[{;\\s])' + n.replace(/[-]/g, '\\-') + '\\s*:', 'g'); let m; while ((m = re.exec(outside))) found.push(n); } return found; } function stripDefinitions(css) { // remove "--name:value;" (or the last one before "}") outside the block; keep everything else byte for byte // the print block and the scene-tokens block keep their text; every other declaration of a token name goes const keep = []; const m = css.match(blockRe); if (m) keep.push([css.indexOf(m[0]), css.indexOf(m[0]) + m[0].length]); let i = -1; while ((i = css.indexOf('@media print{', i + 1)) >= 0) { let depth = 0, j = i + '@media print'.length; for (; j < css.length; j++) { if (css[j] === '{') depth++; else if (css[j] === '}' && --depth === 0) { j++; break; } } keep.push([i, j]); } keep.sort((a, b) => a[0] - b[0]); const strip = s => { for (const n of TOKEN_NAMES) s = s.replace(new RegExp('(^|(?<=[{;\\s]))' + n.replace(/[-]/g, '\\-') + '\\s*:[^;}]*;?', 'g'), ''); return s; }; let out = '', at = 0; for (const [a, b] of keep) { out += strip(css.slice(at, a)) + css.slice(a, b); at = b; } return out + strip(css.slice(at)); } export function writeTokens(css, block) { if (blockRe.test(css)) return css.replace(blockRe, block); // first adoption: the block goes before the file's first :root rule (after the leading comment), and the names leave the rest const i = css.search(/^:root\{/m); if (i < 0) throw new Error('no :root rule to put the scene-tokens block before'); return stripDefinitions(css.slice(0, i) + block + '\n' + css.slice(i)); } export function scopeFor(repo) { if (process.env.SCENE_SYNC_SCOPE) return process.env.SCENE_SYNC_SCOPE; let branch = ''; try { branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repo, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { } if (branch === 'HEAD' && process.env.GITHUB_REF_NAME) branch = process.env.GITHUB_REF_NAME; return /^release-/.test(branch) ? 'app' : 'all'; } const adopted = (repo, side) => existsSync(join(repo, side === 'site' ? 'site/live-dag.js' : 'app/igneum-app/ui/live-dag.js')); export function check(repo, scope = scopeFor(repo)) { const problems = [], seen = []; const tokens = parseTokens(readFileSync(join(repo, 'scene/tokens.css'), 'utf8')); const block = tokenBlock(tokens); for (const t of TARGETS) { if (scope !== 'all' && t.scope !== scope) continue; if (!adopted(repo, t.scope)) continue; const dst = join(repo, t.to); if (!existsSync(dst)) { problems.push(t.to + ' is missing while ' + t.scope + ' has adopted the scene'); continue; } seen.push(t.to); if (t.kind === 'copy') { const a = readFileSync(join(repo, t.from)), b = readFileSync(dst); if (!a.equals(b)) { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; problems.push(t.to + ' differs from ' + t.from + ' at byte ' + i + ' (source ' + a.length + ' bytes, copy ' + b.length + '): the source is right, run tools/scene/sync.mjs'); } } else { const css = readFileSync(dst, 'utf8'); const m = css.match(blockRe); if (!m) { problems.push(t.to + ': no scene-tokens block (run tools/scene/sync.mjs to adopt it)'); continue; } if (m[0] !== block) { const theirs = m[0].split('\n'), ours = block.split('\n'); const line = ours.findIndex((l, k) => theirs[k] !== l); problems.push(t.to + ': scene-tokens block differs from scene/tokens.css at block line ' + (line + 1) + ' (the source is right, run tools/scene/sync.mjs)'); } const stray = strayDefinitions(css); if (stray.length) problems.push(t.to + ' defines ' + [...new Set(stray)].join(', ') + ' outside the scene-tokens block: the block is the only definition'); } } return { problems, seen, scope }; } export function sync(repo, scope = 'all', adopt = false) { const tokens = parseTokens(readFileSync(join(repo, 'scene/tokens.css'), 'utf8')); const block = tokenBlock(tokens), written = []; for (const t of TARGETS) { if (scope !== 'all' && t.scope !== scope) continue; const dst = join(repo, t.to); if (!adopt && !adopted(repo, t.scope)) continue; if (!existsSync(dst) && !(adopt && t.kind === 'copy' && existsSync(dirname(dst)))) continue; if (t.kind === 'copy') { const src = readFileSync(join(repo, t.from)); if (!existsSync(dst) || !src.equals(readFileSync(dst))) { writeFileSync(dst, src); written.push(t.to); } } else { const css = readFileSync(dst, 'utf8'), next = writeTokens(css, block); if (next !== css) { writeFileSync(dst, next); written.push(t.to); } } } return written; } function selfTest(repo) { const tmp = mkdtempSync(join(tmpdir(), 'scene-sync-')); const fails = []; const fixture = () => { rmSync(tmp, { recursive: true, force: true }); for (const d of ['scene', 'site', 'app/igneum-app/ui']) if (existsSync(join(repo, d))) cpSync(join(repo, d), join(tmp, d), { recursive: true, filter: p => !/\/(fonts|img|verify|scenes|api|lib|partials)(\/|$)/.test(p) && !/\.(png|woff2|ico)$/.test(p) }); sync(tmp, 'all'); }; const expectFail = (name, mutate) => { fixture(); mutate(); const r = check(tmp, 'all'); if (!r.problems.length) fails.push('known-failed case passed: ' + name); }; expectFail('a palette token changed on the site side', () => { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8').replace('--included:#3B7DD8', '--included:#3B7DD9')); }); expectFail('a palette token changed in the source but not the copies', () => { const p = join(tmp, 'scene/tokens.css'); writeFileSync(p, readFileSync(p, 'utf8').replace('--ember:#F2541B', '--ember:#F2541C')); }); expectFail('one byte changed in the site copy of live-dag.js', () => { const p = join(tmp, 'site/live-dag.js'); const s = readFileSync(p, 'utf8'); writeFileSync(p, s.replace("version:'2.0", "version:'9.0")); }); if (existsSync(join(tmp, 'app/igneum-app/ui/live-dag.js'))) expectFail('one byte changed in the app copy of live-dag.js', () => { const p = join(tmp, 'app/igneum-app/ui/live-dag.js'); writeFileSync(p, readFileSync(p, 'utf8') + '\n'); }); expectFail('a stray --ember definition outside the block', () => { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8') + '\n:root{--ember:#000}\n'); }); fixture(); { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8') + '\n@media print{:root{--ember:#000}.x{color:var(--ember)}}\n'); const r = check(tmp, 'all'); if (r.problems.length) fails.push('a print-block override was flagged as a stray definition: ' + r.problems.join('; ')); } fixture(); const clean = check(tmp, 'all'); if (clean.problems.length) fails.push('clean fixture failed: ' + clean.problems.join('; ')); rmSync(tmp, { recursive: true, force: true }); return fails; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { const repo = process.env.SCENE_SYNC_REPO || repoDefault; const mode = process.argv[2] || 'write'; if (mode === '--check') { const r = check(repo); for (const p of r.problems) console.error('scene-sync: ' + p); console.log('scene-sync: ' + (r.problems.length ? 'RED, ' + r.problems.length + ' difference(s)' : 'ok') + ' (scope ' + r.scope + ': ' + r.seen.join(', ') + ')'); process.exit(r.problems.length ? 1 : 0); } else if (mode === '--self-test') { const fails = selfTest(repo); for (const f of fails) console.error('scene-sync self-test: ' + f); console.log('scene-sync self-test: ' + (fails.length ? 'RED' : 'ok, five known-failed cases fail, a print override passes, the clean tree passes')); process.exit(fails.length ? 1 : 0); } else if (mode === 'write') { const w = sync(repo, process.argv.includes('--scope') ? process.argv[process.argv.indexOf('--scope') + 1] : 'all', process.argv.includes('--adopt')); console.log('scene-sync: wrote ' + (w.length ? w.join(', ') : 'nothing (every copy was current)') + (process.argv.includes('--adopt') ? '' : ' (sides that have not adopted the scene are left alone; --adopt takes one on)')); } else { console.error('usage: sync.mjs [--check | --self-test]'); process.exit(2); } }