/** * Offline generator for the shared quest-icon registry. * * Reads the curated names + labels in `curated-icons.json` (the one source of * truth for the quick-pick lists), pulls each glyph's SVG path data from * locally installed packages in `admin/node_modules` (lucide-react and * @fortawesome/free-solid-svg-icons — no network), and writes the same * TypeScript module into both bundles: * * admin/src/icons/registry.generated.ts * frontend/src/icons/registry.generated.ts * * node tools/icons/generate.mjs # write both artefacts * node tools/icons/generate.mjs --check # exit 1 if either artefact drifted * * Lucide circles, rects and lines are converted to path `d` strings so every * glyph is plain `{ viewBox, paths }` data any renderer can draw. * * Besides the curated sets (the Lucide set and the Font Awesome quick picks), * the module embeds the complete Font Awesome Free Solid set (`FA_SOLID_ICONS`), * which is what `fa:` resolves against. Its source is the installed * @fortawesome/free-solid-svg-icons package itself: every exported icon * definition, de-duplicated by canonical `iconName` (the package also exports * each legacy alias, e.g. faHome for `house`, as the same definition). The * package carries no human labels, so each label is the kebab name in Title * Case (`spray-can-sparkles` → "Spray Can Sparkles"). Embedding the full set * makes both bundles heavier: the panel bundle is about 1 MB raw / 276 kB gzip. */ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); export const REPO_ROOT = join(HERE, "..", ".."); export const SOURCE_PATH = join(HERE, "curated-icons.json"); export const OUTPUT_PATHS = [ join(REPO_ROOT, "admin", "src", "icons", "registry.generated.ts"), join(REPO_ROOT, "frontend", "src", "icons", "registry.generated.ts"), ]; const ADMIN_MODULES = join(REPO_ROOT, "admin", "node_modules"); const LUCIDE_ICON_DIR = join(ADMIN_MODULES, "lucide-react", "dist", "esm", "icons"); const requireFromAdmin = createRequire(join(REPO_ROOT, "admin", "package.json")); function packageVersion(name) { const pkg = JSON.parse(readFileSync(join(ADMIN_MODULES, name, "package.json"), "utf8")); return pkg.version; } function num(value, what) { const n = Number(value); if (!Number.isFinite(n)) throw new Error(`${what}: not a number: ${value}`); return n; } /** One Lucide icon-node element as a path `d` string. */ function elementToPath(tag, attrs, icon) { const where = `lucide ${icon} <${tag}>`; const known = { path: ["d"], circle: ["cx", "cy", "r"], rect: ["x", "y", "width", "height", "rx", "ry"], line: ["x1", "y1", "x2", "y2"] }; if (!(tag in known)) throw new Error(`${where}: unsupported element`); for (const attr of Object.keys(attrs)) { if (attr !== "key" && !known[tag].includes(attr)) throw new Error(`${where}: unsupported attribute ${attr}`); } if (tag === "path") return attrs.d; if (tag === "line") { return `M${num(attrs.x1, where)} ${num(attrs.y1, where)}L${num(attrs.x2, where)} ${num(attrs.y2, where)}`; } if (tag === "circle") { const [cx, cy, r] = [num(attrs.cx, where), num(attrs.cy, where), num(attrs.r, where)]; return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`; } const [x, y, w, h] = [num(attrs.x ?? 0, where), num(attrs.y ?? 0, where), num(attrs.width, where), num(attrs.height, where)]; const rxRaw = attrs.rx ?? attrs.ry; const ryRaw = attrs.ry ?? attrs.rx; const rx = Math.min(rxRaw === undefined ? 0 : num(rxRaw, where), w / 2); const ry = Math.min(ryRaw === undefined ? 0 : num(ryRaw, where), h / 2); if (rx === 0 || ry === 0) return `M${x} ${y}h${w}v${h}h${-w}z`; const iw = w - 2 * rx; const ih = h - 2 * ry; return ( `M${x + rx} ${y}h${iw}a${rx} ${ry} 0 0 1 ${rx} ${ry}v${ih}` + `a${rx} ${ry} 0 0 1 ${-rx} ${ry}h${-iw}a${rx} ${ry} 0 0 1 ${-rx} ${-ry}` + `v${-ih}a${rx} ${ry} 0 0 1 ${rx} ${-ry}z` ); } /** A Lucide icon's node list; follows lucide-react's alias re-exports (smile → face-slightly-smiling). */ async function lucideNode(name, seen = []) { if (seen.includes(name)) throw new Error(`lucide ${name}: alias cycle ${seen.join(" -> ")}`); const file = join(LUCIDE_ICON_DIR, `${name}.mjs`); const mod = await import(pathToFileURL(file).href); if (mod.__iconData) return mod.__iconData.node; const alias = /export \{ default \} from '\.\/([a-z0-9-]+)\.mjs';/.exec(readFileSync(file, "utf8")); if (!alias) throw new Error(`lucide ${name}: no icon data in ${file}`); return lucideNode(alias[1], [...seen, name]); } function faExportName(name) { return "fa" + name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(""); } function faGlyph(definition, label) { const { iconName: name } = definition; const [width, height, , , pathData] = definition.icon; const paths = Array.isArray(pathData) ? pathData.filter(Boolean) : [pathData]; return { kind: "fa", key: `fa:${name}`, name, label, viewBox: `0 0 ${width} ${height}`, paths }; } function titleCase(name) { return name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "); } /** Every Free Solid glyph, one per canonical name, sorted by name. */ function allSolidGlyphs(faPackage) { const byName = new Map(); for (const definition of Object.values(faPackage)) { if (!definition || typeof definition !== "object" || !Array.isArray(definition.icon)) continue; if (definition.prefix !== "fas") throw new Error(`fa ${definition.iconName}: unexpected prefix ${definition.prefix}`); const name = definition.iconName; if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`fa ${name}: not a kebab name`); const seen = byName.get(name); if (seen && JSON.stringify(seen.icon) !== JSON.stringify(definition.icon)) { throw new Error(`fa ${name}: two exports disagree on the glyph`); } byName.set(name, definition); } if (byName.size === 0) throw new Error("fa: no icons found in @fortawesome/free-solid-svg-icons"); return [...byName.keys()] .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) .map((name) => faGlyph(byName.get(name), titleCase(name))); } function checkCurated(list, namespace) { const seen = new Set(); for (const { name, label } of list) { if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`${namespace}:${name}: not a kebab name`); if (typeof label !== "string" || !label.trim()) throw new Error(`${namespace}:${name}: missing label`); if (seen.has(name)) throw new Error(`${namespace}:${name}: duplicate`); seen.add(name); } } async function buildEntries() { const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")); checkCurated(curated.lucide, "lucide"); checkCurated(curated.fa, "fa"); const lucide = []; for (const { name, label } of curated.lucide) { const node = await lucideNode(name); const paths = node.map(([tag, attrs]) => elementToPath(tag, attrs, name)); lucide.push({ kind: "lucide", key: `lucide:${name}`, name, label, viewBox: "0 0 24 24", paths }); } const faPackage = requireFromAdmin("@fortawesome/free-solid-svg-icons"); const fa = []; for (const { name, label } of curated.fa) { const definition = faPackage[faExportName(name)]; if (!definition || definition.iconName !== name) throw new Error(`fa ${name}: not in @fortawesome/free-solid-svg-icons`); fa.push(faGlyph(definition, label)); } return { lucide, fa, faSolid: allSolidGlyphs(faPackage) }; } const RESOLVER = ` export const FALLBACK_ICON: FallbackIcon = { kind: "fallback", key: null }; /** Mirrors core/icons.py EMOJI_MAX_CODE_POINTS. */ export const EMOJI_MAX_CODE_POINTS = 8; const LUCIDE_BY_NAME: ReadonlyMap = new Map(LUCIDE_ICONS.map((icon) => [icon.name, icon])); /** The full Free Solid set, with each curated entry (and its quick-pick label) taking precedence. */ const FA_BY_NAME: ReadonlyMap = new Map( [...FA_SOLID_ICONS, ...FA_ICONS].map((icon) => [icon.name, icon]), ); function isEmojiPayload(text: string): boolean { const codePoints = [...text]; if (codePoints.length === 0 || codePoints.length > EMOJI_MAX_CODE_POINTS) return false; return !codePoints.some((char) => /[\\s<>&]|\\p{Cc}/u.test(char)); } /** * The renderable icon for a stored definition \`icon\` value. * * \`lucide:\` and a legacy bare \`\` resolve against the curated * Lucide set, \`fa:\` against the full Font Awesome Free Solid set * (curated entries keep their quick-pick labels), and * \`emoji:\` to its text. Like the backend's * normalize_icon_for_read, nothing here throws: null, empty, an unknown * namespace or name, or a malformed emoji all resolve to FALLBACK_ICON. */ export function resolveIcon(value: string | null | undefined): ResolvedIcon { if (!value || !value.trim()) return FALLBACK_ICON; const colon = value.indexOf(":"); if (colon === -1) return LUCIDE_BY_NAME.get(value) ?? FALLBACK_ICON; const namespace = value.slice(0, colon); const name = value.slice(colon + 1); if (namespace === "lucide") return LUCIDE_BY_NAME.get(name) ?? FALLBACK_ICON; if (namespace === "fa") return FA_BY_NAME.get(name) ?? FALLBACK_ICON; if (namespace === "emoji" && isEmojiPayload(name)) return { kind: "emoji", key: value, text: name }; return FALLBACK_ICON; } `; /** The generated module's full text (identical for both bundles). */ export async function renderRegistry() { const { lucide, fa, faSolid } = await buildEntries(); const source = relative(REPO_ROOT, SOURCE_PATH); return `// GENERATED by tools/icons/generate.mjs from ${source} — do not edit. // Regenerate with \`node tools/icons/generate.mjs\` (the drift tests fail if this // file differs from a fresh generation). // // Glyph path data is embedded at generation time; nothing is fetched at runtime. // Lucide glyphs: lucide-react ${packageVersion("lucide-react")} (ISC licence). // Font Awesome glyphs: @fortawesome/free-solid-svg-icons ${packageVersion("@fortawesome/free-solid-svg-icons")} // (Font Awesome Free, icons under CC BY 4.0). /** A vector glyph: draw each path in \`paths\` inside an . Lucide * glyphs are stroked (fill none, stroke-width 2, round caps and joins); * Font Awesome glyphs are filled. */ export interface GlyphIcon { kind: "lucide" | "fa"; key: string; name: string; label: string; viewBox: string; paths: readonly string[]; } /** An emoji icon; render \`text\` as text. */ export interface EmojiIcon { kind: "emoji"; key: string; text: string; } /** No usable icon: the caller draws its own default glyph. */ export interface FallbackIcon { kind: "fallback"; key: null; } export type ResolvedIcon = GlyphIcon | EmojiIcon | FallbackIcon; export const LUCIDE_ICONS: readonly GlyphIcon[] = ${JSON.stringify(lucide, null, 2)}; /** The curated Font Awesome quick-pick set. */ export const FA_ICONS: readonly GlyphIcon[] = ${JSON.stringify(fa, null, 2)}; /** Every Font Awesome Free Solid glyph (${faSolid.length}), sorted by name; one entry per line. */ export const FA_SOLID_ICONS: readonly GlyphIcon[] = [ ${faSolid.map((icon) => JSON.stringify(icon)).join(",\n")} ]; ${RESOLVER}`; } async function main() { const check = process.argv.includes("--check"); const text = await renderRegistry(); let drifted = false; for (const path of OUTPUT_PATHS) { const shown = relative(REPO_ROOT, path); if (check) { let current = null; try { current = readFileSync(path, "utf8"); } catch { // missing counts as drift } if (current !== text) { drifted = true; console.error(`drift: ${shown} differs from a fresh generation`); } } else { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, text); console.log(`wrote ${shown}`); } } if (drifted) { console.error("run `node tools/icons/generate.mjs` and commit the result"); process.exit(1); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); }