#!/usr/bin/env tsx import * as fs from "fs" import * as path from "path" import { iconLibraries, type IconLibraryName } from "shadcn/icons" type IconUsage = Record> function findTsxFiles(dir: string) { const files: string[] = [] const entries = fs.readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { files.push(...findTsxFiles(fullPath)) } else if (entry.isFile() && entry.name.endsWith(".tsx")) { files.push(fullPath) } } return files } function scanIconUsage() { const iconUsage: IconUsage = Object.keys(iconLibraries).reduce((acc, key) => { acc[key as IconLibraryName] = new Set() return acc }, {} as IconUsage) const registryBasesDir = path.join(process.cwd(), "registry/bases") const files = findTsxFiles(registryBasesDir) const libraryNames = Object.values(iconLibraries) .map((lib) => lib.name) .join("|") const iconPlaceholderRegex = new RegExp( `]*?)(?:${libraryNames})=["']([^"']+)["']([^>]*?)\\/?>`, "g" ) for (const file of files) { const content = fs.readFileSync(file, "utf-8") let match while ((match = iconPlaceholderRegex.exec(content)) !== null) { const fullMatch = match[0] for (const [libraryName, config] of Object.entries(iconLibraries)) { const attrMatch = fullMatch.match( new RegExp(`${config.name}=["']([^"']+)["']`) ) if (attrMatch) { iconUsage[libraryName as IconLibraryName].add(attrMatch[1]) } } } } return iconUsage } function generateIconFiles(iconUsage: IconUsage) { const outputDir = path.join(process.cwd(), "registry/icons") const written: string[] = [] Object.entries(iconLibraries).forEach(([libraryName, config]) => { const icons = Array.from(iconUsage[libraryName as IconLibraryName]).sort() if (icons.length === 0) { return } const content = `// Auto-generated by scripts/build-icons.ts ${icons.map((icon) => `export { ${icon} } from "${config.export}"`).join("\n")} ` const filename = `__${libraryName}__.ts` const filepath = path.join(outputDir, filename) // Skip unchanged files to avoid mtime bumps that trigger // unnecessary Turbopack invalidations in watch mode. if ( fs.existsSync(filepath) && fs.readFileSync(filepath, "utf-8") === content ) { return } fs.writeFileSync(filepath, content) written.push(` - ${config.title}: ${icons.length} icons`) }) if (written.length > 0) { console.log("✓ Generated icon files:") written.forEach((line) => console.log(line)) } } function main() { const iconUsage = scanIconUsage() generateIconFiles(iconUsage) } const isWatchMode = process.argv.includes("--watch") if (isWatchMode) { const REGISTRY_DIR = path.join(process.cwd(), "registry/bases") async function startWatcher() { const { default: chokidar } = await import("chokidar") main() const watcher = chokidar.watch(REGISTRY_DIR, { ignored: /(^|[/\\])\../, persistent: true, ignoreInitial: true, }) const rebuild = (filename: string) => { if (!filename.endsWith(".tsx")) return try { main() } catch (error) { console.error("❌ Icons build failed:", error) } } watcher.on("error", (error) => { console.error("❌ Watcher error:", error) }) watcher.on("change", rebuild) watcher.on("add", rebuild) process.on("SIGINT", async () => { await watcher.close() process.exit(0) }) } startWatcher() } else { main() }