mirror of
https://github.com/unovue/shadcn-vue.git
synced 2026-09-14 19:59:38 +08:00
fix(cli): generate icon map from base placeholders to prevent mapping drift (#1896)
* docs: design spec for generating the CLI icon map from IconPlaceholder source of truth Ports the missing half of upstream's build-icons.ts so index.json is generated from the reka base placeholders instead of hand-maintained, closing the #1893 drift class. Adds a coverage assertion for the raw-lucide new-york-v4 registry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: resolve icon-map spec decisions (radix legacy-only, regen __*__.ts, defer #1894) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: icon-map generation implementation plan + spec drift-semantics fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(scripts): icon-map pure helpers — phosphor derivation and record mapping * feat(scripts): icon-map legacy merge (legacy values win, scan fills gaps) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(scripts): render __*__.ts icon re-export files (phosphor aliased) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(scripts): coverage check helper with suffix bridge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(scripts): cover third suffix-bridge direction and dedup/sort in findUncovered Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(icons): seed legacy-mapping.json from current icon map * feat(scripts): scan IconPlaceholder attributes from base SFCs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(scripts): validate generated icon names against installed packages * feat(scripts): build-icons orchestrator, validation and coverage assertion * fix(scripts): skip Vue dynamic icon bindings in placeholder scanner * fix(registry): correct invalid icon names in base placeholders * chore(icons): regenerate icon map and re-export files from bases * fix(scripts): generate only the CLI icon map, leave __*__.ts to the showcase * build(icons): generate the icon map as part of the registry build Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(icons): gate on icon-map staleness and run icon unit tests * docs: amend icon-map spec/plan (generator writes only index.json; vitest; integration fixes) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: remove planning docs from PR Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(icons): resolve CodeRabbit review — dedupe Search key, handle aliased imports - Align blocks/vercel.vue placeholder to lucide="SearchIcon" (the outlier that produced both Search and SearchIcon canonical keys); regeneration now collapses them into one entry. - Add findDuplicateCanonicals guard so the generator fails loud on any future X/XIcon key split from inconsistent base placeholders. - parseLucideImports resolves `as` aliases and `type` modifiers so the coverage check reads the exported name, not the local alias (with regression tests). - Add docstrings to the exported generator API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,3 +41,7 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Check icon map is up to date
|
||||
working-directory: ./apps/v4
|
||||
run: pnpm registry:check:icons
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "tsx scripts/lib/command-item-tailwind.test.ts",
|
||||
"test": "tsx scripts/lib/command-item-tailwind.test.ts && vitest run scripts/build-icons.test.ts",
|
||||
"typecheck": "vue-tsc -b",
|
||||
"deploy": "pnpm build && npx tsx ./scripts/seed-d1.ts && npx wrangler --cwd .output/ deploy && npx tsx ./scripts/purge-cache.ts",
|
||||
"registry:build": "tsx --tsconfig ./tsconfig.json ./scripts/build-registry.ts && pnpm registry:build:index",
|
||||
"registry:build:icons": "tsx --tsconfig ./tsconfig.json ./scripts/build-icons.ts",
|
||||
"registry:build:index": "tsx --tsconfig ./tsconfig.json ./scripts/build-registries-index.ts",
|
||||
"registry:generate": "tsx --tsconfig ./tsconfig.json ./scripts/generate-base-registry.ts"
|
||||
"registry:generate": "tsx --tsconfig ./tsconfig.json ./scripts/generate-base-registry.ts",
|
||||
"registry:check:icons": "tsx scripts/build-icons.ts && git diff --exit-code public/r/icons/index.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "^0.1.21",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -750,7 +750,7 @@ const contributors = [
|
||||
<IconPlaceholder
|
||||
lucide="StarIcon"
|
||||
tabler="IconStar"
|
||||
hugeicons="Star01Icon"
|
||||
hugeicons="StarIcon"
|
||||
phosphor="StarIcon"
|
||||
remixicon="RiStarLine"
|
||||
/>
|
||||
|
||||
@@ -200,7 +200,7 @@ function getStrokePercent(percentage: number) {
|
||||
<InputGroup class="lg:ml-auto lg:max-w-72">
|
||||
<InputGroupAddon>
|
||||
<IconPlaceholder
|
||||
lucide="Search"
|
||||
lucide="SearchIcon"
|
||||
tabler="IconSearch"
|
||||
hugeicons="Search01Icon"
|
||||
phosphor="MagnifyingGlassIcon"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Example } from "~/registry/bases/reka/components/example"
|
||||
<IconPlaceholder
|
||||
lucide="HeartIcon"
|
||||
tabler="IconHeart"
|
||||
hugeicons="HeartIcon"
|
||||
hugeicons="FavouriteIcon"
|
||||
phosphor="HeartIcon"
|
||||
remixicon="RiHeartLine"
|
||||
data-icon="inline-start"
|
||||
|
||||
@@ -79,7 +79,7 @@ const isOpen = ref(false)
|
||||
tabler="IconMaximize"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="ArrowsOutIcon"
|
||||
remixicon="RiExpandLine"
|
||||
remixicon="RiFullscreenLine"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@@ -138,7 +138,7 @@ const theme = ref("light")
|
||||
tabler="IconFolderSearch"
|
||||
hugeicons="SearchIcon"
|
||||
phosphor="FolderSimpleDottedIcon"
|
||||
remixicon="RiFolderSearchLine"
|
||||
remixicon="RiSearchLine"
|
||||
/>
|
||||
Browse...
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -107,7 +107,7 @@ import { Example } from "~/registry/bases/reka/components/example"
|
||||
<IconPlaceholder
|
||||
lucide="PlayIcon"
|
||||
tabler="IconPlayerPlay"
|
||||
hugeicons="Play01Icon"
|
||||
hugeicons="PlayIcon"
|
||||
phosphor="PlayIcon"
|
||||
remixicon="RiPlayLine"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"AlertCircle": {
|
||||
"lucide": "AlertCircle",
|
||||
"radix": "ExclamationTriangleIcon",
|
||||
"tabler": "IconExclamationCircle",
|
||||
"phosphor": "PhWarningCircle",
|
||||
"remixicon": "RiAlertLine"
|
||||
},
|
||||
"ArrowLeft": {
|
||||
"lucide": "ArrowLeft",
|
||||
"radix": "ArrowLeftIcon",
|
||||
"tabler": "IconArrowLeft",
|
||||
"phosphor": "PhArrowLeft",
|
||||
"remixicon": "RiArrowLeftLine"
|
||||
},
|
||||
"ArrowRight": {
|
||||
"lucide": "ArrowRight",
|
||||
"radix": "ArrowRightIcon",
|
||||
"tabler": "IconArrowRight",
|
||||
"phosphor": "PhArrowRight",
|
||||
"remixicon": "RiArrowRightLine"
|
||||
},
|
||||
"ArrowUpDown": {
|
||||
"lucide": "ArrowUpDown",
|
||||
"radix": "CaretSortIcon",
|
||||
"tabler": "IconArrowsSort",
|
||||
"phosphor": "PhArrowsVertical",
|
||||
"remixicon": "RiArrowUpDownLine"
|
||||
},
|
||||
"BellRing": {
|
||||
"lucide": "BellRing",
|
||||
"radix": "BellIcon",
|
||||
"tabler": "IconBellRinging",
|
||||
"phosphor": "PhBellRinging",
|
||||
"remixicon": "RiBellLine"
|
||||
},
|
||||
"Bold": {
|
||||
"lucide": "Bold",
|
||||
"radix": "FontBoldIcon",
|
||||
"tabler": "IconBold",
|
||||
"phosphor": "PhTextB",
|
||||
"remixicon": "RiBold"
|
||||
},
|
||||
"Calculator": {
|
||||
"lucide": "Calculator",
|
||||
"radix": "ComponentPlaceholderIcon",
|
||||
"tabler": "IconCalculator",
|
||||
"phosphor": "PhCalculator",
|
||||
"remixicon": "RiCalculatorLine"
|
||||
},
|
||||
"Calendar": {
|
||||
"lucide": "Calendar",
|
||||
"radix": "CalendarIcon",
|
||||
"tabler": "IconCalendar",
|
||||
"phosphor": "PhCalendarBlank",
|
||||
"remixicon": "RiCalendarLine"
|
||||
},
|
||||
"Check": {
|
||||
"lucide": "Check",
|
||||
"radix": "CheckIcon",
|
||||
"tabler": "IconCheck",
|
||||
"phosphor": "PhCheck",
|
||||
"remixicon": "RiCheckLine"
|
||||
},
|
||||
"ChevronDown": {
|
||||
"lucide": "ChevronDown",
|
||||
"radix": "ChevronDownIcon",
|
||||
"tabler": "IconChevronDown",
|
||||
"phosphor": "PhCaretDown",
|
||||
"remixicon": "RiArrowDownSLine"
|
||||
},
|
||||
"ChevronLeft": {
|
||||
"lucide": "ChevronLeft",
|
||||
"radix": "ChevronLeftIcon",
|
||||
"tabler": "IconChevronLeft",
|
||||
"phosphor": "PhCaretLeft",
|
||||
"remixicon": "RiArrowLeftSLine"
|
||||
},
|
||||
"ChevronRight": {
|
||||
"lucide": "ChevronRight",
|
||||
"radix": "ChevronRightIcon",
|
||||
"tabler": "IconChevronRight",
|
||||
"phosphor": "PhCaretRight",
|
||||
"remixicon": "RiArrowRightSLine"
|
||||
},
|
||||
"ChevronUp": {
|
||||
"lucide": "ChevronUp",
|
||||
"radix": "ChevronUpIcon",
|
||||
"tabler": "IconChevronUp",
|
||||
"phosphor": "PhCaretUp",
|
||||
"remixicon": "RiArrowUpSLine"
|
||||
},
|
||||
"ChevronsLeft": {
|
||||
"lucide": "ChevronsLeftIcon",
|
||||
"radix": "DoubleArrowLeftIcon",
|
||||
"tabler": "IconChevronsLeft",
|
||||
"hugeicons": "ArrowLeftDoubleIcon",
|
||||
"phosphor": "PhCaretDoubleLeft",
|
||||
"remixicon": "RiArrowLeftDoubleLine"
|
||||
},
|
||||
"ChevronsRight": {
|
||||
"lucide": "ChevronsRightIcon",
|
||||
"radix": "DoubleArrowRightIcon",
|
||||
"tabler": "IconChevronsRight",
|
||||
"hugeicons": "ArrowRightDoubleIcon",
|
||||
"phosphor": "PhCaretDoubleRight",
|
||||
"remixicon": "RiArrowRightDoubleLine"
|
||||
},
|
||||
"ChevronsUpDown": {
|
||||
"lucide": "ChevronsUpDown",
|
||||
"radix": "CaretSortIcon",
|
||||
"tabler": "IconSelector",
|
||||
"phosphor": "PhCaretUpDown",
|
||||
"remixicon": "RiExpandUpDownLine"
|
||||
},
|
||||
"Circle": {
|
||||
"lucide": "Circle",
|
||||
"radix": "DotFilledIcon",
|
||||
"tabler": "IconCircle",
|
||||
"phosphor": "PhCircle",
|
||||
"remixicon": "RiCircleLine"
|
||||
},
|
||||
"CircleCheck": {
|
||||
"lucide": "CircleCheckIcon",
|
||||
"radix": "CheckCircledIcon",
|
||||
"tabler": "IconCircleCheck",
|
||||
"hugeicons": "CheckmarkCircle01Icon",
|
||||
"phosphor": "PhCheckCircle",
|
||||
"remixicon": "RiCheckboxCircleLine"
|
||||
},
|
||||
"Copy": {
|
||||
"lucide": "Copy",
|
||||
"radix": "CopyIcon",
|
||||
"tabler": "IconCopy",
|
||||
"phosphor": "PhCopy",
|
||||
"remixicon": "RiFileCopyLine"
|
||||
},
|
||||
"CreditCard": {
|
||||
"lucide": "CreditCard",
|
||||
"radix": "ComponentPlaceholderIcon",
|
||||
"tabler": "IconCreditCard",
|
||||
"phosphor": "PhCreditCard",
|
||||
"remixicon": "RiBankCardLine"
|
||||
},
|
||||
"GripVertical": {
|
||||
"lucide": "GripVertical",
|
||||
"radix": "DragHandleDots2Icon",
|
||||
"tabler": "IconGripVertical",
|
||||
"phosphor": "PhDotsSixVertical",
|
||||
"remixicon": "RiDraggable"
|
||||
},
|
||||
"Info": {
|
||||
"lucide": "InfoIcon",
|
||||
"radix": "InfoCircledIcon",
|
||||
"tabler": "IconInfoCircle",
|
||||
"hugeicons": "InformationCircleIcon",
|
||||
"phosphor": "PhInfo",
|
||||
"remixicon": "RiInformationLine"
|
||||
},
|
||||
"Italic": {
|
||||
"lucide": "Italic",
|
||||
"radix": "FontItalicIcon",
|
||||
"tabler": "IconItalic",
|
||||
"phosphor": "PhTextItalic",
|
||||
"remixicon": "RiItalic"
|
||||
},
|
||||
"Loader2": {
|
||||
"lucide": "Loader2",
|
||||
"radix": "ReloadIcon",
|
||||
"tabler": "IconLoader2",
|
||||
"phosphor": "PhCircleNotch",
|
||||
"remixicon": "RiLoader4Line"
|
||||
},
|
||||
"Mail": {
|
||||
"lucide": "Mail",
|
||||
"radix": "EnvelopeClosedIcon",
|
||||
"tabler": "IconMail",
|
||||
"phosphor": "PhEnvelopeSimple",
|
||||
"remixicon": "RiMailLine"
|
||||
},
|
||||
"MailOpen": {
|
||||
"lucide": "MailOpen",
|
||||
"radix": "EnvelopeOpenIcon",
|
||||
"tabler": "IconMailOpened",
|
||||
"phosphor": "PhEnvelopeSimpleOpen",
|
||||
"remixicon": "RiMailOpenLine"
|
||||
},
|
||||
"Minus": {
|
||||
"lucide": "Minus",
|
||||
"radix": "MinusIcon",
|
||||
"tabler": "IconMinus",
|
||||
"phosphor": "PhMinus",
|
||||
"remixicon": "RiSubtractLine"
|
||||
},
|
||||
"Moon": {
|
||||
"lucide": "Moon",
|
||||
"radix": "MoonIcon",
|
||||
"tabler": "IconMoon",
|
||||
"phosphor": "PhMoon",
|
||||
"remixicon": "RiMoonLine"
|
||||
},
|
||||
"MoreHorizontal": {
|
||||
"lucide": "MoreHorizontal",
|
||||
"radix": "DotsHorizontalIcon",
|
||||
"tabler": "IconDots",
|
||||
"phosphor": "PhDotsThree",
|
||||
"remixicon": "RiMoreLine"
|
||||
},
|
||||
"OctagonX": {
|
||||
"lucide": "OctagonXIcon",
|
||||
"radix": "CrossCircledIcon",
|
||||
"tabler": "IconOctagonOff",
|
||||
"hugeicons": "CancelCircleIcon",
|
||||
"phosphor": "PhXCircle",
|
||||
"remixicon": "RiCloseCircleLine"
|
||||
},
|
||||
"PanelLeft": {
|
||||
"lucide": "PanelLeft",
|
||||
"radix": "ViewVerticalIcon",
|
||||
"tabler": "IconLayoutSidebar",
|
||||
"phosphor": "PhSidebarSimple",
|
||||
"remixicon": "RiSideBarLine"
|
||||
},
|
||||
"Plus": {
|
||||
"lucide": "Plus",
|
||||
"radix": "PlusIcon",
|
||||
"tabler": "IconPlus",
|
||||
"phosphor": "PhPlus",
|
||||
"remixicon": "RiAddLine"
|
||||
},
|
||||
"Search": {
|
||||
"lucide": "Search",
|
||||
"radix": "MagnifyingGlassIcon",
|
||||
"tabler": "IconSearch",
|
||||
"phosphor": "PhMagnifyingGlass",
|
||||
"remixicon": "RiSearchLine"
|
||||
},
|
||||
"Send": {
|
||||
"lucide": "Send",
|
||||
"radix": "PaperPlaneIcon",
|
||||
"tabler": "IconSend",
|
||||
"phosphor": "PhPaperPlaneTilt",
|
||||
"remixicon": "RiSendPlaneLine"
|
||||
},
|
||||
"Settings": {
|
||||
"lucide": "Settings",
|
||||
"radix": "GearIcon",
|
||||
"tabler": "IconSettings",
|
||||
"phosphor": "PhGear",
|
||||
"remixicon": "RiSettingsLine"
|
||||
},
|
||||
"Slash": {
|
||||
"lucide": "Slash",
|
||||
"radix": "SlashIcon",
|
||||
"tabler": "IconSlash",
|
||||
"phosphor": "PhCaretRight",
|
||||
"remixicon": "RiSlashCommands"
|
||||
},
|
||||
"Smile": {
|
||||
"lucide": "Smile",
|
||||
"radix": "FaceIcon",
|
||||
"tabler": "IconMoodSmile",
|
||||
"phosphor": "PhSmiley",
|
||||
"remixicon": "RiEmotionLine"
|
||||
},
|
||||
"Sun": {
|
||||
"lucide": "Sun",
|
||||
"radix": "SunIcon",
|
||||
"tabler": "IconSun",
|
||||
"phosphor": "PhSun",
|
||||
"remixicon": "RiSunLine"
|
||||
},
|
||||
"Terminal": {
|
||||
"lucide": "Terminal",
|
||||
"radix": "RocketIcon",
|
||||
"tabler": "IconTerminal",
|
||||
"phosphor": "PhTerminal",
|
||||
"remixicon": "RiTerminalBoxLine"
|
||||
},
|
||||
"TriangleAlert": {
|
||||
"lucide": "TriangleAlertIcon",
|
||||
"radix": "ExclamationTriangleIcon",
|
||||
"tabler": "IconAlertTriangle",
|
||||
"hugeicons": "Alert02Icon",
|
||||
"phosphor": "PhWarning",
|
||||
"remixicon": "RiAlertLine"
|
||||
},
|
||||
"Underline": {
|
||||
"lucide": "Underline",
|
||||
"radix": "UnderlineIcon",
|
||||
"tabler": "IconUnderline",
|
||||
"phosphor": "PhTextUnderline",
|
||||
"remixicon": "RiUnderline"
|
||||
},
|
||||
"User": {
|
||||
"lucide": "User",
|
||||
"radix": "PersonIcon",
|
||||
"tabler": "IconUser",
|
||||
"phosphor": "PhUser",
|
||||
"remixicon": "RiUserLine"
|
||||
},
|
||||
"X": {
|
||||
"lucide": "X",
|
||||
"radix": "Cross2Icon",
|
||||
"tabler": "IconX",
|
||||
"phosphor": "PhX",
|
||||
"remixicon": "RiCloseLine"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export type Library = 'lucide' | 'tabler' | 'hugeicons' | 'phosphor' | 'remixicon'
|
||||
export const GENERATABLE_LIBRARIES: Library[] = ['lucide', 'tabler', 'hugeicons', 'phosphor', 'remixicon']
|
||||
export type PlaceholderRecord = Partial<Record<Library, string>>
|
||||
export type IconMapping = Record<string, Record<string, string>>
|
||||
|
||||
/**
|
||||
* Convert a Phosphor placeholder alias (e.g. `CheckCircleIcon`) to the raw
|
||||
* `@phosphor-icons/vue` export name (`PhCheckCircle`) that the CLI map needs.
|
||||
*/
|
||||
export function deriveRawPhosphor(alias: string): string {
|
||||
return `Ph${alias.replace(/Icon$/, '')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `canonical -> { library: name }` mapping from scanned placeholder
|
||||
* records, keyed on each record's `lucide` value. Phosphor values are derived
|
||||
* to their raw export; conflicting values keep the first and emit a warning;
|
||||
* records without a `lucide` prop are skipped (with a warning). `usage` collects
|
||||
* the names seen per library (Phosphor keeps the alias form).
|
||||
*/
|
||||
export function buildMappingFromRecords(records: PlaceholderRecord[]): {
|
||||
mapping: IconMapping
|
||||
usage: Record<Library, Set<string>>
|
||||
warnings: string[]
|
||||
} {
|
||||
const mapping: IconMapping = {}
|
||||
const usage = Object.fromEntries(GENERATABLE_LIBRARIES.map(l => [l, new Set<string>()])) as Record<Library, Set<string>>
|
||||
const warnings: string[] = []
|
||||
|
||||
for (const record of records) {
|
||||
const canonical = record.lucide
|
||||
if (!canonical) {
|
||||
warnings.push('IconPlaceholder without a lucide prop')
|
||||
continue
|
||||
}
|
||||
const entry = (mapping[canonical] ??= {})
|
||||
for (const library of GENERATABLE_LIBRARIES) {
|
||||
const raw = record[library]
|
||||
if (!raw)
|
||||
continue
|
||||
usage[library].add(raw)
|
||||
const value = library === 'phosphor' ? deriveRawPhosphor(raw) : raw
|
||||
if (entry[library] && entry[library] !== value) {
|
||||
warnings.push(`Conflicting ${library} mapping for ${canonical}: keeping ${entry[library]}, ignoring ${value}`)
|
||||
continue
|
||||
}
|
||||
entry[library] = value
|
||||
}
|
||||
}
|
||||
|
||||
return { mapping, usage, warnings }
|
||||
}
|
||||
|
||||
const LIBRARY_ORDER = ['lucide', 'radix', 'tabler', 'hugeicons', 'phosphor', 'remixicon'] as const
|
||||
|
||||
/**
|
||||
* Merge the hand-maintained `legacy` layer with the `scanned` mapping. Legacy
|
||||
* entries come first and their values win; the scan only fills libraries a
|
||||
* legacy entry lacks, matched by exact key or the `+Icon` bridge. Remaining
|
||||
* scanned-only canonicals are appended sorted.
|
||||
*/
|
||||
export function mergeLegacy(legacy: IconMapping, scanned: IconMapping): IconMapping {
|
||||
const output: IconMapping = {}
|
||||
const remaining: IconMapping = { ...scanned }
|
||||
|
||||
for (const [canonical, legacyEntry] of Object.entries(legacy)) {
|
||||
const entry: Record<string, string> = { ...legacyEntry }
|
||||
const scannedKey
|
||||
= canonical in remaining ? canonical : `${canonical}Icon` in remaining ? `${canonical}Icon` : undefined
|
||||
if (scannedKey) {
|
||||
for (const library of LIBRARY_ORDER) {
|
||||
if (!entry[library] && remaining[scannedKey][library]) {
|
||||
entry[library] = remaining[scannedKey][library]
|
||||
}
|
||||
}
|
||||
delete remaining[scannedKey]
|
||||
}
|
||||
output[canonical] = entry
|
||||
}
|
||||
|
||||
for (const canonical of Object.keys(remaining).sort()) {
|
||||
const entry: Record<string, string> = {}
|
||||
for (const library of LIBRARY_ORDER) {
|
||||
if (remaining[canonical][library])
|
||||
entry[library] = remaining[canonical][library]
|
||||
}
|
||||
output[canonical] = entry
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export const LIBRARY_EXPORT: Record<Library, string> = {
|
||||
lucide: '@lucide/vue',
|
||||
tabler: '@tabler/icons-vue',
|
||||
hugeicons: '@hugeicons/core-free-icons',
|
||||
phosphor: '@phosphor-icons/vue',
|
||||
remixicon: '@remixicon/vue',
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the deduplicated, sorted set of `usedLucide` names that have no entry
|
||||
* in `mapping`, applying the CLI's suffix bridge (a name matches if it, its
|
||||
* `Icon`-stripped form, or its `+Icon` form is a key).
|
||||
*/
|
||||
export function findUncovered(mapping: IconMapping, usedLucide: string[]): string[] {
|
||||
const has = (n: string) => n in mapping || n.replace(/Icon$/, '') in mapping || `${n}Icon` in mapping
|
||||
return [...new Set(usedLucide)].filter(n => !has(n)).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the exported icon names from every `@lucide/vue` named import in one
|
||||
* SFC's source. An alias (`Foo as Bar`) resolves to the exported name `Foo`,
|
||||
* and a leading `type` modifier is dropped, so coverage lookups match the
|
||||
* mapping keys rather than the local alias.
|
||||
*/
|
||||
export function parseLucideImports(source: string): string[] {
|
||||
const names: string[] = []
|
||||
for (const m of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]@lucide\/vue['"]/g)) {
|
||||
for (const raw of m[1].split(',')) {
|
||||
const name = raw.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0]?.trim()
|
||||
if (name)
|
||||
names.push(name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect canonical keys present in both unsuffixed and `Icon`-suffixed form
|
||||
* (e.g. `Search` and `SearchIcon`) — the same Lucide icon under two keys,
|
||||
* caused by base placeholders disagreeing on their `lucide=` value. Returns
|
||||
* the unsuffixed key of each offending pair, sorted.
|
||||
*/
|
||||
export function findDuplicateCanonicals(mapping: IconMapping): string[] {
|
||||
return Object.keys(mapping)
|
||||
.filter(k => !k.endsWith('Icon') && `${k}Icon` in mapping)
|
||||
.sort()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { it } from 'vitest'
|
||||
import { buildMappingFromRecords, deriveRawPhosphor, findDuplicateCanonicals, findUncovered, mergeLegacy, parseLucideImports } from './build-icons.helpers'
|
||||
|
||||
it('deriveRawPhosphor strips Icon suffix and prefixes Ph', () => {
|
||||
assert.equal(deriveRawPhosphor('CheckCircleIcon'), 'PhCheckCircle')
|
||||
assert.equal(deriveRawPhosphor('WarningIcon'), 'PhWarning')
|
||||
assert.equal(deriveRawPhosphor('XIcon'), 'PhX')
|
||||
assert.equal(deriveRawPhosphor('CaretDoubleLeftIcon'), 'PhCaretDoubleLeft')
|
||||
})
|
||||
|
||||
it('buildMappingFromRecords keys on lucide value and derives raw phosphor', () => {
|
||||
const { mapping, usage } = buildMappingFromRecords([
|
||||
{ lucide: 'CircleCheckIcon', tabler: 'IconCircleCheck', hugeicons: 'CheckmarkCircle01Icon', phosphor: 'CheckCircleIcon', remixicon: 'RiCheckboxCircleLine' },
|
||||
])
|
||||
assert.deepEqual(mapping.CircleCheckIcon, {
|
||||
lucide: 'CircleCheckIcon',
|
||||
tabler: 'IconCircleCheck',
|
||||
hugeicons: 'CheckmarkCircle01Icon',
|
||||
phosphor: 'PhCheckCircle',
|
||||
remixicon: 'RiCheckboxCircleLine',
|
||||
})
|
||||
assert.ok(usage.phosphor.has('CheckCircleIcon')) // usage keeps the alias for __phosphor__.ts
|
||||
assert.ok(usage.tabler.has('IconCircleCheck'))
|
||||
})
|
||||
|
||||
it('buildMappingFromRecords warns and skips a record without lucide', () => {
|
||||
const { mapping, warnings } = buildMappingFromRecords([{ tabler: 'IconX' }])
|
||||
assert.equal(Object.keys(mapping).length, 0)
|
||||
assert.equal(warnings.length, 1)
|
||||
})
|
||||
|
||||
it('buildMappingFromRecords keeps first on conflict and warns', () => {
|
||||
const { mapping, warnings } = buildMappingFromRecords([
|
||||
{ lucide: 'XIcon', tabler: 'IconX' },
|
||||
{ lucide: 'XIcon', tabler: 'IconXbox' },
|
||||
])
|
||||
assert.equal(mapping.XIcon.tabler, 'IconX')
|
||||
assert.ok(warnings.some(w => w.includes('XIcon')))
|
||||
})
|
||||
|
||||
it('mergeLegacy: legacy values win, scan fills only missing libraries', () => {
|
||||
const legacy = { Loader2: { lucide: 'Loader2', radix: 'ReloadIcon', tabler: 'IconLoader2', phosphor: 'PhCircleNotch', remixicon: 'RiLoader4Line' } }
|
||||
const scanned = { Loader2Icon: { lucide: 'Loader2Icon', tabler: 'IconLoader2', hugeicons: 'Loading03Icon', phosphor: 'PhSpinnerGap', remixicon: 'RiLoader4Line' } }
|
||||
const out = mergeLegacy(legacy, scanned)
|
||||
assert.equal(out.Loader2.phosphor, 'PhCircleNotch') // legacy wins, NOT PhSpinnerGap
|
||||
assert.equal(out.Loader2.hugeicons, 'Loading03Icon') // gap filled from scan
|
||||
assert.equal(out.Loader2.radix, 'ReloadIcon') // radix preserved
|
||||
assert.ok(!('Loader2Icon' in out)) // scanned key consumed via +Icon bridge
|
||||
})
|
||||
|
||||
it('mergeLegacy: scanned-only canonicals are appended', () => {
|
||||
const out = mergeLegacy({}, { CircleCheckIcon: { lucide: 'CircleCheckIcon', phosphor: 'PhCheckCircle' } })
|
||||
assert.deepEqual(out.CircleCheckIcon, { lucide: 'CircleCheckIcon', phosphor: 'PhCheckCircle' })
|
||||
})
|
||||
|
||||
it('findUncovered: bridges suffix in both directions', () => {
|
||||
const mapping = { ChevronsLeft: {}, CircleCheckIcon: {} }
|
||||
assert.deepEqual(findUncovered(mapping, ['ChevronsLeftIcon']), []) // key unsuffixed, use suffixed
|
||||
assert.deepEqual(findUncovered(mapping, ['CircleCheckIcon']), []) // exact
|
||||
assert.deepEqual(findUncovered(mapping, ['GripVertical']), ['GripVertical'])
|
||||
assert.deepEqual(findUncovered({ CircleCheckIcon: {} }, ['CircleCheck']), []) // use unsuffixed, key suffixed
|
||||
assert.deepEqual(findUncovered({}, ['Zeta', 'Alpha', 'Alpha']), ['Alpha', 'Zeta']) // dedup and sort
|
||||
})
|
||||
|
||||
import { scanPlaceholders, validateNames } from './build-icons'
|
||||
|
||||
it('scanPlaceholders: extracts multi-line placeholder attributes', () => {
|
||||
const sfc = `<template>
|
||||
<IconPlaceholder
|
||||
lucide="ChevronsLeftIcon"
|
||||
tabler="IconChevronsLeft"
|
||||
hugeicons="ArrowLeftDoubleIcon"
|
||||
phosphor="CaretDoubleLeftIcon"
|
||||
remixicon="RiArrowLeftDoubleLine"
|
||||
data-icon="inline-start"
|
||||
/>
|
||||
</template>`
|
||||
const records = scanPlaceholders(sfc)
|
||||
assert.equal(records.length, 1)
|
||||
assert.deepEqual(records[0], {
|
||||
lucide: 'ChevronsLeftIcon',
|
||||
tabler: 'IconChevronsLeft',
|
||||
hugeicons: 'ArrowLeftDoubleIcon',
|
||||
phosphor: 'CaretDoubleLeftIcon',
|
||||
remixicon: 'RiArrowLeftDoubleLine',
|
||||
})
|
||||
})
|
||||
|
||||
it('scanPlaceholders: ignores non-IconPlaceholder elements', () => {
|
||||
assert.deepEqual(scanPlaceholders('<template><ChevronLeftIcon /></template>'), [])
|
||||
})
|
||||
|
||||
it('scanPlaceholders: skips Vue dynamic bindings (v-bind shorthand and data- attrs)', () => {
|
||||
assert.deepEqual(
|
||||
scanPlaceholders('<IconPlaceholder :lucide="x.lucide" :tabler="x.tabler" />'),
|
||||
[],
|
||||
)
|
||||
assert.deepEqual(
|
||||
scanPlaceholders('<IconPlaceholder lucide="XIcon" :tabler="x.t" />'),
|
||||
[{ lucide: 'XIcon' }],
|
||||
)
|
||||
})
|
||||
|
||||
it('validateNames: real names pass, bogus name fails', async () => {
|
||||
const ok = await validateNames({ X: { phosphor: 'PhX', tabler: 'IconX', radix: 'Cross2Icon' } })
|
||||
assert.deepEqual(ok, []) // radix skipped; PhX and IconX are real
|
||||
const bad = await validateNames({ Nope: { phosphor: 'PhTotallyNotAnIcon' } })
|
||||
assert.equal(bad.length, 1)
|
||||
assert.ok(bad[0].includes('PhTotallyNotAnIcon'))
|
||||
})
|
||||
|
||||
it('parseLucideImports: resolves aliases and type modifiers to the exported name', () => {
|
||||
assert.deepEqual(parseLucideImports(`import { CheckIcon } from '@lucide/vue'`), ['CheckIcon'])
|
||||
assert.deepEqual(parseLucideImports(`import { ChevronDown as DownIcon } from '@lucide/vue'`), ['ChevronDown'])
|
||||
assert.deepEqual(
|
||||
parseLucideImports(`import { CheckIcon, type XIcon, Loader2Icon as Spinner } from "@lucide/vue"`),
|
||||
['CheckIcon', 'XIcon', 'Loader2Icon'],
|
||||
)
|
||||
assert.deepEqual(parseLucideImports(`import { Primitive } from 'reka-ui'`), [])
|
||||
})
|
||||
|
||||
it('findDuplicateCanonicals: flags X/XIcon key pairs', () => {
|
||||
assert.deepEqual(findDuplicateCanonicals({ Search: {}, SearchIcon: {} }), ['Search'])
|
||||
assert.deepEqual(findDuplicateCanonicals({ Search: {}, CircleCheckIcon: {} }), [])
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { IconMapping, Library, PlaceholderRecord } from './build-icons.helpers'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import {
|
||||
buildMappingFromRecords,
|
||||
findDuplicateCanonicals,
|
||||
findUncovered,
|
||||
GENERATABLE_LIBRARIES,
|
||||
LIBRARY_EXPORT,
|
||||
mergeLegacy,
|
||||
parseLucideImports,
|
||||
} from './build-icons.helpers'
|
||||
|
||||
const PLACEHOLDER_RE = /<IconPlaceholder\b([^>]*?)\/?>/g
|
||||
|
||||
/**
|
||||
* Extract one record per `<IconPlaceholder>` in an SFC's source, reading the
|
||||
* static `lucide`/`tabler`/`hugeicons`/`phosphor`/`remixicon` attributes.
|
||||
* Dynamic bindings (`:lucide=`, `v-bind:lucide=`) and `data-` attrs are ignored.
|
||||
*/
|
||||
export function scanPlaceholders(source: string): PlaceholderRecord[] {
|
||||
const records: PlaceholderRecord[] = []
|
||||
for (const match of source.matchAll(PLACEHOLDER_RE)) {
|
||||
const attrs = match[1]
|
||||
const record: PlaceholderRecord = {}
|
||||
for (const library of GENERATABLE_LIBRARIES) {
|
||||
const attr = attrs.match(new RegExp(`(?<![:\\w-])${library}=["']([^"']+)["']`))
|
||||
if (attr)
|
||||
record[library] = attr[1]
|
||||
}
|
||||
if (Object.keys(record).length > 0)
|
||||
records.push(record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify every mapped icon name is a real export of its library's package
|
||||
* (radix is skipped — it is legacy-only and not installed). Returns a list of
|
||||
* human-readable error strings; empty means all names are valid.
|
||||
*/
|
||||
export async function validateNames(mapping: IconMapping): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
const exportsByLib = new Map<string, Set<string>>()
|
||||
|
||||
for (const [library, pkg] of Object.entries(LIBRARY_EXPORT)) {
|
||||
const mod = await import(pkg)
|
||||
exportsByLib.set(library, new Set(Object.keys(mod)))
|
||||
}
|
||||
|
||||
for (const [canonical, entry] of Object.entries(mapping)) {
|
||||
for (const [library, name] of Object.entries(entry)) {
|
||||
if (library === 'radix')
|
||||
continue // not installed; legacy-only
|
||||
const members = exportsByLib.get(library)
|
||||
if (members && !members.has(name)) {
|
||||
errors.push(`${canonical}: ${library} export "${name}" not found in ${LIBRARY_EXPORT[library as Library]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const BASES_DIR = join(process.cwd(), 'registry/bases/reka')
|
||||
const NY_UI_DIR = join(process.cwd(), 'registry/new-york-v4/ui')
|
||||
const ICONS_OUT_DIR = join(process.cwd(), 'registry/icons')
|
||||
const MAP_OUT = join(process.cwd(), 'public/r/icons/index.json')
|
||||
const LEGACY_PATH = join(ICONS_OUT_DIR, 'legacy-mapping.json')
|
||||
|
||||
async function listVue(dir: string): Promise<string[]> {
|
||||
const entries = (await readdir(dir, { recursive: true })).filter(p => p.endsWith('.vue')).sort()
|
||||
return entries.map(p => join(dir, p))
|
||||
}
|
||||
|
||||
async function collectLucideImports(dir: string): Promise<string[]> {
|
||||
const names: string[] = []
|
||||
for (const file of await listVue(dir)) {
|
||||
names.push(...parseLucideImports(await readFile(file, 'utf8')))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
async function writeIfChanged(path: string, content: string): Promise<void> {
|
||||
if (existsSync(path) && (await readFile(path, 'utf8')) === content)
|
||||
return
|
||||
await writeFile(path, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the CLI icon map: scan the base placeholders, merge the legacy
|
||||
* layer, guard against duplicate/invalid/uncovered icons, and write
|
||||
* `public/r/icons/index.json` (only when its content changed). Throws on any
|
||||
* validation, duplicate-key, or coverage failure.
|
||||
*/
|
||||
export async function buildIcons(opts: { verbose?: boolean } = {}): Promise<void> {
|
||||
// 1. scan bases
|
||||
const records = []
|
||||
for (const file of await listVue(BASES_DIR)) {
|
||||
records.push(...scanPlaceholders(await readFile(file, 'utf8')))
|
||||
}
|
||||
const { mapping: scanned, warnings } = buildMappingFromRecords(records)
|
||||
|
||||
// 2. merge legacy (legacy wins, scan fills gaps)
|
||||
const legacy: IconMapping = JSON.parse(await readFile(LEGACY_PATH, 'utf8'))
|
||||
const merged = mergeLegacy(legacy, scanned)
|
||||
|
||||
// 2b. guard against the same icon landing under both `X` and `XIcon` keys
|
||||
// (caused by inconsistent `lucide=` values across base placeholders).
|
||||
const duplicates = findDuplicateCanonicals(merged)
|
||||
if (duplicates.length) {
|
||||
throw new Error(
|
||||
`Duplicate canonical icon keys (same icon under both "X" and "XIcon"): ${duplicates.join(', ')}.\n`
|
||||
+ `Make the base IconPlaceholder \`lucide=\` values consistent (prefer the "Icon"-suffixed form).`,
|
||||
)
|
||||
}
|
||||
|
||||
// 3. validate names
|
||||
const validationErrors = await validateNames(merged)
|
||||
if (validationErrors.length) {
|
||||
throw new Error(`Icon name validation failed:\n${validationErrors.join('\n')}`)
|
||||
}
|
||||
|
||||
// 4. coverage assertion for the raw-lucide new-york-v4 registry
|
||||
const uncovered = findUncovered(merged, await collectLucideImports(NY_UI_DIR))
|
||||
if (uncovered.length) {
|
||||
throw new Error(
|
||||
`The following @lucide/vue icons used in registry/new-york-v4/ui have no mapping:\n${
|
||||
uncovered.map(n => ` - ${n}`).join('\n')
|
||||
}\nAdd each to a base IconPlaceholder (registry/bases/reka/**) or pin it in registry/icons/legacy-mapping.json.`,
|
||||
)
|
||||
}
|
||||
|
||||
// 5. write output
|
||||
await writeIfChanged(MAP_OUT, `${JSON.stringify(merged, null, 2)}\n`)
|
||||
|
||||
console.log(`✓ Icon map: ${Object.keys(merged).length} icons (${Object.keys(legacy).length} legacy)`)
|
||||
if (warnings.length) {
|
||||
if (opts.verbose)
|
||||
warnings.forEach(w => console.warn(`⚠ ${w}`))
|
||||
else console.warn(`⚠ ${warnings.length} icon mapping conflicts (run with --verbose for details)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
buildIcons({ verbose: process.argv.includes('--verbose') }).catch((err) => {
|
||||
console.error(err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { rimraf } from 'rimraf'
|
||||
import { getAllBlocks } from '@/lib/blocks'
|
||||
import { registry } from '@/registry/index'
|
||||
import { ui } from '~/registry/new-york-v4/ui/_registry'
|
||||
import { buildIcons } from './build-icons'
|
||||
import { crawlBlock, crawlChart, crawlComposables, crawlExample, crawlLib, crawlUI } from './crawl-content'
|
||||
import { buildStyles } from './lib/build-styles'
|
||||
import { buildStylesRegistry } from './lib/build-styles-registry'
|
||||
@@ -408,6 +409,10 @@ async function main() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🚀 Starting dynamic registry build...\n')
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🎨 Generating icon map from IconPlaceholder bases...')
|
||||
await buildIcons()
|
||||
|
||||
// Process each base in the configuration
|
||||
for (const base of registryConfig) {
|
||||
await buildBaseRegistry(base)
|
||||
|
||||
Reference in New Issue
Block a user