#!/usr/bin/env -S node -r ts-node/register import fs from 'fs'; import path from 'path'; import ejs from 'ejs'; import YAML from 'js-yaml'; import { optimize, type Config } from 'svgo'; const ROOT_DIR = path.join(__dirname, '..'); const SRC_DIR = path.join(ROOT_DIR, 'src'); const TEMPLATE_DIR = path.join(__dirname, 'templates'); const DEFAULT_VIEWBOX = '0 -960 960 960'; const ROOT_ATTRS_TO_DROP = ['xmlns', 'width', 'height', 'viewBox']; // Material Symbols ship 2-decimal coordinates on a 960px grid; icons render at // ~20px, so rounding to integers (floatPrecision: 0) is invisible and roughly // halves the path-data bytes. removeViewBox is kept so width/height props scale. const svgoConfig: Config = { floatPrecision: 0, plugins: [{ name: 'preset-default', params: { overrides: { removeViewBox: false } } }], }; interface VariantData { inner: string; viewBox: string; rootAttrs: Record; } const extractVariant = (svgPath: string): VariantData | null => { if (!fs.existsSync(svgPath)) return null; const optimized = optimize(fs.readFileSync(svgPath, 'utf8'), svgoConfig).data; const match = optimized.match(/]*)>([\s\S]*)<\/svg>/); if (!match) throw new Error(`Could not parse SVG: ${svgPath}`); const [, attrStr, inner] = match; const viewBox = (attrStr.match(/viewBox="([^"]*)"/) || [])[1] || DEFAULT_VIEWBOX; const rootAttrs: Record = {}; for (const [, key, value] of Array.from(attrStr.matchAll(/([\w:-]+)="([^"]*)"/g))) { if (!ROOT_ATTRS_TO_DROP.includes(key)) rootAttrs[key] = value; } return { inner, viewBox, rootAttrs }; }; // Build the single-source IconData object (inner markup per variant + any // viewBox / extra root-attribute overrides) shared by the React component and // the svgIcon() string builder. const buildIconData = (iconDir: string): Record => { const outlined = extractVariant(path.join(iconDir, 'icon.svg')); const filled = extractVariant(path.join(iconDir, 'icon-fill.svg')); const data: Record = {}; if (outlined) data.o = outlined.inner; if (filled) data.f = filled.inner; const viewBox = filled?.viewBox ?? outlined?.viewBox; if (viewBox && viewBox !== DEFAULT_VIEWBOX) data.vb = viewBox; // Outlined and filled usually share a viewBox; store an override only when they differ. if (outlined && filled && outlined.viewBox !== filled.viewBox) data.vbo = outlined.viewBox; if (outlined && Object.keys(outlined.rootAttrs).length) data.rao = outlined.rootAttrs; if (filled && Object.keys(filled.rootAttrs).length) data.raf = filled.rootAttrs; return data; }; interface IconConfig { [Icon: string]: { name: string; tags: string[]; aliases: string[]; svg_import: string; }; } const main = async () => { const iconsConfig = YAML.load(fs.readFileSync(path.join(ROOT_DIR, 'icons.config.yaml')).toString()) as IconConfig; const icons = Object.entries(iconsConfig).map(([Icon, icon]) => ({ Icon, ...icon, svg_import_fill: icon.svg_import?.replace(/.svg$/, '-fill.svg'), })); // copy svg files under src/svg//icon.svg for (const icon of icons) { if (icon.svg_import) { const svgFile = path.join(SRC_DIR, 'svg', icon.Icon, 'icon.svg'); const svgFileFill = path.join(SRC_DIR, 'svg', icon.Icon, 'icon-fill.svg'); fs.mkdirSync(path.dirname(svgFile), { recursive: true }); fs.copyFileSync(require.resolve(icon.svg_import), svgFile); fs.copyFileSync(require.resolve(icon.svg_import_fill), svgFileFill); console.warn('Copied', svgFile); console.warn('Copied', svgFileFill); } // Generate the single-source data module (shared by the React component and // the svgIcon() string builder) from the icon's SVG files. const iconDir = path.join(SRC_DIR, 'svg', icon.Icon); fs.mkdirSync(iconDir, { recursive: true }); const dataFile = path.join(iconDir, 'data.ts'); fs.writeFileSync( dataFile, `import type { IconData } from '../../types';\n\nexport const data: IconData = ${JSON.stringify( buildIconData(iconDir), )};\n\nexport default data;\n`, ); console.warn('Wrote', dataFile); // Generate SVG module const svgModuleTemplates = fs.readdirSync(path.join(TEMPLATE_DIR, 'svg', 'module')); for (const template of svgModuleTemplates) { const templatePath = path.join(TEMPLATE_DIR, 'svg', 'module', template); const templateContent = fs.readFileSync(templatePath).toString(); const outFile = path.join(SRC_DIR, 'svg', icon.Icon, template.replace('.ejs', '')); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, ejs.render(templateContent, icon)); console.warn('Wrote', outFile); } } // Generate svgIcon.ts const svgIconComponentTemplate = fs.readFileSync(path.join(TEMPLATE_DIR, 'svg', 'svgIcon.ts.ejs')).toString(); const svgIconOutFile = path.join(SRC_DIR, 'svg', 'svgIcon.ts'); fs.writeFileSync(svgIconOutFile, ejs.render(svgIconComponentTemplate, { icons })); console.warn('Wrote', svgIconOutFile); // Generate index.ts for svg const svgIndexTemplate = fs.readFileSync(path.join(TEMPLATE_DIR, 'svg', 'index.ts.ejs')).toString(); const svgIndexOutFile = path.join(SRC_DIR, 'svg', 'index.ts'); fs.writeFileSync(svgIndexOutFile, ejs.render(svgIndexTemplate, { icons })); console.warn('Wrote', svgIndexOutFile); for (const icon of icons) { // Generate react component const reactComponentTemplates = fs.readdirSync(path.join(TEMPLATE_DIR, 'react', 'component')); for (const template of reactComponentTemplates) { const templatePath = path.join(TEMPLATE_DIR, 'react', 'component', template); const templateContent = fs.readFileSync(templatePath).toString(); const outFile = path.join(SRC_DIR, 'react', icon.Icon, template.replace('.ejs', '')); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, ejs.render(templateContent, icon)); console.warn('Wrote', outFile); } } // Generate EpilotIcon.tsx for react const epilotIconComponentTemplate = fs .readFileSync(path.join(TEMPLATE_DIR, 'react', 'EpilotIcon.tsx.ejs')) .toString(); const epilotIconOutFile = path.join(SRC_DIR, 'react', 'EpilotIcon.tsx'); fs.writeFileSync(epilotIconOutFile, ejs.render(epilotIconComponentTemplate, { icons })); console.warn('Wrote', epilotIconOutFile); // Generate index.ts for react const reactIndexTemplate = fs.readFileSync(path.join(TEMPLATE_DIR, 'react', 'index.ts.ejs')).toString(); const reactIndexOutFile = path.join(SRC_DIR, 'react', 'index.ts'); fs.writeFileSync(reactIndexOutFile, ejs.render(reactIndexTemplate, { icons })); console.warn('Wrote', reactIndexOutFile); }; if (require.main === module) { main(); }