#!/usr/bin/env node /* * build-glyphs.mjs — rasterize the 8 turn-card glyphs from the app's SVG sources (06-07, D-12). * ------------------------------------------------------------------------------------------------ * A MAINTAINER-TIME REPO TOOL in the fetch-route-fixtures.mjs / upload-road-graph.mjs pattern: * it is NOT a runtime dependency. Nothing in bot/src/, web/src/, or routing/src/ imports it; * no deploy, test, CI run, or vite build ever runs it. sharp is a devDependency of the routing * workspace only (RESEARCH Installation — zero runtime deps added). * * D-12 BOUNDARY + SINGLE-SOURCE LAW: the committed web/src/guidance/glyphs/*.svg are THE * source — the SAME paths the app renders inline (banner 32×32, list rows 24×24). The PNGs * are their rasterization: committed once, reused everywhere (the chat turn-card's sendPhoto * fetches web/public/glyphs/.png from the Worker's static handler). The recolor is * IN-MEMORY ONLY — the committed SVGs keep their currentColor law (theme-following in-app * rendering) and are never rewritten by this tool. * * Colors are DSGN-01 build constants copied verbatim from theme/src/tokens.css (--color-signal-orange, * --color-matte-black) — the script is not runtime CSS, hex literals are its interface. * * Mechanically, per kind: read the SVG → substitute currentColor → STROKE → inject a full-bleed * 32×32 PLATE rect as the FIRST child (glyph strokes paint over it) → set width/height SIZE * attributes → render 256×256 PNG via sharp. COLLECT-THEN-WRITE: every source is read and * rendered BEFORE any file is written — a missing/invalid source or a failed render exits 1 * with the kind named and NO partial writes (the fixture-precompute precedent). * * Usage: * npm run glyphs -w routing # rebuild all 8 web/public/glyphs/*.png * node scripts/build-glyphs.mjs # same, direct * * Exit codes: 0 all 8 PNGs rendered and written; 1 missing/invalid source SVG, failed render, * or a size/plate sanity-check failure (no file written for a failed batch). */ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); // routing/scripts const SRC_DIR = resolve(here, "..", "..", "web", "src", "guidance", "glyphs"); const OUT_DIR = resolve(here, "..", "..", "web", "public", "glyphs"); /** The 8 turn-card kinds — the GlyphKindName union in bot/src/guidance/cron.ts * (structurally RouteStep.kind, one authority with engine A). */ const GLYPH_KINDS = ["depart", "straight", "left", "right", "slight-left", "slight-right", "uturn", "arrive"]; /** DSGN-01 tokens, verbatim: signal-orange stroke on a matte-black full-bleed plate. */ const STROKE = "#EB8008"; const PLATE = "#1A1A1A"; const SIZE = 256; // output pixels — chat renders it ~48–96px; the oversize keeps edges crisp const SOURCE_GRID = 32; // the committed sources' viewBox (3px stroke, square caps) const MIN_BYTES = 500; // a flat-color 256×256 PNG is ≫ 500 B; smaller means a broken render function fail(message) { console.error(`build-glyphs: ${message}`); process.exit(1); } /* ————— collect: read + transform + render EVERYTHING before any write ————— */ let sharp; try { sharp = (await import("sharp")).default; } catch { fail("sharp is not importable — run `npm install -D -w routing sharp` (maintainer devDependency only)"); } const rendered = []; for (const kind of GLYPH_KINDS) { const srcPath = resolve(SRC_DIR, `${kind}.svg`); let source; try { source = readFileSync(srcPath, "utf8"); } catch { fail(`missing source SVG for kind "${kind}": ${srcPath} — nothing written`); } if (!/]*>/.test(source)) { fail(`invalid source SVG (no root) for kind "${kind}": ${srcPath} — nothing written`); } if (!source.includes("currentColor")) { fail(`source SVG for kind "${kind}" lost its currentColor law (theme-following rendering) — refusing to rasterize; nothing written`); } // Recolor + plate + size — in-memory only; the committed source is untouched. const svg = source .replaceAll("currentColor", STROKE) .replace(/]*>/, (m) => `${m}`); if (svg.includes("currentColor")) { fail(`recolor incomplete for kind "${kind}" — nothing written`); } let png; try { // The width/height SIZE attributes already govern the raster size — an // explicit density here would MULTIPLY with them (256 × 576/72 = 2048), // so none is set. png = await sharp(Buffer.from(svg)).png().toBuffer(); } catch (err) { fail(`sharp render failed for kind "${kind}": ${err?.message ?? err} — nothing written`); } if (png.length < MIN_BYTES) { fail(`render for kind "${kind}" is ${png.length} bytes (< ${MIN_BYTES}) — a broken/empty plate; nothing written`); } rendered.push({ kind, png, srcPath }); } /* ————— write: all 8 rendered — now commit the batch ————— */ mkdirSync(OUT_DIR, { recursive: true }); for (const { kind, png } of rendered) { const outPath = resolve(OUT_DIR, `${kind}.png`); writeFileSync(outPath, png); console.log(`build-glyphs: wrote ${outPath} (${statSync(outPath).size} bytes)`); } console.log(`build-glyphs: ${rendered.length}/${GLYPH_KINDS.length} glyphs rasterized from the committed SVG sources`);