/** * build-components.ts * * Generates the @thesvg/react distribution from the monorepo source data. * For each icon, reads the default SVG and emits a typed React component * that forwards refs and accepts all standard SVGProps. * * Run with: * bun run scripts/build-components.ts * tsx scripts/build-components.ts * * Output layout: * dist/ * {slug}.js ESM component per icon * {slug}.cjs CJS component per icon * {slug}.d.ts Type declarations per icon * index.js ESM barrel (named exports) * index.cjs CJS barrel (named exports) * index.d.ts Type barrel */ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** Root of the packages/react package */ const PKG_ROOT = resolve(__dirname, ".."); /** Root of the thesvg monorepo */ const REPO_ROOT = resolve(PKG_ROOT, "../.."); const ICONS_JSON = join(REPO_ROOT, "src/data/icons.json"); const ICONS_PUBLIC = join(REPO_ROOT, "public/icons"); const DIST = join(PKG_ROOT, "dist"); // --------------------------------------------------------------------------- // Types mirrored from icons.json shape // --------------------------------------------------------------------------- interface RawIconVariants { default?: string; mono?: string; light?: string; dark?: string; wordmark?: string; wordmarkLight?: string; wordmarkDark?: string; color?: string; [key: string]: string | undefined; } interface RawIcon { slug: string; title: string; aliases: string[]; hex: string; categories: string[]; variants: RawIconVariants; license: string; url: string; guidelines?: string; } // --------------------------------------------------------------------------- // SVG reading & parsing // --------------------------------------------------------------------------- /** Read an SVG file from the public directory. Returns empty string on miss. */ function readSvg(slug: string, variant: string): string { const filePath = join(ICONS_PUBLIC, slug, `${variant}.svg`); if (!existsSync(filePath)) return ""; return readFileSync(filePath, "utf8").trim(); } /** * Resolve the "primary" SVG for an icon. * Preference order: default -> color -> mono -> light -> dark -> wordmark -> first available. */ function primarySvg(slug: string, variants: RawIconVariants): string { const order = ["default", "color", "mono", "light", "dark", "wordmark"]; for (const v of order) { if (v in variants) { const content = readSvg(slug, v); if (content) return content; } } for (const v of Object.keys(variants)) { const content = readSvg(slug, v); if (content) return content; } return ""; } // --------------------------------------------------------------------------- // SVG -> JSX conversion // --------------------------------------------------------------------------- /** * Extract the viewBox attribute from an SVG string. * Returns "0 0 24 24" as a safe fallback. */ function extractViewBox(svgContent: string): string { const match = svgContent.match(/viewBox=["']([^"']+)["']/); return match ? match[1] : "0 0 24 24"; } interface RootSvgPaint { fill: string; stroke?: string; } /** * Extract root paint attributes from the outer element. * `fill="none"` is still a valid explicit fill, so we preserve it as-is. */ function extractRootSvgPaint(svgContent: string): RootSvgPaint { const svgTag = svgContent.match(/]*>/s); if (!svgTag) return { fill: "none" }; const fillMatch = svgTag[0].match(/\bfill=["']([^"']+)["']/); const strokeMatch = svgTag[0].match(/\bstroke=["']([^"']+)["']/); return { fill: fillMatch ? fillMatch[1] : "none", stroke: strokeMatch ? strokeMatch[1] : undefined, }; } /** * Convert kebab-case attribute names to camelCase. * e.g. stroke-width -> strokeWidth, fill-rule -> fillRule */ function kebabToCamel(attr: string): string { return attr.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); } /** * Convert a CSS style string like "mask-type:alpha;display:inline" * into a JSX style object expression like `{{ maskType: "alpha", display: "inline" }}`. */ function convertStyleStringToJsx(styleStr: string): string { // Strip surrounding quotes if present const raw = styleStr.replace(/^["']|["']$/g, ""); const declarations = raw .split(";") .map((d) => d.trim()) .filter((d) => d.length > 0); const entries = declarations.map((decl) => { const colonIdx = decl.indexOf(":"); if (colonIdx === -1) return null; const prop = decl.slice(0, colonIdx).trim(); const val = decl.slice(colonIdx + 1).trim(); const camelProp = kebabToCamel(prop); return `${camelProp}: "${val}"`; }).filter(Boolean); return `{{ ${entries.join(", ")} }}`; } /** * Convert SVG attributes to JSX-safe equivalents. * - class -> className * - style="..." -> style={{ ... }} (object form for React) * - kebab-case -> camelCase * - Removes xmlns (React adds it automatically) * - Converts xlink:href -> href (xlink is deprecated) */ function convertAttrToJsx(attr: string, value: string): string | null { // Drop all xmlns declarations — React handles xmlns automatically if (attr === "xmlns" || attr.startsWith("xmlns:")) return null; // Drop Inkscape/Sodipodi-specific attributes (not valid in JSX) if (attr.startsWith("inkscape:") || attr.startsWith("sodipodi:")) return null; // Drop xml:space and other xml: prefixed attributes if (attr.startsWith("xml:")) return null; // Drop namespace-prefixed attributes from Inkscape metadata (dc:, cc:, rdf:) if (attr.startsWith("dc:") || attr.startsWith("cc:") || attr.startsWith("rdf:")) return null; // class -> className if (attr === "class") return `className=${value}`; // xlink:href -> href if (attr === "xlink:href") return `href=${value}`; // style="..." -> style={{ ... }} (React requires object, not string) if (attr === "style") { return `style=${convertStyleStringToJsx(value)}`; } // Convert kebab-case to camelCase const camel = kebabToCamel(attr); return `${camel}=${value}`; } /** * Parse an SVG string and return JSX-safe inner content (everything between * the opening and closing tags), plus any extra props that * should be spread onto the outer element (viewBox). * * We intentionally do NOT use a full DOM parser to keep zero runtime deps. * The conversion is done with targeted regex replacements that are sufficient * for well-formed SVG files produced by design tools / icon libraries. */ function svgToJsxInner(svgContent: string): { inner: string; viewBox: string; fill: string; stroke?: string; } { const viewBox = extractViewBox(svgContent); const { fill, stroke } = extractRootSvgPaint(svgContent); // Strip outer wrapper tags let inner = svgContent // Remove the opening tag (single-line or multi-line) .replace(/^]*>/s, "") // Remove the closing tag .replace(/<\/svg>\s*$/, "") .trim(); // Strip non-JSX XML/HTML constructs inner = inner.replace(/<\?xml[^?]*\?>/g, ""); // XML prologs inner = inner.replace(/]*>/gi, ""); // DOCTYPE declarations inner = inner.replace(//g, ""); // HTML/XML comments // Strip elements that are invalid in JSX inner = inner.replace(/]*(?:\/>|>[\s\S]*?<\/sodipodi:[^>]+>)/g, ""); inner = inner.replace(//g, ""); inner = inner.replace(//g, ""); // Inline CSS