/* icons index — generation of Astro components from icon JSON files for dynamic icon loading. Do not handle raw asset parsing or validation beyond filename structure. Do not manage transport or configuration orchestration for the icon sets. Icon components are generated from JSON — the JSON is the source of truth, not the .astro output. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1254: drop unused io-migration leftovers */ /** * Icons generation module for site-kernel * Generates Astro components from LordIcon JSON files * * @module */ import path from "path"; import { fileURLToPath } from "url"; import { type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import type { KernelCommandInput, KernelCommandResult, KernelLogger, KernelRuntimeContext, } from "@warpgogol/werkstatt-shared/kernel"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = path.join(__dirname, "..", "templates", "icons"); // Configuration — icon assets and generated components live in the // werkstatt-site domain/ui package (RFC-0776 consolidation; RFC-1147 path fix). const UI_PACKAGE_PATH = path.join( __dirname, "..", "..", "..", "..", "werkstatt-site", "src", "domain", "ui", ); const ASSETS_BASE = path.join(UI_PACKAGE_PATH, "assets", "icons"); const OUTPUT_BASE = path.join(UI_PACKAGE_PATH, "icons", "gen"); type IconSet = { base: string; set: string }; /** * Discover all icon sets dynamically from filesystem * Structure: ASSETS_BASE/{base}/{set}/icon.json * Examples: lordicon/doodle-outline/, heroicons/24-outline/ */ async function discoverIconSets(logger: KernelLogger, io: WorkspaceIO): Promise { if (!(await io.exists(ASSETS_BASE))) { logger.event({ severity: "notice", kind: "expected-fallback", message: `Assets base directory not found: ${ASSETS_BASE}`, module: "icons", dedupeKey: `icons-assets-base-missing:${ASSETS_BASE}`, data: { assetsBase: ASSETS_BASE }, }); return []; } const iconSets: IconSet[] = []; // Find base sets (top-level directories) const baseEntries = await io.readdir(ASSETS_BASE); const baseSets = baseEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name); for (const base of baseSets) { const basePath = path.join(ASSETS_BASE, base); const setEntries = await io.readdir(basePath); const sets = setEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name); for (const set of sets) { iconSets.push({ base, set }); } } logger.info(`Discovered ${iconSets.length} icon set(s) in ${baseSets.length} base(s)`); for (const { base, set } of iconSets) { logger.info(` - ${base}/${set}`); } return iconSets; } /** * Convert icon name to valid JS identifier (PascalCase for component) */ function toPascalCase(str: string): string { return str .split(/[-_]/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) .join(""); } /** * Convert icon name to camelCase for variable export */ function _toCamelCase(str: string): string { const pascal = toPascalCase(str); return pascal.charAt(0).toLowerCase() + pascal.slice(1); } /** * Parse icon filename to extract metadata * Format: {set}-{id}-{name}-{trigger}.json */ function parseIconFilename(filename: string, set: string): { name: string; id: string } | null { const match = filename.match(new RegExp(`^${set}-(\\d+)-(.+)-(.+)\\.json$`)); if (!match) return null; const [, id, rawName] = match; return { name: rawName, id }; } async function readTemplate(templatePath: string, io: WorkspaceIO): Promise { return io.readFile(path.join(TEMPLATES_DIR, templatePath)); } function applyTokens(template: string, tokens: Record): string { return template.replace(/{{\s*(\w+)\s*}}/g, (_match, key) => tokens[key] ?? ""); } /** * Generate Astro component content for an icon from template. */ async function generateIconComponent( _iconName: string, _set: string, relativeJsonPath: string, io: WorkspaceIO, ): Promise { return applyTokens(await readTemplate("lord-icon-component.template.astro", io), { RELATIVE_JSON_PATH: relativeJsonPath, }); } /** * Generate index.ts content for an icon set from template. */ async function generateIndexExports( exports: Array<{ fileName: string; exportName: string; subfolder: string }>, io: WorkspaceIO, ): Promise { const exportsList = exports .map(({ fileName, exportName, subfolder }) => { return `export { default as ${exportName} } from "./${subfolder}/${fileName}";`; }) .join("\n"); return applyTokens(await readTemplate("index.template.ts", io), { EXPORTS_LIST: exportsList, }); } /** * Process a single icon set */ async function processIconSet( iconSet: IconSet, logger: KernelLogger, io: WorkspaceIO, ): Promise<{ generated: number; errors: number }> { const { base, set } = iconSet; const inputDir = path.join(ASSETS_BASE, base, set); const outputDir = path.join(OUTPUT_BASE, base, set); if (!(await io.exists(inputDir))) { logger.warn(`Input directory not found: ${inputDir}`); return { generated: 0, errors: 0 }; } // Ensure output directory exists await io.mkdir(outputDir); const jsonFiles = (await io.readdir(inputDir)) .map((e) => e.name) .filter((f) => f.endsWith(".json")); const exports: Array<{ fileName: string; exportName: string; subfolder: string }> = []; let generated = 0; let errors = 0; for (const jsonFile of jsonFiles) { const parsed = parseIconFilename(jsonFile, set); if (!parsed) { logger.warn(`Skipping malformed filename: ${jsonFile}`); errors++; continue; } const { name, id: _id } = parsed; const baseName = toPascalCase(name) + "Icon"; const componentName = /^\d/.test(baseName) ? `Icon${baseName}` : baseName; const componentFileName = `${name}-icon.astro`; // Subfolder based on first letter (a/, b/, c/, etc.) const subfolder = name.charAt(0).toLowerCase(); const subfolderPath = path.join(outputDir, subfolder); await io.mkdir(subfolderPath); // Calculate relative path from component to JSON asset // From: domain/ui/icons/gen/{base}/{subfolder}/ // To: domain/ui/assets/icons/{base}/{set}/ const relativeJsonPath = `../../../../assets/icons/${base}/${set}/${jsonFile}`; const componentContent = await generateIconComponent(name, set, relativeJsonPath, io); const componentPath = path.join(subfolderPath, componentFileName); await io.writeFile(componentPath, componentContent); exports.push({ fileName: componentFileName, exportName: componentName, subfolder, }); generated++; } // Sort exports alphabetically by subfolder then filename exports.sort((a, b) => { if (a.subfolder !== b.subfolder) return a.subfolder.localeCompare(b.subfolder); return a.fileName.localeCompare(b.fileName); }); // Generate index.ts const indexContent = await generateIndexExports(exports, io); await io.writeFile(path.join(outputDir, "index.ts"), indexContent); logger.info(`Generated ${generated} components for ${set} (index.ts: ${exports.length} exports)`); return { generated, errors }; } /** * Main command handler for ui.icons.generate */ export async function runIconsGenerate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise { const logger = context.logger; const outputFormat = context.outputFormat; logger.info("Starting icons generation..."); let totalGenerated = 0; let totalErrors = 0; // Discover icon sets dynamically const iconSets = await discoverIconSets(logger, context.io); if (iconSets.length === 0) { logger.warn("No icon sets found to process"); return { exitCode: 0, summary: "[index] No icon sets found", data: { command: "ui.icons.generate", status: "success", generated: { components: 0, sets: 0, }, }, }; } for (const set of iconSets) { const result = await processIconSet(set, logger, context.io); totalGenerated += result.generated; totalErrors += result.errors; } logger.info( `\n✅ Icons generation complete: ${totalGenerated} components, ${totalErrors} errors`, ); const result = { exitCode: totalErrors > 0 ? 1 : 0, summary: `Generated ${totalGenerated} icon components${totalErrors > 0 ? ` (${totalErrors} errors)` : ""}`, data: { command: "ui.icons.generate", status: totalErrors > 0 ? "fail" : "success", generated: { components: totalGenerated, sets: iconSets.length, }, }, }; // Output JSON if requested if (outputFormat === "json") { logger.info(JSON.stringify(result.data, null, 2)); } return result; } /** * Icons module definition */ export const iconsModule = { name: "icons", description: "Generate Astro icon components from LordIcon JSON files", commands: { generate: { name: "generate", description: "Generate icon components for @warpgogol/werkstatt-site/ui package", run: runIconsGenerate, }, }, };