|
| 1 | +import { readFileSync, readdirSync, writeFileSync } from 'node:fs' |
| 2 | +import { join } from 'node:path' |
| 3 | +import { createElement } from 'react' |
| 4 | +import { renderToStaticMarkup } from 'react-dom/server' |
| 5 | +import * as Lucide from 'lucide-react' |
| 6 | + |
| 7 | +const ICONS_DIR = join(import.meta.dir, '../components/emcn/icons') |
| 8 | + |
| 9 | +/** Extract { exportName: svgMarkup } from every emcn icon .tsx file. */ |
| 10 | +function extractEmcn(): Record<string, string> { |
| 11 | + const out: Record<string, string> = {} |
| 12 | + for (const file of readdirSync(ICONS_DIR)) { |
| 13 | + if (!file.endsWith('.tsx')) continue |
| 14 | + const src = readFileSync(join(ICONS_DIR, file), 'utf8') |
| 15 | + const re = /export\s+(?:function|const)\s+(\w+)[\s\S]*?(<svg[\s\S]*?<\/svg>)/g |
| 16 | + let m: RegExpExecArray | null |
| 17 | + while ((m = re.exec(src))) { |
| 18 | + out[m[1]] = jsxSvgToHtml(m[2]) |
| 19 | + } |
| 20 | + } |
| 21 | + return out |
| 22 | +} |
| 23 | + |
| 24 | +/** Convert a JSX <svg> string into browser-renderable HTML. */ |
| 25 | +function jsxSvgToHtml(jsx: string): string { |
| 26 | + return jsx |
| 27 | + .replace(/\{\.\.\.[^}]*\}/g, '') // {...props} |
| 28 | + .replace(/[\w-]+=\{[^}]*\}/g, '') // attr={expr} |
| 29 | + .replace(/\bclassName=/g, 'class=') |
| 30 | + .replace(/\bstrokeWidth=/g, 'stroke-width=') |
| 31 | + .replace(/\bstrokeLinecap=/g, 'stroke-linecap=') |
| 32 | + .replace(/\bstrokeLinejoin=/g, 'stroke-linejoin=') |
| 33 | + .replace(/\bstrokeMiterlimit=/g, 'stroke-miterlimit=') |
| 34 | + .replace(/\bstrokeDasharray=/g, 'stroke-dasharray=') |
| 35 | + .replace(/\bstrokeOpacity=/g, 'stroke-opacity=') |
| 36 | + .replace(/\bfillRule=/g, 'fill-rule=') |
| 37 | + .replace(/\bclipRule=/g, 'clip-rule=') |
| 38 | + .replace(/\bfillOpacity=/g, 'fill-opacity=') |
| 39 | + .replace(/\bclipPath=/g, 'clip-path=') |
| 40 | + .replace(/\bwidth='[^']*'/, '') // strip fixed size → CSS sizes it |
| 41 | + .replace(/\bheight='[^']*'/, '') |
| 42 | + .replace(/aria-hidden='true'/g, '') |
| 43 | +} |
| 44 | + |
| 45 | +function lucideSvg(name: string): string | null { |
| 46 | + const Comp = (Lucide as Record<string, unknown>)[name] |
| 47 | + if (!Comp) return null |
| 48 | + try { |
| 49 | + return renderToStaticMarkup( |
| 50 | + createElement(Comp as React.ComponentType, { width: 24, height: 24, strokeWidth: 1.75 }) |
| 51 | + ) |
| 52 | + } catch { |
| 53 | + return null |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +const emcn = extractEmcn() |
| 58 | + |
| 59 | +type Item = { name: string; count: string; src: 'emcn' | 'lucide'; lucide?: string } |
| 60 | +type Section = { title: string; note?: string; items: Item[] } |
| 61 | + |
| 62 | +const e = (name: string, count: string): Item => ({ name, count, src: 'emcn' }) |
| 63 | +const l = (name: string, count: string, lucide?: string): Item => ({ |
| 64 | + name, |
| 65 | + count, |
| 66 | + src: 'lucide', |
| 67 | + lucide: lucide ?? name, |
| 68 | +}) |
| 69 | + |
| 70 | +const sections: Section[] = [ |
| 71 | + { |
| 72 | + title: 'TIER 1 · Core — Arrows & Chevrons', |
| 73 | + note: 'Redesign first. Tier-2 chevrons/arrows are rotations of these.', |
| 74 | + items: [ |
| 75 | + e('ChevronDown', '21 +24'), |
| 76 | + e('ArrowLeft', '14 +5'), |
| 77 | + e('ArrowUp', '11 +15'), |
| 78 | + e('ArrowDown', '10 +6'), |
| 79 | + e('ArrowRight', '6 +7'), |
| 80 | + e('ArrowUpDown', '1 +1'), |
| 81 | + ], |
| 82 | + }, |
| 83 | + { |
| 84 | + title: 'TIER 1 · Core — Actions & Controls', |
| 85 | + items: [ |
| 86 | + e('Search', '44 +14'), |
| 87 | + e('Plus', '27 +29'), |
| 88 | + e('X', '16 +31'), |
| 89 | + e('Check', '16 +23'), |
| 90 | + e('Pencil', '16 +7'), |
| 91 | + e('Trash', '25'), |
| 92 | + e('Trash2', '2 +4'), |
| 93 | + e('Settings', '11 +2'), |
| 94 | + e('MoreHorizontal', '9 +9'), |
| 95 | + e('Send', '13'), |
| 96 | + e('Download', '18'), |
| 97 | + e('Upload', '17'), |
| 98 | + e('Duplicate', '11'), |
| 99 | + e('RefreshCw', '3 +5'), |
| 100 | + ], |
| 101 | + }, |
| 102 | + { |
| 103 | + title: 'TIER 1 · Core — Files, Folders & Data', |
| 104 | + items: [ |
| 105 | + e('File', '23 +1'), |
| 106 | + e('Files', '18'), |
| 107 | + e('Folder', '15 +3'), |
| 108 | + e('FolderPlus', '4'), |
| 109 | + e('Table', '21 +1'), |
| 110 | + e('Database', '19 +3'), |
| 111 | + e('Library', '12 +1'), |
| 112 | + e('Clipboard', '4 +12'), |
| 113 | + ], |
| 114 | + }, |
| 115 | + { |
| 116 | + title: 'TIER 1 · Core — Status & Primitives', |
| 117 | + items: [ |
| 118 | + e('Eye', '10 +7'), |
| 119 | + e('EyeOff', '1 +7'), |
| 120 | + e('Lock', '7 +3'), |
| 121 | + e('Unlock', '3 +2'), |
| 122 | + e('Key', '9 +1'), |
| 123 | + e('Info', '5 +10'), |
| 124 | + e('Square', '7 +2'), |
| 125 | + e('Calendar', '10'), |
| 126 | + e('Clock', '2 +4'), |
| 127 | + e('Loader', '10'), |
| 128 | + e('User', '10 +1'), |
| 129 | + e('Users', '5 +2'), |
| 130 | + e('Link', '10'), |
| 131 | + e('Bell', '2 +2'), |
| 132 | + e('Pin', '3 +2'), |
| 133 | + e('PinOff', '2 +1'), |
| 134 | + e('Paperclip', '1 +5'), |
| 135 | + e('PlayOutline', '15'), |
| 136 | + e('Pause', '4 +3'), |
| 137 | + ], |
| 138 | + }, |
| 139 | + { |
| 140 | + title: 'TIER 1 · Core — Type Markers (design as a unified set)', |
| 141 | + items: [ |
| 142 | + e('TypeText', '4'), |
| 143 | + e('TypeNumber', '4'), |
| 144 | + e('TypeBoolean', '4'), |
| 145 | + e('TypeJson', '2'), |
| 146 | + ], |
| 147 | + }, |
| 148 | + { |
| 149 | + title: 'TIER 0 · Name-aliases — no design, just alias existing emcn glyph', |
| 150 | + note: 'Shown is the existing emcn glyph each lucide name maps to.', |
| 151 | + items: [ |
| 152 | + e('X', 'XIcon'), |
| 153 | + e('Send', 'SendIcon'), |
| 154 | + e('Server', 'ServerIcon'), |
| 155 | + e('Wrench', 'WrenchIcon'), |
| 156 | + e('TagIcon', 'Tag'), |
| 157 | + e('TriangleAlert', 'AlertTriangle'), |
| 158 | + e('CircleAlert', 'AlertCircle'), |
| 159 | + e('CircleCheck', 'CheckCircle2'), |
| 160 | + ], |
| 161 | + }, |
| 162 | + { |
| 163 | + title: 'TIER 2 · lucide-only — derivable from a core glyph (rotate / compose / variant)', |
| 164 | + items: [ |
| 165 | + l('ChevronRight', '18'), |
| 166 | + l('ChevronUp', '9'), |
| 167 | + l('ChevronLeft', '6'), |
| 168 | + l('ChevronsUpDown', '4'), |
| 169 | + l('MoreVertical', '2'), |
| 170 | + l('ArrowLeftRight', '5'), |
| 171 | + l('ArrowUpLeft', '1'), |
| 172 | + l('RotateCcw', '4'), |
| 173 | + l('XCircle', '3'), |
| 174 | + l('PauseCircle', '1'), |
| 175 | + l('Circle', '5'), |
| 176 | + l('CircleOff', '2'), |
| 177 | + l('Minus', '1'), |
| 178 | + l('Settings2', '1'), |
| 179 | + l('KeyRound', '1'), |
| 180 | + l('LibraryBig', '1'), |
| 181 | + l('FolderOpen', '1'), |
| 182 | + l('FileText', '4'), |
| 183 | + l('MicOff', '1'), |
| 184 | + l('Filter', '1'), |
| 185 | + l('Image', '2'), |
| 186 | + l('ExternalLink', '8'), |
| 187 | + ], |
| 188 | + }, |
| 189 | + { |
| 190 | + title: 'TIER 3 · lucide-only — net-new glyphs to design from scratch', |
| 191 | + items: [ |
| 192 | + l('RepeatIcon', '10', 'Repeat'), |
| 193 | + l('SplitIcon', '10', 'Split'), |
| 194 | + l('Wand2', '4'), |
| 195 | + l('GraduationCap', '3'), |
| 196 | + l('Bot', '1'), |
| 197 | + l('Building2', '1'), |
| 198 | + l('Camera', '1'), |
| 199 | + l('Compass', '1'), |
| 200 | + l('FormInput', '1'), |
| 201 | + l('GitBranch', '1'), |
| 202 | + l('Github', '1'), |
| 203 | + l('Globe', '1'), |
| 204 | + l('Hash', '1'), |
| 205 | + l('History', '1'), |
| 206 | + l('MessageCircle', '1'), |
| 207 | + l('Moon', '1'), |
| 208 | + l('Music', '1'), |
| 209 | + l('Phone', '1'), |
| 210 | + l('Rss', '1'), |
| 211 | + l('Scan', '1'), |
| 212 | + l('Scissors', '1'), |
| 213 | + l('SendToBack', '1'), |
| 214 | + l('Share2', '1'), |
| 215 | + l('Sparkles', '1'), |
| 216 | + l('Sun', '1'), |
| 217 | + l('Webhook', '1'), |
| 218 | + l('Workflow', '1'), |
| 219 | + ], |
| 220 | + }, |
| 221 | +] |
| 222 | + |
| 223 | +function cell(it: Item): string { |
| 224 | + let svg: string | null |
| 225 | + let label: string |
| 226 | + if (it.src === 'emcn') { |
| 227 | + svg = emcn[it.name] ?? null |
| 228 | + label = it.name |
| 229 | + } else { |
| 230 | + svg = lucideSvg(it.lucide ?? it.name) |
| 231 | + label = it.name |
| 232 | + } |
| 233 | + const art = svg |
| 234 | + ? `<div class="art">${svg}</div>` |
| 235 | + : `<div class="art missing">?</div>` |
| 236 | + return `<div class="cell"><div class="box">${art}</div><div class="name">${label}</div><div class="count">${it.count}</div></div>` |
| 237 | +} |
| 238 | + |
| 239 | +const body = sections |
| 240 | + .map( |
| 241 | + (s) => ` |
| 242 | + <section> |
| 243 | + <h2>${s.title} <span class="n">(${s.items.length})</span></h2> |
| 244 | + ${s.note ? `<p class="note">${s.note}</p>` : ''} |
| 245 | + <div class="grid">${s.items.map(cell).join('')}</div> |
| 246 | + </section>` |
| 247 | + ) |
| 248 | + .join('') |
| 249 | + |
| 250 | +const total = sections.reduce((a, s) => a + s.items.length, 0) |
| 251 | + |
| 252 | +const html = `<!doctype html> |
| 253 | +<html><head><meta charset="utf-8"><title>Sim icon update map</title> |
| 254 | +<style> |
| 255 | + :root { --ink:#1a1a1a; --muted:#8a8a8a; --line:#ececec; --bg:#fff; } |
| 256 | + * { box-sizing: border-box; } |
| 257 | + body { margin:0; font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:#fafafa; padding:32px 40px 80px; } |
| 258 | + header h1 { font-size:22px; margin:0 0 2px; } |
| 259 | + header p { color:var(--muted); margin:0 0 28px; } |
| 260 | + section { margin:0 0 36px; } |
| 261 | + h2 { font-size:13px; letter-spacing:.04em; text-transform:uppercase; color:var(--ink); border-bottom:1px solid var(--line); padding-bottom:8px; margin:0 0 4px; } |
| 262 | + h2 .n { color:var(--muted); font-weight:400; } |
| 263 | + .note { color:var(--muted); font-size:12px; margin:6px 0 14px; } |
| 264 | + .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(96px,1fr)); gap:10px; margin-top:14px; } |
| 265 | + .cell { display:flex; flex-direction:column; align-items:center; gap:6px; padding:14px 6px 10px; background:var(--bg); border:1px solid var(--line); border-radius:10px; } |
| 266 | + .box { width:40px; height:40px; display:flex; align-items:center; justify-content:center; color:var(--ink); } |
| 267 | + .art svg { width:24px; height:24px; display:block; } |
| 268 | + .art.missing { color:#c00; font-size:20px; } |
| 269 | + .name { font-size:11px; font-weight:500; text-align:center; word-break:break-word; } |
| 270 | + .count { font-size:10px; color:var(--muted); font-variant-numeric:tabular-nums; } |
| 271 | +</style></head> |
| 272 | +<body> |
| 273 | + <header> |
| 274 | + <h1>Sim — icons to update (${total})</h1> |
| 275 | + <p>Counts = emcn usage + lucide sites to migrate. Artwork is the current live glyph.</p> |
| 276 | + </header> |
| 277 | + ${body} |
| 278 | +</body></html>` |
| 279 | + |
| 280 | +const outPath = join(import.meta.dir, '../../../icon-gallery.html') |
| 281 | +writeFileSync(outPath, html) |
| 282 | +console.log(`Wrote ${outPath} — ${total} icons`) |
0 commit comments