/
githubmirror
/
ui
Обзор
Документация
Войти
/
githubmirror
/
ui
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
apps/v4/scripts/build-icons.ts
269 строк
8 KB
shadcn
feat(shadcn): migrate icons between any icon libraries (#11241)
22 июл 2026, 14:03
Не верифицирован
22 июл 2026, 14:03
3c26ee2
Код
Авторство
О чём код?
#!/usr/bin/env tsx import * as fs from "fs" import * as path from "path" import { iconLibraries, type IconLibraryName } from "shadcn/icons" type IconUsage = Record<IconLibraryName, Set<string>> // Canonical (lucide) icon name -> { library: icon name }. type IconMapping = Record<string, Record<string, string>> function findTsxFiles(dir: string) { const files: string[] = [] // Sort for a deterministic scan order: mapping conflicts resolve to the // first occurrence, which must not depend on filesystem enumeration order. const entries = fs .readdirSync(dir, { withFileTypes: true }) .sort((a, b) => a.name.localeCompare(b.name)) 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 iconMapping: IconMapping = {} // Keyed to dedupe: the same conflict shows up once per placeholder // occurrence across bases, but only the first file is worth reporting. const warnings = new Map<string, string>() 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( `<IconPlaceholder\\s+([^>]*?)(?:${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] const placeholderIcons: Partial<Record<IconLibraryName, string>> = {} 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]) placeholderIcons[libraryName as IconLibraryName] = attrMatch[1] } } // Lucide is the canonical key: every placeholder has a lucide prop. const canonical = placeholderIcons.lucide if (!canonical) { const relativeFile = path.relative(process.cwd(), file) warnings.set( `missing-lucide|${relativeFile}`, `IconPlaceholder without a lucide prop in ${relativeFile}` ) continue } const entry = (iconMapping[canonical] ??= {}) for (const [libraryName, iconName] of Object.entries(placeholderIcons)) { if (entry[libraryName] && entry[libraryName] !== iconName) { const key = `conflict|${libraryName}|${canonical}|${iconName}` if (!warnings.has(key)) { warnings.set( key, `Conflicting ${libraryName} mapping for ${canonical}: keeping ${entry[libraryName]}, ignoring ${iconName} (first seen in ${path.relative(process.cwd(), file)})` ) } continue } entry[libraryName] = iconName } } } return { iconUsage, iconMapping, warnings: Array.from(warnings.values()), } } 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 generateIconMapping(iconMapping: IconMapping, warnings: string[]) { const legacyPath = path.join( process.cwd(), "registry/icons/legacy-mapping.json" ) const legacy: IconMapping = JSON.parse(fs.readFileSync(legacyPath, "utf-8")) const output: IconMapping = {} const scanned: IconMapping = { ...iconMapping } const libraryOrder = Object.keys(iconLibraries) // Legacy entries come first with their lucide/radix values untouched: // published CLI versions reverse-lookup this file by first match, so // entry order and legacy values are a compatibility contract. for (const [canonical, legacyEntry] of Object.entries(legacy)) { const entry: Record<string, string> = { ...legacyEntry } // Registry code uses lucide's suffixed aliases (e.g. CheckIcon for Check). const scannedKey = canonical in scanned ? canonical : `${canonical}Icon` in scanned ? `${canonical}Icon` : undefined if (scannedKey) { for (const library of libraryOrder) { if (!entry[library] && scanned[scannedKey][library]) { entry[library] = scanned[scannedKey][library] } } delete scanned[scannedKey] } output[canonical] = entry } for (const canonical of Object.keys(scanned).sort()) { const entry: Record<string, string> = {} for (const library of libraryOrder) { if (scanned[canonical][library]) { entry[library] = scanned[canonical][library] } } output[canonical] = entry } const outputPath = path.join(process.cwd(), "public/r/icons/index.json") const content = JSON.stringify(output, null, 2) if ( fs.existsSync(outputPath) && fs.readFileSync(outputPath, "utf-8") === content ) { return } fs.writeFileSync(outputPath, content) console.log( `✓ Generated icon mapping: ${Object.keys(output).length} icons (${ Object.keys(legacy).length } legacy)` ) // Only surface scan warnings when the mapping actually changed: they are // informational (first occurrence wins) and would otherwise repeat on // every rebuild in watch mode. if (warnings.length > 0) { if (isVerbose) { warnings.forEach((warning) => console.warn(`⚠ ${warning}`)) } else { console.warn( `⚠ ${warnings.length} icon mapping conflicts (first occurrence wins). Run with --verbose for details.` ) } } } function main() { const { iconUsage, iconMapping, warnings } = scanIconUsage() generateIconFiles(iconUsage) generateIconMapping(iconMapping, warnings) } const isWatchMode = process.argv.includes("--watch") const isVerbose = process.argv.includes("--verbose") 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() }