/** * Generates the SVG icon sprite file from @remixicon/react bundle. * * Usage: bun run scripts/generate-icon-sprite.mjs * * Reads the minified @remixicon/react bundle, extracts SVG path data * for all Ri* icons used in packages/ui/src, and writes * packages/ui/src/components/icon/sprite.ts. */ import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs" import { resolve, dirname } from "node:path" import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(__dirname, "..") const remixPath = resolve(repoRoot, "node_modules/@remixicon/react/index.mjs") const outPath = resolve(repoRoot, "packages/ui/src/components/icon/sprite.ts") const customIconData = new Map([ [ "openchamber", ``, ], // Claude spark — official Anthropic mark (Simple Icons path), monochrome. [ "claude-code", ``, ], // Cursor two-cursor mark — official (Simple Icons path), monochrome. [ "cursor", ``, ], // Command Code — corner squares + center square (official logo geometry). [ "command-code", ``, ], [ "gitlab", ``, ], [ "gitea", ``, ], ]) const source = readFileSync(remixPath, "utf-8") // --- Step 1: extract variable → path mapping --- // Pattern: const VARNAME=({color:...})=>...createElement("path",{d:"PATH_DATA"})..., // Each icon is defined as `const X=...` where X is 1-4 chars. const varPathMap = new Map() const varRegex = /(?:[,;]const |\),)([A-Za-z0-9_$]{1,4})=\([{]color:/g // Find all variable definitions and their boundaries const varPositions = [] let m while ((m = varRegex.exec(source)) !== null) { varPositions.push({ varName: m[1], start: m.index + m[0].length - 1, // first `{` after `=({color:` }) } for (let i = 0; i < varPositions.length; i++) { const current = varPositions[i] const next = varPositions[i + 1] // End at the )), just before the next variable definition const end = next ? source.indexOf("))," + next.varName + "=(", current.start) : source.length if (end < 0 || end < current.start) continue const segment = source.slice(current.start, end) const pathRegex = /\w+\.createElement\("path",[{]d:"([^"]*)"/g let pm const paths = [] while ((pm = pathRegex.exec(segment)) !== null) { paths.push(pm[1]) } if (paths.length > 0) { varPathMap.set(current.varName, paths) } } // --- Step 2: extract export mapping --- // The export map is near the end of the file: // export{V1 as Ri...Z2 as RiLast}; const exportRegex = /export[{]([^}]+)[}]/ const exportMatch = exportRegex.exec(source) if (!exportMatch) { console.error("Could not find export mapping in remixicon bundle") process.exit(1) } const nameToVar = new Map() const entries = exportMatch[1].split(",") for (const entry of entries) { // Pattern: VAR as RiIconName const parts = entry.trim().split(" as ") if (parts.length === 2) { nameToVar.set(parts[1].trim(), parts[0].trim()) } } const remixToSpriteName = (name) => { // RiArrowDownSLine → arrow-down-s // RiGithubFill → github-fill (keep Fill for fill variants) return name .replace(/^Ri/, "") .replace(/Line$/, "") .replace(/([a-z])([A-Z0-9])/g, "$1-$2") .replace(/([0-9])([A-Z])/g, "$1-$2") .toLowerCase() } const spriteNameToRi = new Map() const hasRemixVariantSuffix = (name) => name.endsWith("Line") || name.endsWith("Fill") const shouldPreferSpriteCandidate = (current, candidate) => { if (!current) return true if (!hasRemixVariantSuffix(candidate) && hasRemixVariantSuffix(current)) return true if (!hasRemixVariantSuffix(current)) return false if (candidate.endsWith("Line") && !current.endsWith("Line")) return true return false } for (const iconName of nameToVar.keys()) { const spriteName = remixToSpriteName(iconName) const current = spriteNameToRi.get(spriteName) if (shouldPreferSpriteCandidate(current, iconName)) { spriteNameToRi.set(spriteName, iconName) } } // --- Step 3: find which icons we actually use --- const srcDir = resolve(repoRoot, "packages/ui/src") // Helper: convert kebab-case name back to RiName function nameToRi(kebab) { // "arrow-down-sline" → RiArrowDownSline const parts = kebab.split("-") let result = "Ri" for (let i = 0; i < parts.length; i++) { const part = parts[i] if (i > 0 && /^\d/.test(part)) { result += part[0].toUpperCase() + part.slice(1) } else { result += part.charAt(0).toUpperCase() + part.slice(1) } } return result } // Finish step 3 synchronously with simpler approach function findAllSourceFiles(dir) { const results = [] for (const entry of readdirSync(dir)) { const full = resolve(dir, entry) try { const st = statSync(full) if (st.isDirectory()) { if (entry === "node_modules") continue results.push(...findAllSourceFiles(full)) } else if (/\.(tsx?)$/.test(entry) && full !== outPath) { results.push(full) } } catch { /* skip */ } } return results } const allSrcFiles = findAllSourceFiles(srcDir) const usedIcons = new Set() const usedCustomIcons = new Set() const addKebabIcon = (kebab) => { if (customIconData.has(kebab)) { usedCustomIcons.add(kebab) return true } const exactRiName = spriteNameToRi.get(kebab) if (exactRiName && !hasRemixVariantSuffix(exactRiName)) { usedIcons.add(exactRiName) return true } for (const suffix of ["Line", "Fill", ""]) { const riName = nameToRi(kebab) + suffix if (nameToVar.has(riName)) { usedIcons.add(riName) return true } } if (exactRiName) { usedIcons.add(exactRiName) return true } return false } const addIconLiterals = (content) => { const iconLiteralRegex = /["']([a-z][a-z0-9-]*)["']/g let literal while ((literal = iconLiteralRegex.exec(content)) !== null) { addKebabIcon(literal[1]) } } function findMatchingBrace(content, openBraceIndex) { let depth = 0 let quote = null let escaped = false let lineComment = false let blockComment = false for (let i = openBraceIndex; i < content.length; i++) { const char = content[i] const next = content[i + 1] if (lineComment) { if (char === "\n") lineComment = false continue } if (blockComment) { if (char === "*" && next === "/") { blockComment = false i++ } continue } if (quote) { if (escaped) { escaped = false } else if (char === "\\") { escaped = true } else if (char === quote) { quote = null } continue } if (char === "/" && next === "/") { lineComment = true i++ continue } if (char === "/" && next === "*") { blockComment = true i++ continue } if (char === "\"" || char === "'" || char === "`") { quote = char continue } if (char === "{") { depth++ } else if (char === "}") { depth-- if (depth === 0) return i } } return -1 } const addIconNameFunctionReturns = (content) => { const functionRegex = /function\s+\w+\s*\([^)]*\)\s*:\s*IconName(?:\s*\|\s*null)?\s*{/g let match while ((match = functionRegex.exec(content)) !== null) { const openBraceIndex = content.indexOf("{", match.index) if (openBraceIndex === -1) continue const closeBraceIndex = findMatchingBrace(content, openBraceIndex) if (closeBraceIndex === -1) continue const body = content.slice(openBraceIndex + 1, closeBraceIndex) const returnRegex = /\breturn\s+["']([a-z][a-z0-9-]*)["']/g let returnMatch while ((returnMatch = returnRegex.exec(body)) !== null) { addKebabIcon(returnMatch[1]) } functionRegex.lastIndex = closeBraceIndex + 1 } } const addTypedIconNameRecords = (content) => { const recordRegex = /:\s*Record<[^>]*IconName[^>]*>\s*=\s*{/g let match while ((match = recordRegex.exec(content)) !== null) { const openBraceIndex = content.indexOf("{", match.index) if (openBraceIndex === -1) continue const closeBraceIndex = findMatchingBrace(content, openBraceIndex) if (closeBraceIndex === -1) continue addIconLiterals(content.slice(openBraceIndex + 1, closeBraceIndex)) recordRegex.lastIndex = closeBraceIndex + 1 } } const addIconNameVariableAssignments = (content) => { if (!/ patterns (already-migrated icons) const iconNameRegex = /]*\bname=(?:["']([^"']+)["']|{\s*["']([^"']+)["']\s*})/g let nm while ((nm = iconNameRegex.exec(content)) !== null) { addKebabIcon(nm[1] || nm[2]) } // Also scan for icon: 'kebab-name' / Icon: 'kebab-name' in object literals. const iconPropRegex = /\b[Ii]con:\s*["']([a-z][a-z0-9-]*)["']/g let ip while ((ip = iconPropRegex.exec(content)) !== null) { addKebabIcon(ip[1]) } // Also scan JSX props named icon/Icon with a string literal value. const iconJsxPropRegex = /\b[Ii]con=(?:["']([^"']+)["']|{\s*["']([^"']+)["']\s*})/g let jp while ((jp = iconJsxPropRegex.exec(content)) !== null) { addKebabIcon(jp[1] || jp[2]) } addIconNameFunctionReturns(content) addTypedIconNameRecords(content) addIconNameVariableAssignments(content) } console.log(`Found ${usedIcons.size} unique remixicon names used in source`) // --- Step 4: build sprite data --- const iconEntries = [] for (const iconName of [...usedIcons].sort()) { const varName = nameToVar.get(iconName) if (!varName) { console.warn(` ⚠ Unknown icon: ${iconName}`) continue } const paths = varPathMap.get(varName) if (!paths || paths.length === 0) { console.warn(` ⚠ No path data for: ${iconName} (var: ${varName})`) continue } // Build SVG content from paths const svgContent = paths .map((d) => ``) .join("") iconEntries.push({ name: iconName, content: svgContent }) } for (const iconName of [...usedCustomIcons].sort()) { iconEntries.push({ name: iconName, content: customIconData.get(iconName) }) } // --- Step 5: write sprite.ts --- const spriteLines = iconEntries .map(({ name, content }) => ({ name: name.startsWith("Ri") ? remixToSpriteName(name) : name, content, })) .sort((left, right) => left.name.localeCompare(right.name)) .map(({ name, content }) => ` "${name}": \`${content}\`,`) const spriteContent = `// This file is auto-generated by scripts/generate-icon-sprite.mjs // Do not edit manually. Run the script to update. export const iconSpriteData = { ${spriteLines.join("\n")} } as const satisfies Record; ` writeFileSync(outPath, spriteContent, "utf-8") console.log(`\n✅ Generated sprite data for ${iconEntries.length} icons → ${outPath}`) console.log(` Total sprite size: ${Buffer.byteLength(spriteContent).toLocaleString()} bytes`)