// Designer moon pack → `src/moon-art.generated.ts` (#661, variant A). // // The pack is one full-moon SVG; the card applies the phase as a mask (C5). // The generator is the gate for what the designer's file may contain: a square // 512 viewBox with the documented disc, vector shapes and gradients only — no // filters, raster, fonts, scripts or external references — and at most 30 KB. // Ids are prefixed so the art can live in the card's shadow root next to // everything else. `--check` fails when the committed module has drifted. import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); export const MOON_PACK_DIR = path.join(ROOT, 'assets', 'moon', 'houseplan-1.0.0'); const OUT = path.join(ROOT, 'src', 'moon-art.generated.ts'); /** Brief to the designer: ≤ 30 KB before compression. */ export const MOON_SVG_MAX_BYTES = 30 * 1024; const ALLOWED_TAGS = new Set([ 'svg', 'title', 'defs', 'g', 'path', 'circle', 'ellipse', 'mask', 'radialGradient', 'linearGradient', 'stop', ]); const ID_PREFIX = 'hp-moon-'; const fail = (message) => { throw new Error(`Moon pack: ${message}`); }; /** pack.json: identity, licence and the disc geometry the mask is built for. */ export function validateMoonPack(pack) { if (!pack || typeof pack !== 'object' || Array.isArray(pack)) fail('pack.json must be an object'); if (pack.schema_version !== 1) fail('schema_version must be 1'); if (pack.pack_id !== 'houseplan') fail('pack_id must be "houseplan"'); if (typeof pack.pack_version !== 'string' || !/^\d+\.\d+\.\d+$/.test(pack.pack_version)) fail('pack_version must be semver'); if (pack.license !== 'MIT') fail('license must be MIT'); if (typeof pack.source_url !== 'string' || !pack.source_url.startsWith('https://')) fail('source_url must be https'); if (pack.file !== 'moon.svg') fail('file must be moon.svg'); if (JSON.stringify(pack.view_box) !== '[0,0,512,512]') fail('view_box must be [0, 0, 512, 512]'); if (JSON.stringify(pack.disc) !== '{"cx":256,"cy":256,"r":240}') fail('disc must be centred at 256 with r 240'); if (pack.mirror !== false) fail('mirror must be false (owner 2026-09-29)'); if (pack.lit_side !== 'left') fail('lit_side must be "left" (owner 2026-09-29)'); return pack; } /** The SVG body the card inlines: validated, title dropped, ids prefixed. */ export function moonArtFromSvg(source) { const text = String(source).replace(/^/, '').trim(); if (Buffer.byteLength(text, 'utf8') > MOON_SVG_MAX_BYTES) fail(`moon.svg is larger than ${MOON_SVG_MAX_BYTES} bytes`); if (/`); } const root = text.match(/^]*)>([\s\S]*)<\/svg>$/); if (!root) fail('moon.svg is not one SVG root'); if (!/\bviewBox="0 0 512 512"/.test(root[1])) fail('moon.svg must have viewBox="0 0 512 512"'); let body = root[2].replace(/[\s\S]*?<\/title>/g, ''); const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]); if (new Set(ids).size !== ids.length) fail('moon.svg repeats an id'); for (const [, ref] of body.matchAll(/url\(#([^)]+)\)/g)) { if (!ids.includes(ref)) fail(`moon.svg references a missing id: ${ref}`); } if (/[`\\]|\$\{/.test(body)) fail('moon.svg contains template-literal syntax'); body = body .replace(/\bid="([^"]+)"/g, (_, id) => `id="${ID_PREFIX}${id}"`) .replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${ID_PREFIX}${id})`); return body; } export function generateMoonModule(packDir = MOON_PACK_DIR) { const pack = validateMoonPack(JSON.parse(fs.readFileSync(path.join(packDir, 'pack.json'), 'utf8'))); const svg = fs.readFileSync(path.join(packDir, pack.file), 'utf8'); const art = moonArtFromSvg(svg); const sha = createHash('sha256').update(svg).digest('hex'); return [ '/* Generated by scripts/generate-moon-assets.mjs. DO NOT EDIT. */', `// Designer pack assets/moon/${pack.pack_id}-${pack.pack_version} (#661). Lazy: imported only by`, '// moon-runtime.ts, never by the initial View graph (C7).', '// A static Lit template: no directive, no binding, nothing parsed at runtime but the art.', "import { svg } from 'lit';", `export const MOON_PACK = ${JSON.stringify(`${pack.pack_id}-${pack.pack_version}`)};`, `export const MOON_ART_SHA256 = ${JSON.stringify(sha)};`, `export const MOON_ART = svg\`${art}\`;`, '', ].join('\n'); } if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) { const next = generateMoonModule(); if (process.argv.includes('--check')) { const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''; if (current !== next) { console.error('src/moon-art.generated.ts is stale: run node scripts/generate-moon-assets.mjs'); process.exit(1); } console.log('moon art is up to date'); } else { fs.writeFileSync(OUT, next); console.log(`wrote ${path.relative(ROOT, OUT)}`); } }