11#!/usr/bin/env node
2+ /**
3+ * Generate icon wrapper components.
4+ *
5+ * Strategy:
6+ * 1. Get all SVGs from lucide-static
7+ * 2. Get export names from lucide-react
8+ * 3. For icons in lucide-react: use their exact name (for drop-in compatibility)
9+ * 4. For icons only in lucide-static: derive name from filename
10+ *
11+ * This ensures:
12+ * - All lucide-react imports work as drop-in replacement
13+ * - Extra icons from lucide-static are also available
14+ * - Future icon packs can be added
15+ */
216import fs from "fs" ;
317import path from "path" ;
418
519const __dirname = path . resolve ( process . cwd ( ) ) ;
620
7- // 1️⃣ Where Lucide's raw SVGs live
8- const ICON_SVG_DIR = path . resolve ( __dirname , "node_modules/lucide-static/icons" ) ;
9-
10- // 2️⃣ Where to write your wrappers
21+ // Where to write wrappers
1122const OUT_DIR = path . resolve ( "src/icons" ) ;
1223
1324// Preserve manually maintained files
@@ -25,43 +36,112 @@ if (fs.existsSync(OUT_DIR)) {
2536 fs . mkdirSync ( OUT_DIR , { recursive : true } ) ;
2637}
2738
28- // 3️⃣ Read every SVG filename
29- const files = fs . readdirSync ( ICON_SVG_DIR ) . filter ( ( f ) => f . endsWith ( ".svg" ) ) ;
39+ // ============================================================================
40+ // Helper functions
41+ // ============================================================================
3042
31- // 4️⃣ Helper: kebab ⇄ Pascal
32- const toPascal = ( s ) =>
33- s
43+ // Convert kebab-case to PascalCase
44+ function kebabToPascal ( str ) {
45+ return str
3446 . split ( "-" )
3547 . map ( ( w ) => w [ 0 ] . toUpperCase ( ) + w . slice ( 1 ) )
3648 . join ( "" ) ;
49+ }
3750
38- // 5️⃣ Track generated names to detect case collisions (macOS is case-insensitive)
39- const generatedNames = new Map ( ) ; // lowercase -> original pascal name
51+ // Convert PascalCase to kebab-case
52+ function pascalToKebab ( str ) {
53+ return str
54+ . replace ( / ( [ a - z ] ) ( [ A - Z ] ) / g, "$1-$2" )
55+ . replace ( / ( [ A - Z ] ) ( [ A - Z ] [ a - z ] ) / g, "$1-$2" )
56+ . replace ( / ( [ a - z A - Z ] ) ( \d ) / g, "$1-$2" )
57+ . replace ( / ( \d ) ( [ a - z A - Z ] ) / g, "$1-$2" )
58+ . toLowerCase ( ) ;
59+ }
4060
41- // 6️⃣ Generate one .tsx per icon
42- let skippedCount = 0 ;
43- for ( const file of files ) {
44- const id = file . replace ( ".svg" , "" ) ; // e.g. "arrow-right"
45- const pascal = toPascal ( id ) ; // "ArrowRight"
46- const lowerPascal = pascal . toLowerCase ( ) ;
61+ // ============================================================================
62+ // Get data from lucide packages
63+ // ============================================================================
4764
48- // Skip case collisions (e.g., ArrowDownAZ vs ArrowDownAz on case-insensitive filesystems)
49- if ( generatedNames . has ( lowerPascal ) ) {
50- skippedCount ++ ;
51- continue ;
65+ // Get all SVG IDs from lucide-static
66+ function getStaticSvgIds ( ) {
67+ const svgDir = path . resolve ( __dirname , "node_modules/lucide-static/icons" ) ;
68+ const files = fs . readdirSync ( svgDir ) . filter ( ( f ) => f . endsWith ( ".svg" ) ) ;
69+ return new Set ( files . map ( ( f ) => f . replace ( ".svg" , "" ) ) ) ;
70+ }
71+
72+ // Get export names from lucide-react (for compatibility mapping)
73+ function getLucideReactExports ( ) {
74+ const lucideDts = path . resolve ( __dirname , "node_modules/lucide-react/dist/lucide-react.d.ts" ) ;
75+ if ( ! fs . existsSync ( lucideDts ) ) {
76+ console . warn ( "⚠️ lucide-react types not found. Using derived names only." ) ;
77+ return new Map ( ) ;
78+ }
79+ const content = fs . readFileSync ( lucideDts , "utf8" ) ;
80+
81+ const exports = new Map ( ) ; // kebab-ish key -> exact export name
82+
83+ const matches = content . matchAll ( / d e c l a r e c o n s t ( \w + ) : r e a c t \. F o r w a r d R e f E x o t i c C o m p o n e n t / g) ;
84+ for ( const match of matches ) {
85+ const name = match [ 1 ] ;
86+ if ( ! name . startsWith ( "Lucide" ) && ! name . endsWith ( "Icon" ) ) {
87+ // Create a normalized key for matching
88+ const key = pascalToKebab ( name ) . replace ( / - / g, "" ) ;
89+ exports . set ( key , name ) ;
90+ }
5291 }
53- generatedNames . set ( lowerPascal , pascal ) ;
92+
93+ return exports ;
94+ }
5495
96+ // Find lucide-react's name for an SVG ID, or derive one
97+ function getComponentName ( svgId , lucideReactExports ) {
98+ // Normalize the SVG ID for lookup
99+ const normalizedKey = svgId . replace ( / - / g, "" ) ;
100+
101+ // Check if lucide-react has this icon
102+ if ( lucideReactExports . has ( normalizedKey ) ) {
103+ return lucideReactExports . get ( normalizedKey ) ;
104+ }
105+
106+ // Derive name from SVG filename
107+ return kebabToPascal ( svgId ) ;
108+ }
109+
110+ // ============================================================================
111+ // Generate wrappers
112+ // ============================================================================
113+
114+ const staticSvgIds = getStaticSvgIds ( ) ;
115+ const lucideReactExports = getLucideReactExports ( ) ;
116+
117+ // Track generated names for case collision detection (macOS is case-insensitive)
118+ const generatedNames = new Map ( ) ; // lowercase -> { name, svgId }
119+
120+ let generatedCount = 0 ;
121+ let skippedCollisions = 0 ;
122+
123+ for ( const svgId of staticSvgIds ) {
124+ const componentName = getComponentName ( svgId , lucideReactExports ) ;
125+ const lowerName = componentName . toLowerCase ( ) ;
126+
127+ // Skip case collisions on case-insensitive filesystems
128+ if ( generatedNames . has ( lowerName ) ) {
129+ skippedCollisions ++ ;
130+ continue ;
131+ }
132+
133+ generatedNames . set ( lowerName , { name : componentName , svgId } ) ;
134+
55135 const wrapperTsx = `\
56136import { SPRITE_PATH } from "../config.js";
57137import { warnMissingIconSize } from "../utils.js";
58- import { ${ pascal } as DevIcon } from "lucide-react"
138+ import { ${ componentName } as DevIcon } from "lucide-react"
59139import { renderUse,type IconProps,} from "../_shared.js";
60140
61141
62142
63- export function ${ pascal } ({ size, width, height, ...props }: IconProps) {
64- warnMissingIconSize("${ pascal } ", size, width, height);
143+ export function ${ componentName } ({ size, width, height, ...props }: IconProps) {
144+ warnMissingIconSize("${ componentName } ", size, width, height);
65145 if (process.env.NODE_ENV !== "production" && DevIcon) {
66146 return (
67147 <DevIcon
@@ -72,11 +152,17 @@ export function ${pascal}({ size, width, height, ...props }: IconProps) {
72152 />
73153 );
74154 }
75- return renderUse("${ id } ", width, height, size, SPRITE_PATH, props)
155+ return renderUse("${ svgId } ", width, height, size, SPRITE_PATH, props)
76156}
77157` ;
78158
79- fs . writeFileSync ( path . join ( OUT_DIR , `${ pascal } .tsx` ) , wrapperTsx ) ;
159+ fs . writeFileSync ( path . join ( OUT_DIR , `${ componentName } .tsx` ) , wrapperTsx ) ;
160+ generatedCount ++ ;
80161}
81162
82- console . log ( `✅ Generated ${ generatedNames . size } icon wrappers in ${ OUT_DIR } ${ skippedCount > 0 ? ` (skipped ${ skippedCount } case-collision aliases)` : "" } ` ) ;
163+ console . log ( `✅ Generated ${ generatedCount } icon wrappers in ${ OUT_DIR } ` ) ;
164+ if ( skippedCollisions > 0 ) {
165+ console . log ( ` (skipped ${ skippedCollisions } case-collision aliases)` ) ;
166+ }
167+ console . log ( ` lucide-react compatible: ${ lucideReactExports . size } icons` ) ;
168+ console . log ( ` Extra from lucide-static: ${ generatedCount - lucideReactExports . size } icons` ) ;
0 commit comments