270 lines
7.9 KiB
TypeScript
270 lines
7.9 KiB
TypeScript
#!/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()
|
|
}
|