snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
269
apps/v4/scripts/build-icons.ts
Normal file
269
apps/v4/scripts/build-icons.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/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()
|
||||
}
|
||||
Reference in New Issue
Block a user