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:
zernonia
2026-07-25 14:17:43 +08:00
committed by GitHub
parent ecd41b1d98
commit 2ddedc02f3
14 changed files with 1548 additions and 39 deletions
+4
View File
@@ -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
+4 -2
View File
@@ -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"
/>
+309
View File
@@ -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"
}
}
+139
View File
@@ -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()
}
+126
View File
@@ -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: {} }), [])
})
+151
View File
@@ -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)
})
}
+5
View File
@@ -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)