- routing/scripts/build-glyphs.mjs: maintainer shell (Usage/Exit codes/ collect-then-write — a missing/invalid source exits 1 with the kind named and nothing written); recolors currentColor → #EB8008 in-memory and injects a full-bleed #1A1A1A plate (stroke="none" — it must not inherit the root's orange stroke); the committed SVGs are THE single source and stay untouched - sharp 0.35.5 as a routing devDependency (maintainer-only, zero runtime deps); 'glyphs' script added - 8× 256×256 PNGs committed (web/public/glyphs/) — the chat turn-card's sendPhoto URL rides the Worker's static handler; two render bugs fixed en route: density multiplied the explicit size attrs into 2048² (dropped), and the plate inherited the root stroke (orange frame — stroke="none") - SVG sources verified unchanged after the run
116 lines
5.5 KiB
JavaScript
116 lines
5.5 KiB
JavaScript
#!/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/<kind>.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 (!/<svg\b[^>]*>/.test(source)) {
|
||
fail(`invalid source SVG (no <svg> 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(/<svg\b/, `<svg width="${SIZE}" height="${SIZE}"`)
|
||
.replace(/<svg\b[^>]*>/, (m) => `${m}<rect width="${SOURCE_GRID}" height="${SOURCE_GRID}" fill="${PLATE}" stroke="none"/>`);
|
||
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`);
|