inkhouse 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +201 -0
  2. package/bin/inkhouse.mjs +171 -0
  3. package/code.js +11802 -0
  4. package/manifest.json +30 -0
  5. package/package.json +45 -0
  6. package/scanner/blob-placement-regression.ts +132 -0
  7. package/scanner/class-collector.ts +69 -0
  8. package/scanner/cli.ts +336 -0
  9. package/scanner/component-scanner.ts +2876 -0
  10. package/scanner/css-patch-regression.ts +112 -0
  11. package/scanner/css-token-reader-regression.ts +92 -0
  12. package/scanner/css-token-reader.ts +477 -0
  13. package/scanner/font-style-resolver-regression.ts +32 -0
  14. package/scanner/index.ts +9 -0
  15. package/scanner/radial-gradient-regression.ts +53 -0
  16. package/scanner/style-map.ts +145 -0
  17. package/scanner/tailwind-parser.ts +644 -0
  18. package/scanner/transform-math-regression.ts +42 -0
  19. package/scanner/types.ts +298 -0
  20. package/src/blob-placement.ts +111 -0
  21. package/src/change-detection.ts +204 -0
  22. package/src/class-utils.ts +105 -0
  23. package/src/clip-path-decorative.ts +194 -0
  24. package/src/color-resolver.ts +98 -0
  25. package/src/colors.ts +196 -0
  26. package/src/component-defs.ts +54 -0
  27. package/src/component-gen.ts +561 -0
  28. package/src/component-lookup.ts +82 -0
  29. package/src/config.ts +115 -0
  30. package/src/design-system.ts +59 -0
  31. package/src/dev-server.ts +173 -0
  32. package/src/figma-globals.d.ts +3 -0
  33. package/src/font-style-resolver.ts +171 -0
  34. package/src/github.ts +1465 -0
  35. package/src/icon-builder.ts +607 -0
  36. package/src/image-cache.ts +22 -0
  37. package/src/inline-text.ts +271 -0
  38. package/src/layout-parser.ts +667 -0
  39. package/src/layout-utils.ts +155 -0
  40. package/src/main.ts +687 -0
  41. package/src/node-ir.ts +595 -0
  42. package/src/pack-provider.ts +148 -0
  43. package/src/packs.ts +126 -0
  44. package/src/radial-gradient.ts +84 -0
  45. package/src/render-context.ts +138 -0
  46. package/src/responsive-analyzer.ts +139 -0
  47. package/src/state-analyzer.ts +143 -0
  48. package/src/story-builder.ts +1706 -0
  49. package/src/story-layout.ts +38 -0
  50. package/src/tailwind.ts +2379 -0
  51. package/src/text-builder.ts +116 -0
  52. package/src/text-line.ts +42 -0
  53. package/src/token-source.ts +43 -0
  54. package/src/tokens.ts +717 -0
  55. package/src/transform-math.ts +44 -0
  56. package/src/ui-builder.ts +1996 -0
  57. package/src/utility-resolver.ts +125 -0
  58. package/src/variables.ts +1042 -0
  59. package/src/width-solver.ts +466 -0
  60. package/templates/patch-tokens-route.ts +165 -0
  61. package/templates/scan-components-route.ts +57 -0
  62. package/ui.html +1222 -0
@@ -0,0 +1,2876 @@
1
+ /**
2
+ * Component Scanner
3
+ *
4
+ * Scans React component files and detects their type:
5
+ * - CVA: Components using class-variance-authority
6
+ * - Compound: Multiple sub-components with cn() classes
7
+ * - State: Components with Tailwind state modifiers
8
+ * - Simple: Components with static classes
9
+ */
10
+
11
+ import { Project, SyntaxKind, Node, SourceFile } from 'ts-morph';
12
+ import * as path from 'path';
13
+ import * as fs from 'fs';
14
+ import type {
15
+ ComponentAnalysis,
16
+ CVAComponentAnalysis,
17
+ CompoundComponentAnalysis,
18
+ StateComponentAnalysis,
19
+ SimpleComponentAnalysis,
20
+ SubComponentInfo,
21
+ StateInfo,
22
+ StoryInfo,
23
+ ComponentInstance,
24
+ ScannerConfig,
25
+ JsxNode,
26
+ JsxElement,
27
+ JsxText,
28
+ IconImportSpec,
29
+ } from './types';
30
+ import { groupClassesByState, STATE_MODIFIERS } from './tailwind-parser';
31
+
32
+ // ============================================================================
33
+ // Helpers
34
+ // ============================================================================
35
+
36
+ const HTML_ENTITIES: Record<string, string> = {
37
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"',
38
+ '&apos;': "'", '&nbsp;': '\u00a0', '&rarr;': '→', '&larr;': '←',
39
+ '&rArr;': '⇒', '&lArr;': '⇐', '&hellip;': '…', '&mdash;': '—', '&ndash;': '–',
40
+ };
41
+
42
+ function decodeHtmlEntities(text: string): string {
43
+ return text.replace(/&[a-zA-Z]+;/g, (entity) => HTML_ENTITIES[entity] ?? entity);
44
+ }
45
+
46
+ // ============================================================================
47
+ // Component to HTML Element Mapping
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Maps React/Next.js components to their HTML element equivalents.
52
+ * These components are treated as HTML elements in the JSX tree output.
53
+ */
54
+ const COMPONENT_TO_HTML_MAP: Record<string, string> = {
55
+ Link: 'a', // next/link
56
+ };
57
+
58
+ // ============================================================================
59
+ // Scanner Class
60
+ // ============================================================================
61
+
62
+ export class ComponentScanner {
63
+ private project: Project;
64
+ private config: ScannerConfig;
65
+ private iconImports: Map<string, IconImportSpec>;
66
+ // Cache for resolved imported component JSX trees
67
+ private importedComponentCache: Map<string, { sourceFile: SourceFile; componentName: string }>;
68
+ // Cache for array values found in source files (for .map() expansion)
69
+ private arrayValueCache: Map<string, any[]>;
70
+
71
+ constructor(config: ScannerConfig) {
72
+ this.config = config;
73
+ this.project = new Project({
74
+ tsConfigFilePath: path.resolve(process.cwd(), 'tsconfig.json'),
75
+ skipAddingFilesFromTsConfig: true,
76
+ });
77
+ this.iconImports = new Map();
78
+ this.importedComponentCache = new Map();
79
+ this.arrayValueCache = new Map();
80
+ }
81
+
82
+ /**
83
+ * Scan all components in configured paths
84
+ */
85
+ async scanAll(): Promise<ComponentAnalysis[]> {
86
+ const analyses: ComponentAnalysis[] = [];
87
+ const seenPaths = new Set<string>();
88
+
89
+ for (const componentPath of this.config.componentPaths) {
90
+ const fullPath = path.resolve(process.cwd(), componentPath);
91
+ const files = this.project.addSourceFilesAtPaths(`${fullPath}/${this.config.filePattern}`);
92
+
93
+ for (const file of files) {
94
+ const filePath = file.getFilePath();
95
+ const fileName = path.basename(filePath, '.tsx');
96
+
97
+ // Deduplicate (overlapping componentPaths may match same files)
98
+ if (seenPaths.has(filePath)) continue;
99
+ seenPaths.add(filePath);
100
+
101
+ // Skip excluded components, test files, and story files
102
+ if (this.config.exclude.includes(fileName)) continue;
103
+ if (fileName.endsWith('.test') || fileName.endsWith('.stories')) continue;
104
+
105
+ try {
106
+ const analysis = this.analyzeFile(filePath);
107
+ if (analysis) {
108
+ // Check for co-located story file
109
+ const storyPath = filePath.replace(/\.tsx$/, '.stories.tsx');
110
+ if (fs.existsSync(storyPath)) {
111
+ analysis.hasStory = true;
112
+ try {
113
+ analysis.stories = this.parseStories(storyPath);
114
+ } catch (storyErr) {
115
+ console.warn(` Failed to parse stories for ${fileName}:`, storyErr);
116
+ analysis.stories = [];
117
+ }
118
+ }
119
+
120
+ analyses.push(analysis);
121
+ }
122
+ } catch (err) {
123
+ console.warn(`Failed to analyze ${fileName}:`, err);
124
+ }
125
+ }
126
+ }
127
+
128
+ if (!this.config.onlyWithStories) return analyses;
129
+ return this.filterAnalysesByStoryReachability(analyses);
130
+ }
131
+
132
+ private filterAnalysesByStoryReachability(analyses: ComponentAnalysis[]): ComponentAnalysis[] {
133
+ if (analyses.length === 0) return analyses;
134
+
135
+ const byName = new Map<string, ComponentAnalysis>();
136
+ const byNormalized = new Map<string, ComponentAnalysis[]>();
137
+ const normalize = (value: string): string => value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
138
+
139
+ for (const analysis of analyses) {
140
+ if (analysis.name) {
141
+ byName.set(analysis.name, analysis);
142
+ const key = normalize(analysis.name);
143
+ const list = byNormalized.get(key) || [];
144
+ list.push(analysis);
145
+ byNormalized.set(key, list);
146
+ }
147
+ }
148
+
149
+ const keep = new Set<ComponentAnalysis>();
150
+ const queue: ComponentAnalysis[] = [];
151
+ for (const analysis of analyses) {
152
+ if (analysis.hasStory) {
153
+ keep.add(analysis);
154
+ queue.push(analysis);
155
+ }
156
+ }
157
+
158
+ while (queue.length > 0) {
159
+ const current = queue.shift() as ComponentAnalysis;
160
+ const deps = this.collectStoryComponentRefs(current);
161
+ for (const depName of deps) {
162
+ let dep = byName.get(depName);
163
+ if (!dep) {
164
+ const normalizedHits = byNormalized.get(normalize(depName)) || [];
165
+ if (normalizedHits.length === 1) {
166
+ dep = normalizedHits[0];
167
+ }
168
+ }
169
+ if (!dep || keep.has(dep)) continue;
170
+ keep.add(dep);
171
+ queue.push(dep);
172
+ }
173
+ }
174
+
175
+ return analyses.filter(analysis => keep.has(analysis));
176
+ }
177
+
178
+ private collectStoryComponentRefs(analysis: ComponentAnalysis): Set<string> {
179
+ const refs = new Set<string>();
180
+ const stories = analysis.stories || [];
181
+ const visit = (node: JsxNode | undefined): void => {
182
+ if (!node || node.type !== 'element') return;
183
+ const el = node as JsxElement;
184
+ const tagName = el.tagName || '';
185
+ if (/^[A-Z]/.test(tagName)) {
186
+ refs.add(tagName);
187
+ }
188
+ const children = el.children || [];
189
+ for (let i = 0; i < children.length; i++) {
190
+ visit(children[i]);
191
+ }
192
+ };
193
+
194
+ for (let i = 0; i < stories.length; i++) {
195
+ visit(stories[i].jsxTree as JsxNode | undefined);
196
+ }
197
+ return refs;
198
+ }
199
+
200
+ /**
201
+ * Analyze a single component file
202
+ */
203
+ analyzeFile(filePath: string): ComponentAnalysis | null {
204
+ const sourceFile = this.project.addSourceFileAtPath(filePath);
205
+ const fileName = path.basename(filePath, '.tsx');
206
+
207
+ // Check for CVA usage first (most structured)
208
+ const cvaAnalysis = this.analyzeCVA(sourceFile, filePath);
209
+ if (cvaAnalysis) {
210
+ return cvaAnalysis;
211
+ }
212
+
213
+ // Check for compound component pattern (multiple exports with cn())
214
+ const compoundAnalysis = this.analyzeCompound(sourceFile, filePath);
215
+ if (compoundAnalysis) {
216
+ return compoundAnalysis;
217
+ }
218
+
219
+ // Check for state-based component (Tailwind modifiers)
220
+ const stateAnalysis = this.analyzeState(sourceFile, filePath);
221
+ if (stateAnalysis) {
222
+ return stateAnalysis;
223
+ }
224
+
225
+ // Fall back to simple component
226
+ const simpleAnalysis = this.analyzeSimple(sourceFile, filePath);
227
+ return simpleAnalysis;
228
+ }
229
+
230
+ // ===========================================================================
231
+ // CVA Analysis
232
+ // ===========================================================================
233
+
234
+ private analyzeCVA(sourceFile: SourceFile, filePath: string): CVAComponentAnalysis | null {
235
+ // Find cva() call expressions
236
+ const cvaCalls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)
237
+ .filter(call => call.getExpression().getText() === 'cva');
238
+
239
+ if (cvaCalls.length === 0) {
240
+ return null;
241
+ }
242
+
243
+ const cvaCall = cvaCalls[0];
244
+ const args = cvaCall.getArguments();
245
+
246
+ if (args.length < 2) {
247
+ return null;
248
+ }
249
+
250
+ // Parse base classes (first argument)
251
+ const baseClassesArg = args[0];
252
+ const baseClasses = this.extractStringValue(baseClassesArg);
253
+
254
+ // Parse config object (second argument)
255
+ const configArg = args[1];
256
+ if (!Node.isObjectLiteralExpression(configArg)) {
257
+ return null;
258
+ }
259
+
260
+ const variants: Record<string, string[]> = {};
261
+ const defaultVariants: Record<string, string> = {};
262
+ const variantClasses: Record<string, Record<string, string[]>> = {};
263
+
264
+ // Extract variants property
265
+ const variantsProp = configArg.getProperty('variants');
266
+ if (variantsProp && Node.isPropertyAssignment(variantsProp)) {
267
+ const variantsObj = variantsProp.getInitializer();
268
+ if (variantsObj && Node.isObjectLiteralExpression(variantsObj)) {
269
+ for (const prop of variantsObj.getProperties()) {
270
+ if (Node.isPropertyAssignment(prop)) {
271
+ const variantName = prop.getName();
272
+ const variantValues = prop.getInitializer();
273
+
274
+ if (variantValues && Node.isObjectLiteralExpression(variantValues)) {
275
+ variants[variantName] = [];
276
+ variantClasses[variantName] = {};
277
+
278
+ for (const valueProp of variantValues.getProperties()) {
279
+ if (Node.isPropertyAssignment(valueProp)) {
280
+ const valueName = valueProp.getName();
281
+ const classString = this.extractStringValue(valueProp.getInitializer()!);
282
+
283
+ variants[variantName].push(valueName);
284
+ variantClasses[variantName][valueName] = classString.split(/\s+/).filter(Boolean);
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ // Extract defaultVariants property
294
+ const defaultVariantsProp = configArg.getProperty('defaultVariants');
295
+ if (defaultVariantsProp && Node.isPropertyAssignment(defaultVariantsProp)) {
296
+ const defaultObj = defaultVariantsProp.getInitializer();
297
+ if (defaultObj && Node.isObjectLiteralExpression(defaultObj)) {
298
+ for (const prop of defaultObj.getProperties()) {
299
+ if (Node.isPropertyAssignment(prop)) {
300
+ const name = prop.getName();
301
+ const value = this.extractStringValue(prop.getInitializer()!);
302
+ defaultVariants[name] = value.replace(/['"]/g, '');
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ // Infer component name from variable name
309
+ const parent = cvaCall.getParent();
310
+ let name = path.basename(filePath, '.tsx');
311
+ if (Node.isVariableDeclaration(parent)) {
312
+ const varName = parent.getName();
313
+ // buttonVariants -> Button
314
+ name = varName.replace(/Variants$/, '');
315
+ name = name.charAt(0).toUpperCase() + name.slice(1);
316
+ }
317
+
318
+ return {
319
+ type: 'cva',
320
+ name,
321
+ filePath,
322
+ baseClasses: baseClasses.split(/\s+/).filter(Boolean),
323
+ variants,
324
+ defaultVariants,
325
+ variantClasses,
326
+ };
327
+ }
328
+
329
+ // ===========================================================================
330
+ // Compound Component Analysis
331
+ // ===========================================================================
332
+
333
+ private analyzeCompound(sourceFile: SourceFile, filePath: string): CompoundComponentAnalysis | null {
334
+ // Find all variable declarations that look like components
335
+ // Pattern: const ComponentName = React.forwardRef<...>(...) or const ComponentName = ...
336
+ const componentDeclarations = this.findComponentDeclarations(sourceFile);
337
+
338
+ if (componentDeclarations.length < 2) {
339
+ return null;
340
+ }
341
+
342
+ const subComponents: SubComponentInfo[] = [];
343
+
344
+ for (const { name, node } of componentDeclarations) {
345
+ const classes = this.extractClassesFromNode(node);
346
+ if (classes.length > 0) {
347
+ subComponents.push({
348
+ name,
349
+ classes,
350
+ slot: this.inferSlot(name),
351
+ });
352
+ }
353
+ }
354
+
355
+ if (subComponents.length < 2) {
356
+ return null;
357
+ }
358
+
359
+ // Use filename as the authoritative component name (e.g. "select.tsx" → "Select"),
360
+ // same convention as analyzeSimple. The old heuristic (shortest subcomponent name)
361
+ // breaks when the root component (e.g. "Select") has no classes and is filtered out,
362
+ // leaving a longer sub-component like "SelectItem" as the winner.
363
+ const fileBaseName = path.basename(filePath, '.tsx');
364
+ const mainName = fileBaseName.charAt(0).toUpperCase() + fileBaseName.slice(1);
365
+
366
+ return {
367
+ type: 'compound',
368
+ name: mainName,
369
+ filePath,
370
+ subComponents,
371
+ };
372
+ }
373
+
374
+ // ===========================================================================
375
+ // State-based Component Analysis
376
+ // ===========================================================================
377
+
378
+ private analyzeState(sourceFile: SourceFile, filePath: string): StateComponentAnalysis | null {
379
+ const allClasses = this.extractAllClassesFromFile(sourceFile);
380
+
381
+ if (allClasses.length === 0) {
382
+ return null;
383
+ }
384
+
385
+ // Check if any classes have state modifiers
386
+ const hasStateModifiers = allClasses.some(cls =>
387
+ STATE_MODIFIERS.some(mod => cls.startsWith(mod))
388
+ );
389
+
390
+ if (!hasStateModifiers) {
391
+ return null;
392
+ }
393
+
394
+ if (!this.hasRootStateModifier(sourceFile, filePath)) {
395
+ return null;
396
+ }
397
+
398
+ const groupedClasses = groupClassesByState(allClasses);
399
+
400
+ // Need at least 2 states (default + something else)
401
+ if (Object.keys(groupedClasses).length < 2) {
402
+ return null;
403
+ }
404
+
405
+ const states: Record<string, StateInfo> = {};
406
+
407
+ for (const [stateName, classes] of Object.entries(groupedClasses)) {
408
+ let trigger = '';
409
+ if (stateName === 'hover') trigger = 'hover:';
410
+ else if (stateName === 'focus') trigger = 'focus-visible:';
411
+ else if (stateName === 'disabled') trigger = 'disabled:';
412
+ else if (stateName === 'error') trigger = 'aria-invalid:';
413
+ else if (stateName === 'active') trigger = 'active:';
414
+ else if (stateName === 'checked') trigger = 'data-[state=checked]:';
415
+ else if (stateName === 'open') trigger = 'data-[state=open]:';
416
+
417
+ states[stateName] = {
418
+ classes,
419
+ trigger,
420
+ };
421
+ }
422
+
423
+ const name = path.basename(filePath, '.tsx');
424
+
425
+ return {
426
+ type: 'state',
427
+ name: name.charAt(0).toUpperCase() + name.slice(1),
428
+ filePath,
429
+ baseClasses: groupedClasses.default || [],
430
+ states,
431
+ };
432
+ }
433
+
434
+ private hasRootStateModifier(sourceFile: SourceFile, filePath: string): boolean {
435
+ const nameCandidates = this.getComponentNameCandidates(filePath);
436
+ let jsxTree: JsxNode | null = null;
437
+ for (const candidate of nameCandidates) {
438
+ jsxTree = this.extractComponentJsxTree(sourceFile, candidate);
439
+ if (jsxTree) break;
440
+ }
441
+ if (!jsxTree || jsxTree.type !== 'element') {
442
+ return false;
443
+ }
444
+
445
+ const root = jsxTree as JsxElement;
446
+ const className = root.props && typeof root.props.className === 'string'
447
+ ? root.props.className
448
+ : '';
449
+ if (!className) {
450
+ // Wrapper components (root tag is another component like <Hero>) should not
451
+ // be marked as state components from nested hover/focus classes.
452
+ if (/^[A-Z]/.test(root.tagName)) {
453
+ return false;
454
+ }
455
+ // Keep state analysis for primitives that use dynamic className builders
456
+ // (e.g. <input className={cn(...focus-visible:...)} />).
457
+ return true;
458
+ }
459
+
460
+ const classes = className.split(/\s+/).filter(Boolean);
461
+ for (const cls of classes) {
462
+ for (const modifier of STATE_MODIFIERS) {
463
+ if (cls.startsWith(modifier)) {
464
+ return true;
465
+ }
466
+ }
467
+ }
468
+ return false;
469
+ }
470
+
471
+ private getComponentNameCandidates(filePath: string): string[] {
472
+ const rawName = path.basename(filePath, '.tsx');
473
+ const candidates: string[] = [];
474
+ const direct = rawName.charAt(0).toUpperCase() + rawName.slice(1);
475
+ if (direct) candidates.push(direct);
476
+ const pascal = rawName
477
+ .split(/[-_]/g)
478
+ .filter(Boolean)
479
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
480
+ .join('');
481
+ if (pascal && candidates.indexOf(pascal) === -1) candidates.push(pascal);
482
+ return candidates;
483
+ }
484
+
485
+ // ===========================================================================
486
+ // Simple Component Analysis
487
+ // ===========================================================================
488
+
489
+ private analyzeSimple(sourceFile: SourceFile, filePath: string): SimpleComponentAnalysis | null {
490
+ const classes = this.extractAllClassesFromFile(sourceFile);
491
+ // Don't bail on empty classes — stub components (returning null) have no classes
492
+ // but their stories carry all the rendering data. The onlyWithStories filter
493
+ // handles truly empty components downstream.
494
+
495
+ const name = path.basename(filePath, '.tsx');
496
+ const componentName = name.charAt(0).toUpperCase() + name.slice(1);
497
+
498
+ // Also try PascalCase (e.g. "hero-section" → "HeroSection") for components
499
+ // whose export name doesn't match the simple first-letter-uppercased file name.
500
+ const pascalName = name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
501
+ .replace(/^[a-z]/, (c: string) => c.toUpperCase());
502
+
503
+ // Try to capture the component's JSX tree structure
504
+ const jsxTree = this.extractComponentJsxTree(sourceFile, componentName)
505
+ ?? (pascalName !== componentName ? this.extractComponentJsxTree(sourceFile, pascalName) : null);
506
+
507
+ const analysis: SimpleComponentAnalysis = {
508
+ type: 'simple',
509
+ name: componentName,
510
+ filePath,
511
+ classes,
512
+ };
513
+
514
+ if (jsxTree) {
515
+ analysis.jsxTree = jsxTree;
516
+ }
517
+
518
+ return analysis;
519
+ }
520
+
521
+ /**
522
+ * Extract the JSX tree from the main component function in a source file.
523
+ * This preserves the element structure including decorative elements with styles.
524
+ */
525
+ private extractComponentJsxTree(sourceFile: SourceFile, componentName: string): JsxNode | null {
526
+ const relativeImports = this.collectComponentImports(sourceFile);
527
+
528
+ const localComponents = this.collectLocalComponentsFromFile(sourceFile);
529
+
530
+ // Look for: export function ComponentName() { ... }
531
+ for (const func of sourceFile.getFunctions()) {
532
+ if (func.getName() === componentName && func.isExported()) {
533
+ const jsxTree = this.extractJsxTreeFromFunctionBody(func, relativeImports, localComponents);
534
+ if (jsxTree) return jsxTree;
535
+ }
536
+ }
537
+
538
+ // Look for: export const ComponentName = () => { ... } or forwardRef
539
+ for (const varStmt of sourceFile.getVariableStatements()) {
540
+ if (!varStmt.isExported()) continue;
541
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
542
+ if (decl.getName() === componentName) {
543
+ const init = decl.getInitializer();
544
+ if (init) {
545
+ const jsxTree = this.extractJsxTreeFromFunctionBody(init, relativeImports, localComponents);
546
+ if (jsxTree) return jsxTree;
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ return null;
553
+ }
554
+
555
+ /**
556
+ * Extract JSX tree from a function body (arrow function, function expression, or function declaration).
557
+ */
558
+ private extractJsxTreeFromFunctionBody(
559
+ funcNode: Node,
560
+ relativeImports: Map<string, string>,
561
+ localComponents: Map<string, Node>
562
+ ): JsxNode | null {
563
+ const jsxElements = funcNode.getDescendantsOfKind(SyntaxKind.JsxElement);
564
+ const selfClosing = funcNode.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
565
+ const allJsx = [...jsxElements, ...selfClosing];
566
+
567
+ if (allJsx.length === 0) return null;
568
+
569
+ // Find root JSX (fewest ancestors)
570
+ let rootJsx = allJsx[0];
571
+ for (const el of allJsx) {
572
+ if (el.getAncestors().length < rootJsx.getAncestors().length) {
573
+ rootJsx = el;
574
+ }
575
+ }
576
+
577
+ return this.buildJsxTree(rootJsx, localComponents, relativeImports);
578
+ }
579
+
580
+ // ===========================================================================
581
+ // Story Parsing
582
+ // ===========================================================================
583
+
584
+ /**
585
+ * Parse a Storybook *.stories.tsx file and extract individual stories
586
+ * with their component instances, props, and layout.
587
+ */
588
+ parseStories(storyFilePath: string): StoryInfo[] {
589
+ const sourceFile = this.project.addSourceFileAtPath(storyFilePath);
590
+ const stories: StoryInfo[] = [];
591
+ const iconImports = this.collectReactIconImports(sourceFile);
592
+ let metaDecoratorWrappers: Array<{ tagName: string; classes: string[] }> = [];
593
+
594
+ // Collect import names to identify which JSX tags are component usages.
595
+ // Also track import targets for component expansion.
596
+ const importedComponents = new Set<string>();
597
+ const relativeImports = this.collectComponentImports(sourceFile);
598
+
599
+ for (const imp of sourceFile.getImportDeclarations()) {
600
+ for (const named of imp.getNamedImports()) {
601
+ const localName = named.getAliasNode()?.getText() || named.getName();
602
+ if (/^[A-Z]/.test(localName)) {
603
+ importedComponents.add(localName);
604
+ }
605
+ }
606
+ const defaultImport = imp.getDefaultImport();
607
+ if (defaultImport && /^[A-Z]/.test(defaultImport.getText())) {
608
+ importedComponents.add(defaultImport.getText());
609
+ }
610
+ }
611
+
612
+ // Collect local component definitions (const X = () => JSX)
613
+ // These are components defined in the story file itself
614
+ const localComponents = new Map<string, Node>();
615
+ for (const varStmt of sourceFile.getVariableStatements()) {
616
+ if (varStmt.isExported()) continue; // Skip exports (these are stories)
617
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
618
+ const name = decl.getName();
619
+ if (/^[A-Z]/.test(name)) {
620
+ const init = decl.getInitializer();
621
+ if (init && Node.isArrowFunction(init)) {
622
+ // Find the JSX return in the arrow function
623
+ const body = init.getBody();
624
+ if (body) {
625
+ localComponents.set(name, body);
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+
632
+ // Also check function declarations for local components
633
+ for (const func of sourceFile.getFunctions()) {
634
+ const name = func.getName();
635
+ if (name && /^[A-Z]/.test(name) && !func.isExported()) {
636
+ localComponents.set(name, func);
637
+ }
638
+ }
639
+
640
+ // Find the meta's component name (from `component: Button`)
641
+ let metaComponentName: string | undefined;
642
+ for (const varStmt of sourceFile.getVariableStatements()) {
643
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
644
+ if (decl.getName() === 'meta') {
645
+ const init = decl.getInitializer();
646
+ if (init && Node.isObjectLiteralExpression(init)) {
647
+ const compProp = init.getProperty('component');
648
+ if (compProp && Node.isPropertyAssignment(compProp)) {
649
+ metaComponentName = compProp.getInitializer()?.getText();
650
+ }
651
+ const decoratorsProp = init.getProperty('decorators');
652
+ metaDecoratorWrappers = this.extractDecoratorWrappers(decoratorsProp);
653
+ }
654
+ }
655
+ }
656
+ }
657
+
658
+ // Find all exported const declarations that are stories (not `meta` or `default`)
659
+ for (const varStmt of sourceFile.getVariableStatements()) {
660
+ if (!varStmt.isExported()) continue;
661
+
662
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
663
+ const storyName = decl.getName();
664
+ if (storyName === 'meta' || storyName === 'default') continue;
665
+
666
+ const init = decl.getInitializer();
667
+ if (!init || !Node.isObjectLiteralExpression(init)) continue;
668
+
669
+ const story: StoryInfo = {
670
+ name: storyName,
671
+ instances: [],
672
+ };
673
+ const storyDecoratorWrappers = this.extractDecoratorWrappers(
674
+ init.getProperty('decorators')
675
+ );
676
+
677
+ // Check for `args` property
678
+ const argsProp = init.getProperty('args');
679
+ if (argsProp && Node.isPropertyAssignment(argsProp)) {
680
+ const argsInit = argsProp.getInitializer();
681
+ if (argsInit && Node.isObjectLiteralExpression(argsInit)) {
682
+ const args: Record<string, string> = {};
683
+ for (const prop of argsInit.getProperties()) {
684
+ if (Node.isPropertyAssignment(prop)) {
685
+ const propInit = prop.getInitializer();
686
+ if (propInit) {
687
+ args[prop.getName()] = this.extractStringValue(propInit);
688
+ }
689
+ }
690
+ }
691
+ story.args = args;
692
+
693
+ if (metaComponentName) {
694
+ const instance: ComponentInstance = {
695
+ componentName: metaComponentName,
696
+ props: { ...args },
697
+ children: args.children,
698
+ };
699
+ delete instance.props.children;
700
+ story.instances.push(instance);
701
+
702
+ // For args-based stories, build jsxTree from the local component definition
703
+ const localCompBody = localComponents.get(metaComponentName);
704
+ if (localCompBody && !story.jsxTree) {
705
+ // Find the root JSX element within the component body
706
+ // (handles arrow functions like `() => (<div>...</div>)`)
707
+ const jsxElements = localCompBody.getDescendantsOfKind(SyntaxKind.JsxElement);
708
+ const selfClosingElements = localCompBody.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
709
+ const allJsx: Node[] = [...jsxElements, ...selfClosingElements];
710
+ if (allJsx.length > 0) {
711
+ // Find root (fewest ancestors)
712
+ let root = allJsx[0];
713
+ for (const el of allJsx) {
714
+ if (el.getAncestors().length < root.getAncestors().length) root = el;
715
+ }
716
+ story.jsxTree = this.buildJsxTree(root, localComponents, relativeImports);
717
+ }
718
+ }
719
+ }
720
+ }
721
+ }
722
+
723
+ // Check for `render` property
724
+ const renderProp = init.getProperty('render');
725
+ if (renderProp && Node.isPropertyAssignment(renderProp)) {
726
+ const renderInit = renderProp.getInitializer();
727
+ if (renderInit) {
728
+ this.extractJsxInstances(renderInit, importedComponents, localComponents, relativeImports, story);
729
+ }
730
+ }
731
+
732
+ // Check for method-style `render()`
733
+ const renderMethod = init.getProperty('render');
734
+ if (renderMethod && Node.isMethodDeclaration(renderMethod)) {
735
+ this.extractJsxInstances(renderMethod, importedComponents, localComponents, relativeImports, story);
736
+ }
737
+
738
+ const decoratorWrappers = metaDecoratorWrappers.concat(storyDecoratorWrappers);
739
+ if (decoratorWrappers.length > 0) {
740
+ if (story.jsxTree) {
741
+ story.jsxTree = this.wrapJsxTreeWithDecorators(story.jsxTree, decoratorWrappers);
742
+ }
743
+ if (!story.layoutClasses || story.layoutClasses.length === 0) {
744
+ const merged = this.flattenDecoratorClasses(decoratorWrappers);
745
+ if (merged.length > 0) {
746
+ story.layoutClasses = merged;
747
+ }
748
+ }
749
+ }
750
+
751
+ if (story.jsxTree) {
752
+ this.collectIconUsage(story.jsxTree, iconImports);
753
+ }
754
+
755
+ stories.push(story);
756
+ }
757
+ }
758
+
759
+ return stories;
760
+ }
761
+
762
+ /**
763
+ * Extract component instances from JSX within a render function.
764
+ * Also builds the full JSX tree for recursive rendering.
765
+ */
766
+ private extractJsxInstances(
767
+ node: Node,
768
+ importedComponents: Set<string>,
769
+ localComponents: Map<string, Node>,
770
+ relativeImports: Map<string, string>,
771
+ story: StoryInfo
772
+ ): void {
773
+ // Find all JSX elements
774
+ const jsxElements = node.getDescendantsOfKind(SyntaxKind.JsxElement);
775
+ const selfClosingElements = node.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
776
+
777
+ // Find the root JSX element (fewest ancestors)
778
+ const allJsx: Node[] = [...jsxElements, ...selfClosingElements];
779
+ if (allJsx.length > 0) {
780
+ let root = allJsx[0];
781
+ for (const el of allJsx) {
782
+ if (el.getAncestors().length < root.getAncestors().length) root = el;
783
+ }
784
+
785
+ // Build the full JSX tree from the root, expanding local and imported components
786
+ story.jsxTree = this.buildJsxTree(root, localComponents, relativeImports);
787
+
788
+ // Extract layout classes from root element (any tag, not just div)
789
+ let attrs: any[] = [];
790
+ if (Node.isJsxElement(root)) {
791
+ attrs = root.getOpeningElement().getDescendantsOfKind(SyntaxKind.JsxAttribute);
792
+ } else if (Node.isJsxSelfClosingElement(root)) {
793
+ attrs = root.getDescendantsOfKind(SyntaxKind.JsxAttribute);
794
+ }
795
+ const classAttr = attrs.find(a => {
796
+ const nameNode = (a as any).getNameNode?.();
797
+ return nameNode ? nameNode.getText() === 'className' : false;
798
+ });
799
+ if (classAttr) {
800
+ const value = classAttr.getInitializer();
801
+ if (value && Node.isStringLiteral(value)) {
802
+ story.layoutClasses = value.getLiteralValue().split(/\s+/).filter(Boolean);
803
+ }
804
+ }
805
+ }
806
+
807
+ // Also extract flat component instances (for backward compatibility)
808
+ for (const el of jsxElements) {
809
+ const tagName = el.getOpeningElement().getTagNameNode().getText();
810
+ if (importedComponents.has(tagName)) {
811
+ const instance = this.extractInstanceFromJsxOpening(
812
+ el.getOpeningElement(),
813
+ tagName
814
+ );
815
+ const textChildren = el.getJsxChildren()
816
+ .filter(child => Node.isJsxText(child))
817
+ .map(child => decodeHtmlEntities(child.getText().trim()))
818
+ .filter(Boolean)
819
+ .join(' ');
820
+ if (textChildren) {
821
+ instance.children = textChildren;
822
+ }
823
+ story.instances.push(instance);
824
+ }
825
+ }
826
+
827
+ for (const el of selfClosingElements) {
828
+ const tagName = el.getTagNameNode().getText();
829
+ if (importedComponents.has(tagName)) {
830
+ const instance = this.extractInstanceFromSelfClosing(el, tagName);
831
+ story.instances.push(instance);
832
+ }
833
+ }
834
+ }
835
+
836
+ private extractDecoratorWrappers(
837
+ decoratorsProp: Node | undefined
838
+ ): Array<{ tagName: string; classes: string[] }> {
839
+ if (!decoratorsProp || !Node.isPropertyAssignment(decoratorsProp)) return [];
840
+ const init = decoratorsProp.getInitializer();
841
+ if (!init || !Node.isArrayLiteralExpression(init)) return [];
842
+ const wrappers: Array<{ tagName: string; classes: string[] }> = [];
843
+
844
+ for (const element of init.getElements()) {
845
+ let bodyNode: Node | undefined = element;
846
+ if (Node.isArrowFunction(element) || Node.isFunctionExpression(element)) {
847
+ bodyNode = element.getBody();
848
+ if (bodyNode && Node.isParenthesizedExpression(bodyNode)) {
849
+ bodyNode = bodyNode.getExpression();
850
+ }
851
+ }
852
+
853
+ if (!bodyNode) continue;
854
+
855
+ const allJsx: Node[] = [];
856
+ if (Node.isJsxElement(bodyNode) || Node.isJsxSelfClosingElement(bodyNode)) {
857
+ allJsx.push(bodyNode);
858
+ }
859
+ const jsxElements = bodyNode.getDescendantsOfKind(SyntaxKind.JsxElement);
860
+ const selfClosing = bodyNode.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
861
+ allJsx.push(...jsxElements, ...selfClosing);
862
+ if (allJsx.length === 0) continue;
863
+
864
+ let root = allJsx[0];
865
+ for (const el of allJsx) {
866
+ if (el.getAncestors().length < root.getAncestors().length) root = el;
867
+ }
868
+
869
+ const result = this.extractLayoutClassesFromRoot(root);
870
+ if (result && result.classes.length > 0) {
871
+ wrappers.push(result);
872
+ }
873
+ }
874
+
875
+ return wrappers;
876
+ }
877
+
878
+ private flattenDecoratorClasses(wrappers: Array<{ tagName: string; classes: string[] }>): string[] {
879
+ const out: string[] = [];
880
+ for (const wrapper of wrappers) {
881
+ for (const cls of wrapper.classes) {
882
+ out.push(cls);
883
+ }
884
+ }
885
+ return out;
886
+ }
887
+
888
+ private wrapJsxTreeWithDecorators(tree: JsxNode, wrappers: Array<{ tagName: string; classes: string[] }>): JsxNode {
889
+ let current = tree;
890
+ for (const wrapper of wrappers) {
891
+ if (!wrapper || wrapper.classes.length === 0) continue;
892
+ current = {
893
+ type: 'element',
894
+ tagName: wrapper.tagName,
895
+ isComponent: false,
896
+ props: { className: wrapper.classes.join(' ') },
897
+ children: [current],
898
+ };
899
+ }
900
+ return current;
901
+ }
902
+
903
+ private static readonly DECORATOR_BLOCK_TAGS = new Set([
904
+ 'div', 'header', 'nav', 'section', 'main', 'footer', 'article', 'aside',
905
+ ]);
906
+
907
+ private extractLayoutClassesFromRoot(root: Node): { tagName: string; classes: string[] } | null {
908
+ let tagName = '';
909
+ let attrs: any[] = [];
910
+ if (Node.isJsxElement(root)) {
911
+ tagName = root.getOpeningElement().getTagNameNode().getText();
912
+ attrs = root.getOpeningElement().getDescendantsOfKind(SyntaxKind.JsxAttribute);
913
+ } else if (Node.isJsxSelfClosingElement(root)) {
914
+ tagName = root.getTagNameNode().getText();
915
+ attrs = root.getDescendantsOfKind(SyntaxKind.JsxAttribute);
916
+ }
917
+ if (!ComponentScanner.DECORATOR_BLOCK_TAGS.has(tagName.toLowerCase())) return null;
918
+ const classAttr = attrs.find(a => {
919
+ const nameNode = (a as any).getNameNode?.();
920
+ return nameNode ? nameNode.getText() === 'className' : false;
921
+ });
922
+ if (!classAttr) return null;
923
+ const value = classAttr.getInitializer();
924
+ if (value && Node.isStringLiteral(value)) {
925
+ const classes = value.getLiteralValue().split(/\s+/).filter(Boolean);
926
+ return { tagName: tagName.toLowerCase(), classes };
927
+ }
928
+ return null;
929
+ }
930
+
931
+ private resolveImportedComponentPath(baseDir: string, moduleSpec: string): string | null {
932
+ let importBasePath: string | null = null;
933
+
934
+ if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
935
+ importBasePath = path.resolve(baseDir, moduleSpec);
936
+ } else if (moduleSpec.startsWith('~/')) {
937
+ importBasePath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
938
+ }
939
+
940
+ if (!importBasePath) return null;
941
+
942
+ const candidates = [
943
+ importBasePath,
944
+ `${importBasePath}.tsx`,
945
+ `${importBasePath}.ts`,
946
+ path.join(importBasePath, 'index.tsx'),
947
+ path.join(importBasePath, 'index.ts'),
948
+ ];
949
+
950
+ for (const candidate of candidates) {
951
+ if (!fs.existsSync(candidate)) continue;
952
+ const stat = fs.statSync(candidate);
953
+ if (stat.isFile()) return candidate;
954
+ }
955
+
956
+ return null;
957
+ }
958
+
959
+ private collectComponentImports(sourceFile: SourceFile): Map<string, string> {
960
+ const imports = new Map<string, string>();
961
+ const fileDir = path.dirname(sourceFile.getFilePath());
962
+
963
+ for (const imp of sourceFile.getImportDeclarations()) {
964
+ const moduleSpec = imp.getModuleSpecifierValue();
965
+ const resolvedPath = this.resolveImportedComponentPath(fileDir, moduleSpec);
966
+ if (!resolvedPath) continue;
967
+
968
+ for (const named of imp.getNamedImports()) {
969
+ const localName = named.getAliasNode()?.getText() || named.getName();
970
+ if (/^[A-Z]/.test(localName)) {
971
+ imports.set(localName, resolvedPath);
972
+ }
973
+ }
974
+
975
+ const defaultImport = imp.getDefaultImport();
976
+ if (defaultImport) {
977
+ const localName = defaultImport.getText();
978
+ if (/^[A-Z]/.test(localName)) {
979
+ imports.set(localName, resolvedPath);
980
+ }
981
+ }
982
+ }
983
+
984
+ return imports;
985
+ }
986
+
987
+ /**
988
+ * Recursively build a JSX tree from a ts-morph JSX node.
989
+ * Expands local component definitions and imported view components inline.
990
+ * @param propsContext - Resolved prop values from parent context (e.g., arrays passed from story)
991
+ */
992
+ private buildJsxTree(
993
+ node: Node,
994
+ localComponents: Map<string, Node>,
995
+ relativeImports: Map<string, string> = new Map(),
996
+ propsContext: Map<string, any> = new Map()
997
+ ): JsxNode {
998
+ if (Node.isJsxElement(node)) {
999
+ const rawTagName = node.getOpeningElement().getTagNameNode().getText();
1000
+ // Map known components to HTML elements (e.g., Link -> a)
1001
+ const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
1002
+ const props = this.extractPropsFromNode(node.getOpeningElement(), rawTagName, propsContext).props;
1003
+ const children: JsxNode[] = [];
1004
+
1005
+ for (const child of node.getJsxChildren()) {
1006
+ if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
1007
+ const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext);
1008
+ if ((childNode as any).__portalSkip) continue;
1009
+ children.push(childNode);
1010
+ } else if (Node.isJsxText(child)) {
1011
+ const text = decodeHtmlEntities(child.getText().trim());
1012
+ if (text) {
1013
+ children.push({ type: 'text', content: text });
1014
+ }
1015
+ } else if (Node.isJsxExpression(child)) {
1016
+ const expr = child.getExpression();
1017
+ if (expr && Node.isStringLiteral(expr)) {
1018
+ children.push({ type: 'text', content: expr.getLiteralValue() });
1019
+ } else if (expr && Node.isCallExpression(expr)) {
1020
+ // Check if this is a .map() call
1021
+ const callExpr = expr.getExpression();
1022
+ if (Node.isPropertyAccessExpression(callExpr) && callExpr.getName() === 'map') {
1023
+ // Try to expand the .map() call, passing propsContext for array resolution
1024
+ const expandedChildren = this.expandMapCall(expr, node.getSourceFile(), localComponents, relativeImports, propsContext);
1025
+ if (expandedChildren.length > 0) {
1026
+ children.push(...expandedChildren);
1027
+ }
1028
+ }
1029
+ } else if (expr && Node.isIdentifier(expr) && propsContext.has(expr.getText())) {
1030
+ this.pushResolvedPropValue(children, propsContext.get(expr.getText()));
1031
+ } else if (expr && Node.isIdentifier(expr)) {
1032
+ // Try to resolve as a module-level const string (e.g. const LONG = "...")
1033
+ const varDecl = node.getSourceFile().getVariableDeclaration(expr.getText());
1034
+ if (varDecl) {
1035
+ const init = varDecl.getInitializer();
1036
+ if (init && Node.isStringLiteral(init)) {
1037
+ children.push({ type: 'text', content: init.getLiteralValue() });
1038
+ }
1039
+ }
1040
+ } else if (expr && Node.isPropertyAccessExpression(expr)) {
1041
+ // Resolve {plan.name} style expressions when `plan` is an object in propsContext
1042
+ const objExpr = expr.getExpression();
1043
+ const propName = expr.getName();
1044
+ if (Node.isIdentifier(objExpr) && propsContext.has(objExpr.getText())) {
1045
+ const obj = propsContext.get(objExpr.getText());
1046
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj) && propName in obj) {
1047
+ this.pushResolvedPropValue(children, obj[propName]);
1048
+ }
1049
+ }
1050
+ } else if (expr) {
1051
+ const exprText = expr.getText();
1052
+ // Skip conditional expressions - they don't render well in Figma design system
1053
+ const isConditional = exprText.includes('?') || exprText.includes('&&') || exprText.includes('||');
1054
+ const isFunction = exprText.includes('=>') || exprText.includes('function');
1055
+ if (exprText && !isConditional && !isFunction) {
1056
+ // Only include simple variable references like {userName}
1057
+ children.push({ type: 'text', content: `{${exprText}}` });
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ if (/^[A-Z]/.test(rawTagName)) {
1064
+ const evaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
1065
+ if (children.length > 0) {
1066
+ evaluatedProps.children = children;
1067
+ }
1068
+
1069
+ const localDef = localComponents.get(rawTagName);
1070
+ if (localDef) {
1071
+ const expandedLocal = this.expandLocalComponentWithProps(localDef, evaluatedProps, localComponents, relativeImports);
1072
+ if (expandedLocal) {
1073
+ return expandedLocal;
1074
+ }
1075
+ }
1076
+
1077
+ const importedFilePath = relativeImports.get(rawTagName);
1078
+ const SKIP_EXPANSION_MAIN = ['Skeleton', 'SkeletonText'];
1079
+ if (importedFilePath && !SKIP_EXPANSION_MAIN.includes(rawTagName)) {
1080
+ const resolvedProps = new Map<string, any>();
1081
+ for (const [key, value] of Object.entries(evaluatedProps)) {
1082
+ resolvedProps.set(key, value);
1083
+ }
1084
+ const expandedImported = this.expandImportedComponent(rawTagName, importedFilePath, relativeImports, resolvedProps);
1085
+ if (expandedImported) {
1086
+ if (this.isPortalRootedNode(expandedImported)) {
1087
+ return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as any;
1088
+ }
1089
+ if (!this.isUnresolvedExpandedComponent(expandedImported, localComponents, relativeImports)) {
1090
+ return expandedImported;
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ const outputProps = { ...props } as Record<string, any>;
1097
+ delete outputProps.children;
1098
+ return {
1099
+ type: 'element',
1100
+ tagName,
1101
+ isComponent: /^[A-Z]/.test(tagName),
1102
+ props: outputProps,
1103
+ children,
1104
+ };
1105
+ } else if (Node.isJsxSelfClosingElement(node)) {
1106
+ const rawTagName = node.getTagNameNode().getText();
1107
+ // Map known components to HTML elements (e.g., Link -> a)
1108
+ const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
1109
+ const props = this.extractPropsFromNode(node, rawTagName, propsContext).props;
1110
+ const evaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
1111
+ const children: JsxNode[] = [];
1112
+ if (Object.prototype.hasOwnProperty.call(evaluatedProps, 'children')) {
1113
+ this.pushResolvedPropValue(children, evaluatedProps.children);
1114
+ }
1115
+
1116
+ // Check if this is a local component that should be expanded
1117
+ const localDef = localComponents.get(rawTagName);
1118
+ if (localDef) {
1119
+ const expandedLocal = this.expandLocalComponentWithProps(localDef, evaluatedProps, localComponents, relativeImports);
1120
+ if (expandedLocal) {
1121
+ return expandedLocal;
1122
+ }
1123
+ }
1124
+
1125
+ // Check if this is an imported component from a relative path
1126
+ // Skip expansion for simple components that the ui-builder handles directly
1127
+ // (e.g., Skeleton uses clsx with props that we can't statically evaluate)
1128
+ const SKIP_EXPANSION_MAIN = ['Skeleton', 'SkeletonText'];
1129
+ const importedFilePath = relativeImports.get(rawTagName);
1130
+ if (importedFilePath && !SKIP_EXPANSION_MAIN.includes(rawTagName)) {
1131
+ const resolvedProps = new Map<string, any>();
1132
+ for (const [key, value] of Object.entries(evaluatedProps)) {
1133
+ resolvedProps.set(key, value);
1134
+ }
1135
+
1136
+ const expandedJsx = this.expandImportedComponent(rawTagName, importedFilePath, relativeImports, resolvedProps);
1137
+ if (expandedJsx) {
1138
+ if (this.isPortalRootedNode(expandedJsx)) {
1139
+ return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as any;
1140
+ }
1141
+ if (!this.isUnresolvedExpandedComponent(expandedJsx, localComponents, relativeImports)) {
1142
+ return expandedJsx;
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ const outputProps = { ...props } as Record<string, any>;
1148
+ delete outputProps.children;
1149
+ return {
1150
+ type: 'element',
1151
+ tagName,
1152
+ isComponent: /^[A-Z]/.test(tagName),
1153
+ props: outputProps,
1154
+ children,
1155
+ };
1156
+ }
1157
+
1158
+ // Fallback for unexpected node types
1159
+ return { type: 'text', content: '' };
1160
+ }
1161
+
1162
+ private cloneJsxNode(node: JsxNode): JsxNode {
1163
+ if (node.type === 'text') {
1164
+ return { type: 'text', content: (node as JsxText).content };
1165
+ }
1166
+ const el = node as JsxElement;
1167
+ const children: JsxNode[] = [];
1168
+ for (let i = 0; i < (el.children || []).length; i++) {
1169
+ children.push(this.cloneJsxNode(el.children[i]));
1170
+ }
1171
+ return {
1172
+ type: 'element',
1173
+ tagName: el.tagName,
1174
+ isComponent: el.isComponent,
1175
+ props: Object.assign({}, el.props || {}),
1176
+ children,
1177
+ };
1178
+ }
1179
+
1180
+ private pushResolvedPropValue(children: JsxNode[], value: any): void {
1181
+ if (value == null || value === '') return;
1182
+ if (Array.isArray(value)) {
1183
+ for (let i = 0; i < value.length; i++) {
1184
+ const item = value[i];
1185
+ if (item == null || item === '') continue;
1186
+ if (typeof item === 'object' && item.type && (item.type === 'text' || item.type === 'element')) {
1187
+ children.push(this.cloneJsxNode(item as JsxNode));
1188
+ } else {
1189
+ children.push({ type: 'text', content: String(item) });
1190
+ }
1191
+ }
1192
+ return;
1193
+ }
1194
+ if (typeof value === 'object' && value.type && (value.type === 'text' || value.type === 'element')) {
1195
+ children.push(this.cloneJsxNode(value as JsxNode));
1196
+ return;
1197
+ }
1198
+ children.push({ type: 'text', content: String(value) });
1199
+ }
1200
+
1201
+ private resolveInvocationProps(
1202
+ props: Record<string, string>,
1203
+ sourceFile: SourceFile,
1204
+ propsContext: Map<string, any>
1205
+ ): Record<string, any> {
1206
+ const resolved: Record<string, any> = {};
1207
+ for (const [propName, propValue] of Object.entries(props)) {
1208
+ if (typeof propValue !== 'string') {
1209
+ resolved[propName] = propValue;
1210
+ continue;
1211
+ }
1212
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(propValue)) {
1213
+ if (propsContext.has(propValue)) {
1214
+ resolved[propName] = propsContext.get(propValue);
1215
+ continue;
1216
+ }
1217
+ const arrayValue = this.findArrayValue(propValue, sourceFile);
1218
+ if (arrayValue) {
1219
+ resolved[propName] = arrayValue;
1220
+ continue;
1221
+ }
1222
+ }
1223
+ resolved[propName] = propValue;
1224
+ }
1225
+ return resolved;
1226
+ }
1227
+
1228
+ /**
1229
+ * Build a propsContext Map from a map-callback item so that resolveClassNameExpression
1230
+ * can substitute item.field references inside template literals and ternaries.
1231
+ * For a normal param (`item`): adds `item → itemValue` so `item.selected` resolves.
1232
+ * For destructured params (`__destructured__`): adds each key directly.
1233
+ */
1234
+ private buildItemPropsContext(itemParamName: string, itemValue: any): Map<string, any> {
1235
+ const ctx = new Map<string, any>();
1236
+ if (!itemParamName || itemValue == null) return ctx;
1237
+ if (itemParamName === '__destructured__') {
1238
+ if (typeof itemValue === 'object') {
1239
+ for (const [k, v] of Object.entries(itemValue)) ctx.set(k, v);
1240
+ }
1241
+ } else {
1242
+ ctx.set(itemParamName, itemValue);
1243
+ }
1244
+ return ctx;
1245
+ }
1246
+
1247
+ private resolveSubstitutionProps(
1248
+ props: Record<string, string>,
1249
+ itemParamName: string,
1250
+ itemValue: any
1251
+ ): Record<string, any> {
1252
+ const resolved: Record<string, any> = {};
1253
+ for (const [propName, propValue] of Object.entries(props)) {
1254
+ if (propValue.startsWith(itemParamName + '.')) {
1255
+ const propPath = propValue.slice(itemParamName.length + 1);
1256
+ resolved[propName] = itemValue && typeof itemValue === 'object'
1257
+ ? itemValue[propPath]
1258
+ : undefined;
1259
+ } else {
1260
+ resolved[propName] = propValue;
1261
+ }
1262
+ }
1263
+ return resolved;
1264
+ }
1265
+
1266
+ /**
1267
+ * Collect local component definitions from a source file.
1268
+ * Looks for function declarations and arrow function variable declarations.
1269
+ */
1270
+ private collectLocalComponentsFromFile(sourceFile: SourceFile): Map<string, Node> {
1271
+ const localComponents = new Map<string, Node>();
1272
+
1273
+ // Check function declarations
1274
+ for (const func of sourceFile.getFunctions()) {
1275
+ const name = func.getName();
1276
+ if (name && /^[A-Z]/.test(name)) {
1277
+ localComponents.set(name, func);
1278
+ }
1279
+ }
1280
+
1281
+ // Check variable declarations with arrow functions
1282
+ for (const varStmt of sourceFile.getVariableStatements()) {
1283
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
1284
+ const name = decl.getName();
1285
+ if (/^[A-Z]/.test(name)) {
1286
+ const init = decl.getInitializer();
1287
+ if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
1288
+ localComponents.set(name, init);
1289
+ }
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ return localComponents;
1295
+ }
1296
+
1297
+ /**
1298
+ * Expand a .map() call by finding the source array and generating JSX for each item.
1299
+ * Handles patterns like: {features.map((feature) => (<li>...</li>))}
1300
+ * @param propsContext - Resolved prop values that may contain the array (e.g., from story)
1301
+ */
1302
+ private expandMapCall(
1303
+ callExpr: Node,
1304
+ sourceFile: SourceFile,
1305
+ localComponents: Map<string, Node>,
1306
+ relativeImports: Map<string, string>,
1307
+ propsContext: Map<string, any> = new Map()
1308
+ ): JsxNode[] {
1309
+ const results: JsxNode[] = [];
1310
+
1311
+ try {
1312
+ if (!Node.isCallExpression(callExpr)) return results;
1313
+
1314
+ // Get the property access (e.g., features.map)
1315
+ const propAccess = callExpr.getExpression();
1316
+ if (!Node.isPropertyAccessExpression(propAccess)) return results;
1317
+
1318
+ // Get the array expression (may be a variable name or an inline array literal)
1319
+ const arrayExpr = propAccess.getExpression();
1320
+ const arrayName = Node.isArrayLiteralExpression(arrayExpr) ? '__inline__' : arrayExpr.getText();
1321
+
1322
+ // Get the map callback function
1323
+ const args = callExpr.getArguments();
1324
+ if (args.length === 0) return results;
1325
+
1326
+ const callback = args[0];
1327
+ if (!Node.isArrowFunction(callback) && !Node.isFunctionExpression(callback)) return results;
1328
+
1329
+ // Get the callback parameter — may be a simple name or object destructuring pattern.
1330
+ // For destructuring ({ icon: Icon, label, shortcut }), we build a local→sourceKey map
1331
+ // and use itemParamName = '__destructured__' so substitution uses flat property lookup.
1332
+ const params = callback.getParameters();
1333
+ if (params.length === 0) return results;
1334
+
1335
+ let itemParamName: string;
1336
+ let destructuringBindings: Map<string, string> | null = null;
1337
+ const firstParamNameNode = params[0].getNameNode();
1338
+ if (Node.isObjectBindingPattern(firstParamNameNode)) {
1339
+ destructuringBindings = new Map();
1340
+ for (const element of firstParamNameNode.getElements()) {
1341
+ const propNameNode = element.getPropertyNameNode();
1342
+ const elemNameNode = element.getNameNode();
1343
+ const localName = elemNameNode.getText();
1344
+ const sourceKey = propNameNode ? propNameNode.getText() : localName;
1345
+ destructuringBindings.set(localName, sourceKey);
1346
+ }
1347
+ itemParamName = '__destructured__';
1348
+ } else {
1349
+ itemParamName = params[0].getName();
1350
+ }
1351
+
1352
+ // Find the JSX in the callback body
1353
+ const body = callback.getBody();
1354
+ if (!body) return results;
1355
+
1356
+ // Find the root JSX element in the callback
1357
+ let callbackJsx: Node | null = null;
1358
+ if (Node.isJsxElement(body) || Node.isJsxSelfClosingElement(body)) {
1359
+ callbackJsx = body;
1360
+ } else if (Node.isParenthesizedExpression(body)) {
1361
+ const inner = body.getExpression();
1362
+ if (Node.isJsxElement(inner) || Node.isJsxSelfClosingElement(inner)) {
1363
+ callbackJsx = inner;
1364
+ }
1365
+ } else {
1366
+ // Block body - look for return statement or JSX
1367
+ const jsxElements = body.getDescendantsOfKind(SyntaxKind.JsxElement);
1368
+ const selfClosing = body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
1369
+ const allJsx = [...jsxElements, ...selfClosing];
1370
+ if (allJsx.length > 0) {
1371
+ // Find root JSX (fewest ancestors)
1372
+ callbackJsx = allJsx[0];
1373
+ for (const el of allJsx) {
1374
+ if (el.getAncestors().length < (callbackJsx as Node).getAncestors().length) {
1375
+ callbackJsx = el;
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1380
+
1381
+ if (!callbackJsx) return results;
1382
+
1383
+ // Find the array's value - inline literal first, then propsContext, then source file
1384
+ let arrayValue: any[] | null = null;
1385
+
1386
+ // Inline array literal: [{ ... }, ...].map(...)
1387
+ if (arrayName === '__inline__' && Node.isArrayLiteralExpression(arrayExpr)) {
1388
+ arrayValue = this.parseArrayLiteral(arrayExpr);
1389
+ }
1390
+
1391
+ // Check if array was passed via props (e.g., features={minimalFeatures} from story)
1392
+ if (!arrayValue && propsContext.has(arrayName)) {
1393
+ const fromProps = propsContext.get(arrayName);
1394
+ if (Array.isArray(fromProps)) {
1395
+ arrayValue = fromProps;
1396
+ } else if (typeof fromProps === 'string' && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fromProps)) {
1397
+ // Prop may still be an unresolved identifier (e.g. features={defaultFeatures}).
1398
+ // Resolve that identifier in the current component source before falling back.
1399
+ arrayValue = this.findArrayValue(fromProps, sourceFile);
1400
+ }
1401
+ }
1402
+
1403
+ if (!arrayValue) {
1404
+ // Look in source file for default value
1405
+ arrayValue = this.findArrayValue(arrayName, sourceFile);
1406
+ }
1407
+
1408
+ if (!arrayValue || arrayValue.length === 0) {
1409
+ // If we can't find the array value, generate one iteration as a template
1410
+ const templateJsx = this.buildJsxTreeWithSubstitution(
1411
+ callbackJsx,
1412
+ itemParamName,
1413
+ { name: 'Item', description: 'Description' }, // Placeholder values
1414
+ localComponents,
1415
+ relativeImports
1416
+ );
1417
+ if (templateJsx) {
1418
+ results.push(templateJsx);
1419
+ }
1420
+ return results;
1421
+ }
1422
+
1423
+ // Collect local components from the source file (for expanding things like FeatureCheck)
1424
+ const fileLocalComponents = this.collectLocalComponentsFromFile(sourceFile);
1425
+ // Merge with provided localComponents (story file components take precedence)
1426
+ const mergedLocalComponents = new Map([...fileLocalComponents, ...localComponents]);
1427
+
1428
+ // Generate JSX for each item in the array (limit to prevent huge outputs)
1429
+ const maxItems = Math.min(arrayValue.length, 7);
1430
+ for (let i = 0; i < maxItems; i++) {
1431
+ let item = arrayValue[i];
1432
+ // When the callback uses destructuring, flatten the item to use local names as keys
1433
+ if (destructuringBindings && typeof item === 'object' && item !== null) {
1434
+ const flatItem: Record<string, any> = {};
1435
+ for (const [localName, sourceKey] of destructuringBindings) {
1436
+ flatItem[localName] = item[sourceKey];
1437
+ }
1438
+ item = flatItem;
1439
+ }
1440
+ const itemJsx = this.buildJsxTreeWithSubstitution(
1441
+ callbackJsx,
1442
+ itemParamName,
1443
+ item,
1444
+ mergedLocalComponents,
1445
+ relativeImports
1446
+ );
1447
+ if (itemJsx) {
1448
+ results.push(itemJsx);
1449
+ }
1450
+ }
1451
+ } catch (err) {
1452
+ console.warn('Failed to expand .map() call:', err);
1453
+ }
1454
+
1455
+ return results;
1456
+ }
1457
+
1458
+ /**
1459
+ * Find the value of an array variable in the source file.
1460
+ * Looks for const declarations and default prop values.
1461
+ */
1462
+ private findArrayValue(arrayName: string, sourceFile: SourceFile): any[] | null {
1463
+ // Check cache first
1464
+ const cacheKey = `${sourceFile.getFilePath()}:${arrayName}`;
1465
+ if (this.arrayValueCache.has(cacheKey)) {
1466
+ return this.arrayValueCache.get(cacheKey)!;
1467
+ }
1468
+
1469
+ // Look for const declaration: const features = [...]
1470
+ for (const varStmt of sourceFile.getVariableStatements()) {
1471
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
1472
+ if (decl.getName() === arrayName) {
1473
+ const init = this.unwrapStaticValueExpression(decl.getInitializer());
1474
+ if (init && Node.isArrayLiteralExpression(init)) {
1475
+ const value = this.parseArrayLiteral(init);
1476
+ this.arrayValueCache.set(cacheKey, value);
1477
+ return value;
1478
+ }
1479
+ }
1480
+ }
1481
+ }
1482
+
1483
+ // Look for default parameter value in function: function Comp({ features = defaultFeatures })
1484
+ for (const func of sourceFile.getFunctions()) {
1485
+ const params = func.getParameters();
1486
+ for (const param of params) {
1487
+ // Check if it's an object binding pattern: { features = defaultFeatures }
1488
+ const bindingName = param.getNameNode();
1489
+ if (Node.isObjectBindingPattern(bindingName)) {
1490
+ for (const element of bindingName.getElements()) {
1491
+ const elemName = element.getNameNode();
1492
+ if (Node.isIdentifier(elemName) && elemName.getText() === arrayName) {
1493
+ const defaultInit = this.unwrapStaticValueExpression(element.getInitializer());
1494
+ if (defaultInit && Node.isIdentifier(defaultInit)) {
1495
+ // Recursively find the referenced array
1496
+ const referencedName = defaultInit.getText();
1497
+ const result = this.findArrayValue(referencedName, sourceFile);
1498
+ if (result) {
1499
+ this.arrayValueCache.set(cacheKey, result);
1500
+ return result;
1501
+ }
1502
+ } else if (defaultInit && Node.isArrayLiteralExpression(defaultInit)) {
1503
+ const value = this.parseArrayLiteral(defaultInit);
1504
+ this.arrayValueCache.set(cacheKey, value);
1505
+ return value;
1506
+ }
1507
+ }
1508
+ }
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ // Also check variable declarations with arrow functions
1514
+ for (const varStmt of sourceFile.getVariableStatements()) {
1515
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
1516
+ const init = decl.getInitializer();
1517
+ if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
1518
+ const params = init.getParameters();
1519
+ for (const param of params) {
1520
+ const bindingName = param.getNameNode();
1521
+ if (Node.isObjectBindingPattern(bindingName)) {
1522
+ for (const element of bindingName.getElements()) {
1523
+ const elemName = element.getNameNode();
1524
+ if (Node.isIdentifier(elemName) && elemName.getText() === arrayName) {
1525
+ const defaultInit = this.unwrapStaticValueExpression(element.getInitializer());
1526
+ if (defaultInit && Node.isIdentifier(defaultInit)) {
1527
+ const referencedName = defaultInit.getText();
1528
+ const result = this.findArrayValue(referencedName, sourceFile);
1529
+ if (result) {
1530
+ this.arrayValueCache.set(cacheKey, result);
1531
+ return result;
1532
+ }
1533
+ }
1534
+ }
1535
+ }
1536
+ }
1537
+ }
1538
+ }
1539
+ }
1540
+ }
1541
+
1542
+ return null;
1543
+ }
1544
+
1545
+ /**
1546
+ * Unwrap syntax wrappers around static values so declarations like
1547
+ * `const FAQS = [...] as const` still resolve to the underlying literal.
1548
+ */
1549
+ private unwrapStaticValueExpression(expr: Node | undefined): Node | undefined {
1550
+ let current = expr;
1551
+ const nodeApi = Node as any;
1552
+
1553
+ while (current) {
1554
+ if (Node.isParenthesizedExpression(current)) {
1555
+ current = current.getExpression();
1556
+ continue;
1557
+ }
1558
+ if (Node.isAsExpression(current)) {
1559
+ current = current.getExpression();
1560
+ continue;
1561
+ }
1562
+ if (typeof nodeApi.isTypeAssertion === 'function' && nodeApi.isTypeAssertion(current)) {
1563
+ current = current.getExpression();
1564
+ continue;
1565
+ }
1566
+ if (typeof nodeApi.isSatisfiesExpression === 'function' && nodeApi.isSatisfiesExpression(current)) {
1567
+ current = current.getExpression();
1568
+ continue;
1569
+ }
1570
+ break;
1571
+ }
1572
+
1573
+ return current;
1574
+ }
1575
+
1576
+ /**
1577
+ * Parse an array literal expression into JavaScript values.
1578
+ */
1579
+ private parseArrayLiteral(arrayLit: Node): any[] {
1580
+ const result: any[] = [];
1581
+
1582
+ if (!Node.isArrayLiteralExpression(arrayLit)) return result;
1583
+
1584
+ for (const element of arrayLit.getElements()) {
1585
+ if (Node.isObjectLiteralExpression(element)) {
1586
+ const obj: Record<string, any> = {};
1587
+ for (const prop of element.getProperties()) {
1588
+ if (Node.isPropertyAssignment(prop)) {
1589
+ const name = prop.getName();
1590
+ const init = prop.getInitializer();
1591
+ if (init) {
1592
+ if (Node.isStringLiteral(init)) {
1593
+ obj[name] = init.getLiteralValue();
1594
+ } else if (Node.isNumericLiteral(init)) {
1595
+ obj[name] = Number(init.getLiteralText());
1596
+ } else if (init.getKind() === SyntaxKind.TrueKeyword) {
1597
+ obj[name] = true;
1598
+ } else if (init.getKind() === SyntaxKind.FalseKeyword) {
1599
+ obj[name] = false;
1600
+ } else {
1601
+ obj[name] = init.getText();
1602
+ }
1603
+ }
1604
+ }
1605
+ }
1606
+ result.push(obj);
1607
+ } else if (Node.isStringLiteral(element)) {
1608
+ result.push(element.getLiteralValue());
1609
+ } else if (Node.isNumericLiteral(element)) {
1610
+ result.push(Number(element.getLiteralText()));
1611
+ }
1612
+ }
1613
+
1614
+ return result;
1615
+ }
1616
+
1617
+ /**
1618
+ * Build a JSX tree with value substitution for map callback items.
1619
+ * Replaces {item.prop} expressions with actual values.
1620
+ */
1621
+ private buildJsxTreeWithSubstitution(
1622
+ node: Node,
1623
+ itemParamName: string,
1624
+ itemValue: any,
1625
+ localComponents: Map<string, Node>,
1626
+ relativeImports: Map<string, string>
1627
+ ): JsxNode | null {
1628
+ if (Node.isJsxElement(node)) {
1629
+ const rawTagName = node.getOpeningElement().getTagNameNode().getText();
1630
+ const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
1631
+ const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue);
1632
+ const props = this.extractPropsFromNode(node.getOpeningElement(), rawTagName, itemPropsContext).props;
1633
+ const children: JsxNode[] = [];
1634
+
1635
+ for (const child of node.getJsxChildren()) {
1636
+ if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
1637
+ const childJsx = this.buildJsxTreeWithSubstitution(child, itemParamName, itemValue, localComponents, relativeImports);
1638
+ if (childJsx) children.push(childJsx);
1639
+ } else if (Node.isJsxText(child)) {
1640
+ const text = decodeHtmlEntities(child.getText().trim());
1641
+ if (text) {
1642
+ children.push({ type: 'text', content: text });
1643
+ }
1644
+ } else if (Node.isJsxExpression(child)) {
1645
+ const expr = child.getExpression();
1646
+ if (expr) {
1647
+ // Handle conditional JSX: {condition && <JSX>} or {condition && (<JSX>)}
1648
+ if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '&&') {
1649
+ const left = expr.getLeft();
1650
+ let right = expr.getRight();
1651
+ // Unwrap parenthesized expression
1652
+ if (Node.isParenthesizedExpression(right)) {
1653
+ right = right.getExpression();
1654
+ }
1655
+ const condValue = this.substituteExpression(left, itemParamName, itemValue);
1656
+ if (condValue && (Node.isJsxElement(right) || Node.isJsxSelfClosingElement(right))) {
1657
+ const jsxChild = this.buildJsxTreeWithSubstitution(right, itemParamName, itemValue, localComponents, relativeImports);
1658
+ if (jsxChild) children.push(jsxChild);
1659
+ }
1660
+ // Handle ternary JSX: {condition ? <JSX1> : <JSX2>}
1661
+ } else if (Node.isConditionalExpression(expr)) {
1662
+ const condition = expr.getCondition();
1663
+ let whenTrue = expr.getWhenTrue();
1664
+ let whenFalse = expr.getWhenFalse();
1665
+ // Unwrap parenthesized expressions
1666
+ if (Node.isParenthesizedExpression(whenTrue)) {
1667
+ whenTrue = whenTrue.getExpression();
1668
+ }
1669
+ if (Node.isParenthesizedExpression(whenFalse)) {
1670
+ whenFalse = whenFalse.getExpression();
1671
+ }
1672
+ const condValue = this.substituteExpression(condition, itemParamName, itemValue);
1673
+ const branch = condValue ? whenTrue : whenFalse;
1674
+ if (Node.isJsxElement(branch) || Node.isJsxSelfClosingElement(branch)) {
1675
+ const jsxChild = this.buildJsxTreeWithSubstitution(branch, itemParamName, itemValue, localComponents, relativeImports);
1676
+ if (jsxChild) children.push(jsxChild);
1677
+ } else {
1678
+ // Non-JSX ternary result - substitute as text
1679
+ const substituted = this.substituteExpression(branch, itemParamName, itemValue);
1680
+ if (substituted !== null && substituted !== undefined && substituted !== '') {
1681
+ children.push({ type: 'text', content: String(substituted) });
1682
+ }
1683
+ }
1684
+ } else {
1685
+ const substituted = this.substituteExpression(expr, itemParamName, itemValue);
1686
+ if (substituted !== null && substituted !== undefined && substituted !== '') {
1687
+ children.push({ type: 'text', content: String(substituted) });
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1692
+ }
1693
+
1694
+ if (/^[A-Z]/.test(rawTagName)) {
1695
+ const evaluatedProps = this.resolveSubstitutionProps(props, itemParamName, itemValue);
1696
+ if (children.length > 0) {
1697
+ evaluatedProps.children = children;
1698
+ }
1699
+
1700
+ const localDef = localComponents.get(rawTagName);
1701
+ if (localDef) {
1702
+ const expandedLocal = this.expandLocalComponentWithProps(
1703
+ localDef,
1704
+ evaluatedProps,
1705
+ localComponents,
1706
+ relativeImports
1707
+ );
1708
+ if (expandedLocal) {
1709
+ return expandedLocal;
1710
+ }
1711
+ }
1712
+
1713
+ const importedFilePath = relativeImports.get(rawTagName);
1714
+ const SKIP_EXPANSION = ['Skeleton', 'SkeletonText'];
1715
+ if (importedFilePath && !SKIP_EXPANSION.includes(rawTagName)) {
1716
+ const resolvedProps = new Map<string, any>();
1717
+ for (const [key, value] of Object.entries(evaluatedProps)) {
1718
+ resolvedProps.set(key, value);
1719
+ }
1720
+ const expandedImported = this.expandImportedComponent(
1721
+ rawTagName,
1722
+ importedFilePath,
1723
+ relativeImports,
1724
+ resolvedProps
1725
+ );
1726
+ if (expandedImported && !this.isUnresolvedExpandedComponent(expandedImported, localComponents, relativeImports)) {
1727
+ return expandedImported;
1728
+ }
1729
+ }
1730
+ }
1731
+
1732
+ const outputProps = { ...props } as Record<string, any>;
1733
+ delete outputProps.children;
1734
+ return {
1735
+ type: 'element',
1736
+ tagName,
1737
+ isComponent: /^[A-Z]/.test(tagName),
1738
+ props: outputProps,
1739
+ children,
1740
+ };
1741
+ } else if (Node.isJsxSelfClosingElement(node)) {
1742
+ let rawTagName = node.getTagNameNode().getText();
1743
+ // Resolve dynamic component names from destructured map items:
1744
+ // e.g. { icon: Icon } where Icon = 'UserIcon' → use 'UserIcon' as the actual tag
1745
+ if (itemParamName === '__destructured__' && /^[A-Z]/.test(rawTagName)
1746
+ && typeof itemValue === 'object' && itemValue !== null && typeof itemValue[rawTagName] === 'string'
1747
+ && /^[A-Z]/.test(itemValue[rawTagName])) {
1748
+ rawTagName = itemValue[rawTagName];
1749
+ }
1750
+ const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
1751
+ const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue);
1752
+ const props = this.extractPropsFromNode(node, rawTagName, itemPropsContext).props;
1753
+
1754
+ // Check for local component expansion
1755
+ const localDef = localComponents.get(rawTagName);
1756
+ if (localDef) {
1757
+ const evaluatedProps = this.resolveSubstitutionProps(props, itemParamName, itemValue);
1758
+
1759
+ // Find the JSX in the component, handling ternary expressions based on props
1760
+ const expandedJsx = this.expandLocalComponentWithProps(localDef, evaluatedProps, localComponents, relativeImports);
1761
+ if (expandedJsx) {
1762
+ return expandedJsx;
1763
+ }
1764
+ }
1765
+
1766
+ // Check for imported component expansion
1767
+ // Skip expansion for simple components that the ui-builder handles directly
1768
+ // (e.g., Skeleton uses clsx with props that we can't statically evaluate)
1769
+ const SKIP_EXPANSION = ['Skeleton', 'SkeletonText'];
1770
+ const importedFilePath = relativeImports.get(rawTagName);
1771
+ if (importedFilePath && !SKIP_EXPANSION.includes(rawTagName)) {
1772
+ const evaluatedProps = this.resolveSubstitutionProps(props, itemParamName, itemValue);
1773
+ const resolvedProps = new Map<string, any>();
1774
+ for (const [key, value] of Object.entries(evaluatedProps)) {
1775
+ resolvedProps.set(key, value);
1776
+ }
1777
+ const expandedJsx = this.expandImportedComponent(rawTagName, importedFilePath, relativeImports, resolvedProps);
1778
+ if (expandedJsx && !this.isUnresolvedExpandedComponent(expandedJsx, localComponents, relativeImports)) {
1779
+ return expandedJsx;
1780
+ }
1781
+ }
1782
+
1783
+ const outputProps = { ...props } as Record<string, any>;
1784
+ delete outputProps.children;
1785
+ return {
1786
+ type: 'element',
1787
+ tagName,
1788
+ isComponent: /^[A-Z]/.test(tagName),
1789
+ props: outputProps,
1790
+ children: [],
1791
+ };
1792
+ }
1793
+
1794
+ return null;
1795
+ }
1796
+
1797
+ /**
1798
+ * Expand a local component with evaluated props.
1799
+ * Handles ternary expressions based on prop values.
1800
+ */
1801
+ private expandLocalComponentWithProps(
1802
+ componentDef: Node,
1803
+ evaluatedProps: Record<string, any>,
1804
+ localComponents: Map<string, Node>,
1805
+ relativeImports: Map<string, string>
1806
+ ): JsxNode | null {
1807
+ // Find return statement or JSX in the component
1808
+ let jsxToExpand: Node | null = null;
1809
+
1810
+ // Look for return statement with JSX or conditional
1811
+ const returnStatements = componentDef.getDescendantsOfKind(SyntaxKind.ReturnStatement);
1812
+ for (const ret of returnStatements) {
1813
+ const expr = ret.getExpression();
1814
+ if (!expr) continue;
1815
+
1816
+ // Handle ternary: return condition ? <JSX1> : <JSX2>
1817
+ if (Node.isConditionalExpression(expr)) {
1818
+ const condition = expr.getCondition();
1819
+ const whenTrue = expr.getWhenTrue();
1820
+ const whenFalse = expr.getWhenFalse();
1821
+
1822
+ // Evaluate the condition based on props
1823
+ const condText = condition.getText();
1824
+ const condValue = evaluatedProps[condText];
1825
+
1826
+ // Choose the appropriate branch
1827
+ let branch = condValue ? whenTrue : whenFalse;
1828
+ // Unwrap parentheses
1829
+ if (Node.isParenthesizedExpression(branch)) {
1830
+ branch = branch.getExpression();
1831
+ }
1832
+
1833
+ if (Node.isJsxElement(branch) || Node.isJsxSelfClosingElement(branch)) {
1834
+ jsxToExpand = branch;
1835
+ break;
1836
+ }
1837
+ }
1838
+
1839
+ // Handle direct JSX return
1840
+ let jsxExpr = expr;
1841
+ if (Node.isParenthesizedExpression(jsxExpr)) {
1842
+ jsxExpr = jsxExpr.getExpression();
1843
+ }
1844
+ if (Node.isJsxElement(jsxExpr) || Node.isJsxSelfClosingElement(jsxExpr)) {
1845
+ jsxToExpand = jsxExpr;
1846
+ break;
1847
+ }
1848
+ }
1849
+
1850
+ // Fallback: find any JSX in the component
1851
+ if (!jsxToExpand) {
1852
+ const jsxElements = componentDef.getDescendantsOfKind(SyntaxKind.JsxElement);
1853
+ const selfClosing = componentDef.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
1854
+ const allJsx = [...jsxElements, ...selfClosing];
1855
+ if (allJsx.length > 0) {
1856
+ jsxToExpand = allJsx[0];
1857
+ for (const el of allJsx) {
1858
+ if (el.getAncestors().length < jsxToExpand.getAncestors().length) {
1859
+ jsxToExpand = el;
1860
+ }
1861
+ }
1862
+ }
1863
+ }
1864
+
1865
+ if (!jsxToExpand) return null;
1866
+
1867
+ const propsMap = new Map<string, any>();
1868
+ for (const [key, value] of Object.entries(evaluatedProps)) {
1869
+ propsMap.set(key, value);
1870
+ }
1871
+
1872
+ const parameterNodes = this.getComponentParameterNodes(componentDef);
1873
+
1874
+ if (parameterNodes.length > 0) {
1875
+ const firstParam = parameterNodes[0];
1876
+ const nameNode = firstParam.getNameNode?.();
1877
+ if (nameNode && Node.isIdentifier(nameNode)) {
1878
+ // Function signature uses a plain `props` identifier.
1879
+ propsMap.set(nameNode.getText(), { ...evaluatedProps });
1880
+ } else if (nameNode && Node.isObjectBindingPattern(nameNode)) {
1881
+ const consumedKeys = new Set<string>();
1882
+ const restBindings: string[] = [];
1883
+
1884
+ for (const element of nameNode.getElements()) {
1885
+ if (element.getDotDotDotToken()) {
1886
+ restBindings.push(element.getName());
1887
+ continue;
1888
+ }
1889
+
1890
+ const sourceKey = element.getPropertyNameNode?.()?.getText() || element.getName();
1891
+ consumedKeys.add(sourceKey);
1892
+ if (Object.prototype.hasOwnProperty.call(evaluatedProps, sourceKey)) {
1893
+ propsMap.set(element.getName(), evaluatedProps[sourceKey]);
1894
+ continue;
1895
+ }
1896
+
1897
+ const defaultInit = this.unwrapStaticValueExpression(element.getInitializer?.());
1898
+ if (!defaultInit) continue;
1899
+
1900
+ const defaultValue = this.resolveExpressionValue(defaultInit, propsMap);
1901
+ if (defaultValue !== undefined) {
1902
+ propsMap.set(element.getName(), defaultValue);
1903
+ continue;
1904
+ }
1905
+
1906
+ if (Node.isStringLiteral(defaultInit) || Node.isNoSubstitutionTemplateLiteral(defaultInit)) {
1907
+ propsMap.set(element.getName(), defaultInit.getLiteralValue());
1908
+ } else if (Node.isNumericLiteral(defaultInit)) {
1909
+ propsMap.set(element.getName(), defaultInit.getLiteralValue());
1910
+ } else {
1911
+ const defaultText = defaultInit.getText();
1912
+ if (defaultText === 'true') propsMap.set(element.getName(), true);
1913
+ if (defaultText === 'false') propsMap.set(element.getName(), false);
1914
+ }
1915
+ }
1916
+
1917
+ if (restBindings.length > 0) {
1918
+ const restObject: Record<string, any> = {};
1919
+ for (const [key, value] of Object.entries(evaluatedProps)) {
1920
+ if (!consumedKeys.has(key)) {
1921
+ restObject[key] = value;
1922
+ }
1923
+ }
1924
+ for (const restBinding of restBindings) {
1925
+ propsMap.set(restBinding, restObject);
1926
+ }
1927
+ }
1928
+ }
1929
+ }
1930
+
1931
+ return this.buildJsxTree(jsxToExpand, localComponents, relativeImports, propsMap);
1932
+ }
1933
+
1934
+ private getComponentParameterNodes(componentDef: Node): any[] {
1935
+ if (typeof (componentDef as any).getParameters === 'function') {
1936
+ return (componentDef as any).getParameters();
1937
+ }
1938
+
1939
+ if (Node.isCallExpression(componentDef)) {
1940
+ for (const arg of componentDef.getArguments()) {
1941
+ if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) {
1942
+ return arg.getParameters();
1943
+ }
1944
+ }
1945
+ }
1946
+
1947
+ return [];
1948
+ }
1949
+
1950
+ /**
1951
+ * Returns true if the expanded node's root is a portal wrapper (renders outside the DOM tree).
1952
+ * Used to skip components like DialogContent, SelectContent, SheetContent, DropdownMenuContent.
1953
+ */
1954
+ private isPortalRootedNode(node: JsxNode): boolean {
1955
+ if (node.type !== 'element') return false;
1956
+ return node.tagName.toLowerCase().includes('portal');
1957
+ }
1958
+
1959
+ private isUnresolvedExpandedComponent(
1960
+ node: JsxNode,
1961
+ localComponents: Map<string, Node>,
1962
+ relativeImports: Map<string, string>
1963
+ ): boolean {
1964
+ if (node.type !== 'element') return false;
1965
+ const el = node as JsxElement;
1966
+ const tagName = el.tagName;
1967
+
1968
+ if (!/^[A-Z]/.test(tagName)) return false;
1969
+ // Namespaced tags like `AccordionPrimitive.Trigger` come from imported
1970
+ // third-party primitives. Keep expanded trees in this case so nested
1971
+ // children (e.g. icons/text) are not dropped.
1972
+ if (tagName.includes('.')) return false;
1973
+ if (Object.prototype.hasOwnProperty.call(COMPONENT_TO_HTML_MAP, tagName)) return false;
1974
+ if (localComponents.has(tagName)) return false;
1975
+ if (relativeImports.has(tagName)) return false;
1976
+
1977
+ const children = el.children || [];
1978
+ if (children.length === 0) return true;
1979
+
1980
+ // Generic pass-through wrapper heuristic:
1981
+ // unresolved uppercase tag + one rendered child + no visual class/style props.
1982
+ // This catches dynamic aliases like `const Comp = asChild ? Slot : "button"`
1983
+ // where static expansion cannot resolve the className expression reliably.
1984
+ const props = el.props || {};
1985
+ const className = typeof props.className === 'string' ? props.className.trim() : '';
1986
+ const styleProp = typeof props.style === 'string' ? props.style.trim() : '';
1987
+ const hasVisualProps = className.length > 0 || styleProp.length > 0;
1988
+ const hasSingleChild = children.length === 1;
1989
+ if (!hasVisualProps && hasSingleChild) {
1990
+ return true;
1991
+ }
1992
+
1993
+ return false;
1994
+ }
1995
+
1996
+ /**
1997
+ * Substitute an expression with values from the map item.
1998
+ * Handles: feature.name, feature.description, feature.free, etc.
1999
+ */
2000
+ private substituteExpression(expr: Node, itemParamName: string, itemValue: any): any {
2001
+ const text = expr.getText();
2002
+
2003
+ // Handle property access: feature.name, feature.description
2004
+ if (Node.isPropertyAccessExpression(expr)) {
2005
+ const objExpr = expr.getExpression();
2006
+ const propName = expr.getName();
2007
+
2008
+ if (objExpr.getText() === itemParamName && typeof itemValue === 'object') {
2009
+ return itemValue[propName];
2010
+ }
2011
+ }
2012
+
2013
+ // Handle simple variable reference: feature (if it's the whole item)
2014
+ if (Node.isIdentifier(expr) && text === itemParamName) {
2015
+ return typeof itemValue === 'object' ? JSON.stringify(itemValue) : itemValue;
2016
+ }
2017
+
2018
+ // Handle destructured variable reference: {label}, {shortcut}, etc.
2019
+ // When itemParamName === '__destructured__', item is already keyed by local name.
2020
+ if (itemParamName === '__destructured__' && Node.isIdentifier(expr) && typeof itemValue === 'object' && itemValue !== null) {
2021
+ const val = itemValue[text];
2022
+ if (val !== undefined) return val;
2023
+ }
2024
+
2025
+ // Handle conditional: feature.description && <p>...</p>
2026
+ if (Node.isBinaryExpression(expr)) {
2027
+ const left = expr.getLeft();
2028
+ const operator = expr.getOperatorToken().getText();
2029
+ const right = expr.getRight();
2030
+
2031
+ if (operator === '&&') {
2032
+ const leftValue = this.substituteExpression(left, itemParamName, itemValue);
2033
+ if (leftValue) {
2034
+ // If right side is JSX, we'd need to render it - for now just return text
2035
+ if (Node.isJsxElement(right) || Node.isJsxSelfClosingElement(right)) {
2036
+ // Get text content from the JSX
2037
+ const textNodes = right.getDescendantsOfKind(SyntaxKind.JsxText);
2038
+ const exprNodes = right.getDescendantsOfKind(SyntaxKind.JsxExpression);
2039
+ let textContent = '';
2040
+ for (const textNode of textNodes) {
2041
+ textContent += decodeHtmlEntities(textNode.getText().trim()) + ' ';
2042
+ }
2043
+ for (const exprNode of exprNodes) {
2044
+ const innerExpr = exprNode.getExpression();
2045
+ if (innerExpr) {
2046
+ const val = this.substituteExpression(innerExpr, itemParamName, itemValue);
2047
+ if (val !== null && val !== undefined) {
2048
+ textContent += String(val) + ' ';
2049
+ }
2050
+ }
2051
+ }
2052
+ return textContent.trim();
2053
+ }
2054
+ return this.substituteExpression(right, itemParamName, itemValue);
2055
+ }
2056
+ return null;
2057
+ }
2058
+ }
2059
+
2060
+ // Handle ternary: feature.free ? <Check /> : <Cross />
2061
+ if (Node.isConditionalExpression(expr)) {
2062
+ const condition = expr.getCondition();
2063
+ const condValue = this.substituteExpression(condition, itemParamName, itemValue);
2064
+ // Return indication of which branch (for components like Check/Cross)
2065
+ if (condValue) {
2066
+ return 'true';
2067
+ } else {
2068
+ return 'false';
2069
+ }
2070
+ }
2071
+
2072
+ // Handle string literal
2073
+ if (Node.isStringLiteral(expr)) {
2074
+ return expr.getLiteralValue();
2075
+ }
2076
+
2077
+ return null;
2078
+ }
2079
+
2080
+ /**
2081
+ * Expand an imported component by reading its source file and extracting JSX.
2082
+ * Always expands the component structure - the plugin will handle className merging.
2083
+ * @param resolvedProps - Props resolved from the story file (e.g., arrays passed to the component)
2084
+ */
2085
+ private expandImportedComponent(
2086
+ componentName: string,
2087
+ filePath: string,
2088
+ relativeImports: Map<string, string>,
2089
+ resolvedProps: Map<string, any> = new Map()
2090
+ ): JsxNode | null {
2091
+ try {
2092
+ // Check cache first
2093
+ const cacheKey = `${filePath}:${componentName}`;
2094
+ let sourceFile: SourceFile;
2095
+
2096
+ if (this.importedComponentCache.has(cacheKey)) {
2097
+ const cached = this.importedComponentCache.get(cacheKey)!;
2098
+ sourceFile = cached.sourceFile;
2099
+ } else {
2100
+ // Add the source file to the project
2101
+ sourceFile = this.project.addSourceFileAtPath(filePath);
2102
+ this.importedComponentCache.set(cacheKey, { sourceFile, componentName });
2103
+ }
2104
+
2105
+ // Collect icon imports from the component file and merge into main registry
2106
+ const componentIcons = this.collectReactIconImports(sourceFile);
2107
+ for (const [name, spec] of componentIcons.entries()) {
2108
+ this.iconImports.set(name, spec);
2109
+ }
2110
+
2111
+ // Always expand - the plugin will handle className merging with component definitions
2112
+ return this.extractComponentJsx(sourceFile, componentName, relativeImports, resolvedProps);
2113
+ } catch (err) {
2114
+ console.warn(`Failed to expand imported component ${componentName} from ${filePath}:`, err);
2115
+ return null;
2116
+ }
2117
+ }
2118
+
2119
+ /**
2120
+ * Extract JSX from a component function in a source file.
2121
+ * @param resolvedProps - Props resolved from the caller (e.g., arrays passed from story)
2122
+ */
2123
+ private extractComponentJsx(
2124
+ sourceFile: SourceFile,
2125
+ componentName: string,
2126
+ relativeImports: Map<string, string>,
2127
+ resolvedProps: Map<string, any> = new Map()
2128
+ ): JsxNode | null {
2129
+ const localComponents = this.collectLocalComponentsFromFile(sourceFile);
2130
+ const mergedRelativeImports = new Map(relativeImports);
2131
+ for (const [name, filePath] of this.collectComponentImports(sourceFile)) {
2132
+ mergedRelativeImports.set(name, filePath);
2133
+ }
2134
+
2135
+ // Find the component function (exported function or const)
2136
+ // Look for: export function ComponentName() { ... }
2137
+ for (const func of sourceFile.getFunctions()) {
2138
+ if (func.getName() === componentName) {
2139
+ const evaluatedProps = Object.fromEntries(resolvedProps.entries());
2140
+ const expanded = this.expandLocalComponentWithProps(
2141
+ func,
2142
+ evaluatedProps,
2143
+ localComponents,
2144
+ mergedRelativeImports
2145
+ );
2146
+ if (expanded) {
2147
+ return expanded;
2148
+ }
2149
+
2150
+ const jsxElements = func.getDescendantsOfKind(SyntaxKind.JsxElement);
2151
+ const selfClosing = func.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
2152
+ const allJsx = [...jsxElements, ...selfClosing];
2153
+
2154
+ if (allJsx.length > 0) {
2155
+ let rootJsx = allJsx[0];
2156
+ for (const el of allJsx) {
2157
+ if (el.getAncestors().length < rootJsx.getAncestors().length) {
2158
+ rootJsx = el;
2159
+ }
2160
+ }
2161
+ return this.buildJsxTree(rootJsx, localComponents, mergedRelativeImports, resolvedProps);
2162
+ }
2163
+ }
2164
+ }
2165
+
2166
+ // Look for: export const ComponentName = () => { ... } or forwardRef
2167
+ for (const varStmt of sourceFile.getVariableStatements()) {
2168
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
2169
+ if (decl.getName() === componentName) {
2170
+ const init = decl.getInitializer();
2171
+ if (init) {
2172
+ const evaluatedProps = Object.fromEntries(resolvedProps.entries());
2173
+ const expanded = this.expandLocalComponentWithProps(
2174
+ init,
2175
+ evaluatedProps,
2176
+ localComponents,
2177
+ mergedRelativeImports
2178
+ );
2179
+ if (expanded) {
2180
+ return expanded;
2181
+ }
2182
+
2183
+ const jsxElements = init.getDescendantsOfKind(SyntaxKind.JsxElement);
2184
+ const selfClosing = init.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
2185
+ const allJsx = [...jsxElements, ...selfClosing];
2186
+
2187
+ if (allJsx.length > 0) {
2188
+ let rootJsx = allJsx[0];
2189
+ for (const el of allJsx) {
2190
+ if (el.getAncestors().length < rootJsx.getAncestors().length) {
2191
+ rootJsx = el;
2192
+ }
2193
+ }
2194
+ return this.buildJsxTree(rootJsx, localComponents, mergedRelativeImports, resolvedProps);
2195
+ }
2196
+ }
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ // Follow barrel re-exports: `export * from "./hero"` or `export { Hero } from "./hero"`
2202
+ const fileDir = path.dirname(sourceFile.getFilePath());
2203
+ for (const exportDecl of sourceFile.getExportDeclarations()) {
2204
+ const moduleSpec = exportDecl.getModuleSpecifierValue();
2205
+ if (!moduleSpec) continue;
2206
+ const namedExports = exportDecl.getNamedExports();
2207
+ // Only follow if it's a wildcard export OR exports the specific component name
2208
+ const isWildcard = namedExports.length === 0;
2209
+ const exportsComponent = namedExports.some(
2210
+ (ne) => (ne.getAliasNode()?.getText() || ne.getName()) === componentName
2211
+ );
2212
+ if (!isWildcard && !exportsComponent) continue;
2213
+ const resolvedPath = this.resolveImportedComponentPath(fileDir, moduleSpec);
2214
+ if (!resolvedPath) continue;
2215
+ try {
2216
+ const reExportedFile = this.project.addSourceFileAtPath(resolvedPath);
2217
+ const result = this.extractComponentJsx(reExportedFile, componentName, relativeImports, resolvedProps);
2218
+ if (result) return result;
2219
+ } catch {
2220
+ // ignore unresolvable re-exports
2221
+ }
2222
+ }
2223
+
2224
+ return null;
2225
+ }
2226
+
2227
+ /**
2228
+ * Extract props from a JsxOpeningElement
2229
+ */
2230
+ private extractInstanceFromJsxOpening(
2231
+ opening: Node,
2232
+ componentName: string
2233
+ ): ComponentInstance {
2234
+ return this.extractPropsFromNode(opening, componentName);
2235
+ }
2236
+
2237
+ /**
2238
+ * Extract props from a JsxSelfClosingElement
2239
+ */
2240
+ private extractInstanceFromSelfClosing(
2241
+ el: Node,
2242
+ componentName: string
2243
+ ): ComponentInstance {
2244
+ return this.extractPropsFromNode(el, componentName);
2245
+ }
2246
+
2247
+ /**
2248
+ * Parse an ObjectLiteralExpression AST node into a plain JS object.
2249
+ * Handles string, number, boolean, nested object, and array values.
2250
+ */
2251
+ private parseObjectLiteralExpression(expr: Node): Record<string, any> {
2252
+ const result: Record<string, any> = {};
2253
+ for (const prop of (expr as any).getProperties?.() ?? []) {
2254
+ if (!Node.isPropertyAssignment(prop)) continue;
2255
+ const key = (prop as any).getName?.();
2256
+ if (!key) continue;
2257
+ const val = (prop as any).getInitializer?.();
2258
+ if (!val) continue;
2259
+ result[key] = this.parseLiteralValue(val);
2260
+ }
2261
+ return result;
2262
+ }
2263
+
2264
+ /**
2265
+ * Parse a literal AST node into a plain JS value (string, number, boolean, array, object).
2266
+ */
2267
+ private parseLiteralValue(val: Node): any {
2268
+ const unwrapped = this.unwrapStaticValueExpression(val);
2269
+ if (unwrapped && unwrapped !== val) {
2270
+ return this.parseLiteralValue(unwrapped);
2271
+ }
2272
+ if (Node.isStringLiteral(val)) return val.getLiteralValue();
2273
+ if (Node.isNumericLiteral(val)) return val.getLiteralValue();
2274
+ const text = val.getText();
2275
+ if (text === 'true') return true;
2276
+ if (text === 'false') return false;
2277
+ if (Node.isArrayLiteralExpression(val)) {
2278
+ return (val as any).getElements().map((el: Node) => this.parseLiteralValue(el));
2279
+ }
2280
+ if (Node.isObjectLiteralExpression(val)) {
2281
+ return this.parseObjectLiteralExpression(val);
2282
+ }
2283
+ return text;
2284
+ }
2285
+
2286
+ private coerceBoolean(value: any): boolean | null {
2287
+ if (typeof value === 'boolean') return value;
2288
+ if (typeof value === 'number') return value !== 0;
2289
+ if (typeof value === 'string') {
2290
+ const normalized = value.trim().toLowerCase();
2291
+ if (normalized === 'true') return true;
2292
+ if (normalized === 'false') return false;
2293
+ if (normalized === '1') return true;
2294
+ if (normalized === '0') return false;
2295
+ if (normalized.length > 0) return true;
2296
+ return false;
2297
+ }
2298
+ return null;
2299
+ }
2300
+
2301
+ private resolveExpressionValue(expr: Node, propsContext: Map<string, any>): any {
2302
+ if (Node.isParenthesizedExpression(expr)) {
2303
+ return this.resolveExpressionValue(expr.getExpression(), propsContext);
2304
+ }
2305
+ if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
2306
+ return expr.getLiteralValue();
2307
+ }
2308
+ if (Node.isNumericLiteral(expr)) {
2309
+ return expr.getLiteralValue();
2310
+ }
2311
+ const exprText = expr.getText();
2312
+ if (exprText === 'true') return true;
2313
+ if (exprText === 'false') return false;
2314
+ if (Node.isIdentifier(expr)) {
2315
+ if (propsContext.has(exprText)) return propsContext.get(exprText);
2316
+ // Resolve module-level const string (e.g. const navLinkCls = "...")
2317
+ const varDecl = expr.getSourceFile().getVariableDeclaration(exprText);
2318
+ if (varDecl) {
2319
+ const init = varDecl.getInitializer();
2320
+ if (init && Node.isStringLiteral(init)) return init.getLiteralValue();
2321
+ }
2322
+ return undefined;
2323
+ }
2324
+ if (Node.isPropertyAccessExpression(expr)) {
2325
+ const baseExpr = expr.getExpression();
2326
+ const propName = expr.getName();
2327
+ if (Node.isIdentifier(baseExpr) && propsContext.has(baseExpr.getText())) {
2328
+ const baseValue = propsContext.get(baseExpr.getText());
2329
+ if (baseValue && typeof baseValue === 'object' && propName in baseValue) {
2330
+ return (baseValue as any)[propName];
2331
+ }
2332
+ }
2333
+ return undefined;
2334
+ }
2335
+ if (Node.isConditionalExpression(expr)) {
2336
+ const conditionValue = this.resolveExpressionValue(expr.getCondition(), propsContext);
2337
+ const conditionBool = this.coerceBoolean(conditionValue);
2338
+ if (conditionBool == null) return undefined;
2339
+ return this.resolveExpressionValue(
2340
+ conditionBool ? expr.getWhenTrue() : expr.getWhenFalse(),
2341
+ propsContext
2342
+ );
2343
+ }
2344
+ if (Node.isBinaryExpression(expr)) {
2345
+ const op = expr.getOperatorToken().getText();
2346
+ if (op === '===' || op === '==') {
2347
+ const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
2348
+ const right = this.resolveExpressionValue(expr.getRight(), propsContext);
2349
+ if (left === undefined || right === undefined) return undefined;
2350
+ return left === right;
2351
+ }
2352
+ if (op === '!==' || op === '!=') {
2353
+ const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
2354
+ const right = this.resolveExpressionValue(expr.getRight(), propsContext);
2355
+ if (left === undefined || right === undefined) return undefined;
2356
+ return left !== right;
2357
+ }
2358
+ if (op === '&&') {
2359
+ const left = this.coerceBoolean(this.resolveExpressionValue(expr.getLeft(), propsContext));
2360
+ if (left === false) return false;
2361
+ if (left === true) return this.resolveExpressionValue(expr.getRight(), propsContext);
2362
+ return undefined;
2363
+ }
2364
+ if (op === '||') {
2365
+ const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
2366
+ const leftBool = this.coerceBoolean(leftValue);
2367
+ if (leftBool === true) return leftValue;
2368
+ if (leftBool === false) return this.resolveExpressionValue(expr.getRight(), propsContext);
2369
+ return undefined;
2370
+ }
2371
+ if (op === '+') {
2372
+ const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
2373
+ const right = this.resolveExpressionValue(expr.getRight(), propsContext);
2374
+ if (left === undefined || right === undefined) return undefined;
2375
+ return String(left) + String(right);
2376
+ }
2377
+ }
2378
+ return undefined;
2379
+ }
2380
+
2381
+ private resolveClassNameExpression(expr: Node, propsContext: Map<string, any>): string {
2382
+ if (Node.isParenthesizedExpression(expr)) {
2383
+ return this.resolveClassNameExpression(expr.getExpression(), propsContext);
2384
+ }
2385
+ if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
2386
+ return expr.getLiteralValue();
2387
+ }
2388
+ if (Node.isTemplateExpression(expr)) {
2389
+ // Use compilerNode.text to get the raw literal content of template head/middle/tail
2390
+ const parts: string[] = [(expr.getHead().compilerNode as any).text ?? ''];
2391
+ for (const span of expr.getTemplateSpans()) {
2392
+ parts.push(this.resolveClassNameExpression(span.getExpression(), propsContext));
2393
+ parts.push((span.getLiteral().compilerNode as any).text ?? '');
2394
+ }
2395
+ return parts.join(' ').trim().replace(/\s+/g, ' ');
2396
+ }
2397
+ if (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) {
2398
+ const resolved = this.resolveExpressionValue(expr, propsContext);
2399
+ return typeof resolved === 'string' ? resolved : '';
2400
+ }
2401
+ if (Node.isConditionalExpression(expr)) {
2402
+ const conditionValue = this.resolveExpressionValue(expr.getCondition(), propsContext);
2403
+ const conditionBool = this.coerceBoolean(conditionValue);
2404
+ if (conditionBool === true) return this.resolveClassNameExpression(expr.getWhenTrue(), propsContext);
2405
+ if (conditionBool === false) return this.resolveClassNameExpression(expr.getWhenFalse(), propsContext);
2406
+ const fallbackClasses: string[] = [];
2407
+ this.extractClassesFromExpression(expr, fallbackClasses);
2408
+ return [...new Set(fallbackClasses)].join(' ');
2409
+ }
2410
+ if (Node.isBinaryExpression(expr)) {
2411
+ const op = expr.getOperatorToken().getText();
2412
+ if (op === '&&') {
2413
+ const leftBool = this.coerceBoolean(this.resolveExpressionValue(expr.getLeft(), propsContext));
2414
+ if (leftBool === false) return '';
2415
+ if (leftBool === true) return this.resolveClassNameExpression(expr.getRight(), propsContext);
2416
+ return '';
2417
+ }
2418
+ if (op === '||') {
2419
+ const left = this.resolveClassNameExpression(expr.getLeft(), propsContext);
2420
+ if (left.trim()) return left;
2421
+ return this.resolveClassNameExpression(expr.getRight(), propsContext);
2422
+ }
2423
+ if (op === '+') {
2424
+ const left = this.resolveClassNameExpression(expr.getLeft(), propsContext);
2425
+ const right = this.resolveClassNameExpression(expr.getRight(), propsContext);
2426
+ return `${left} ${right}`.trim();
2427
+ }
2428
+ }
2429
+ if (Node.isArrayLiteralExpression(expr)) {
2430
+ const parts: string[] = [];
2431
+ for (const element of expr.getElements()) {
2432
+ const value = this.resolveClassNameExpression(element, propsContext).trim();
2433
+ if (value) parts.push(value);
2434
+ }
2435
+ return parts.join(' ').trim();
2436
+ }
2437
+ if (Node.isObjectLiteralExpression(expr)) {
2438
+ const enabled: string[] = [];
2439
+ for (const prop of expr.getProperties()) {
2440
+ if (!Node.isPropertyAssignment(prop)) continue;
2441
+ const keyNode = prop.getNameNode();
2442
+ const className = Node.isStringLiteral(keyNode) ? keyNode.getLiteralValue() : keyNode.getText();
2443
+ const init = prop.getInitializer();
2444
+ if (!init) continue;
2445
+ const boolValue = this.coerceBoolean(this.resolveExpressionValue(init, propsContext));
2446
+ if (boolValue === true) enabled.push(className);
2447
+ }
2448
+ return enabled.join(' ');
2449
+ }
2450
+ if (Node.isCallExpression(expr)) {
2451
+ const callee = expr.getExpression().getText();
2452
+ if (['cn', 'clsx', 'cva', 'twMerge'].includes(callee)) {
2453
+ const parts: string[] = [];
2454
+ for (const arg of expr.getArguments()) {
2455
+ const value = this.resolveClassNameExpression(arg, propsContext).trim();
2456
+ if (value) parts.push(value);
2457
+ }
2458
+ return parts.join(' ').trim();
2459
+ }
2460
+ }
2461
+ return '';
2462
+ }
2463
+
2464
+ /**
2465
+ * Extract props from a JSX node (opening element or self-closing element).
2466
+ * Uses getDescendantsOfKind to safely get only JsxAttribute nodes,
2467
+ * filtering to direct attributes only (depth 1 from the JsxAttributes container).
2468
+ */
2469
+ private extractPropsFromNode(
2470
+ jsxNode: Node,
2471
+ componentName: string,
2472
+ propsContext: Map<string, any> = new Map()
2473
+ ): ComponentInstance {
2474
+ const props: Record<string, any> = {};
2475
+
2476
+ const directAttributes =
2477
+ typeof (jsxNode as any).getAttributes === 'function'
2478
+ ? ((jsxNode as any).getAttributes() as Node[])
2479
+ : [];
2480
+ const attributes = directAttributes.length > 0
2481
+ ? directAttributes
2482
+ : (jsxNode.getChildrenOfKind(SyntaxKind.JsxAttributes)[0]?.getChildren().filter(
2483
+ child => Node.isJsxAttribute(child) || Node.isJsxSpreadAttribute(child)
2484
+ ) ?? []);
2485
+
2486
+ if (attributes.length === 0) return { componentName, props };
2487
+
2488
+ for (const attr of attributes) {
2489
+ if (Node.isJsxSpreadAttribute(attr)) {
2490
+ const expr = attr.getExpression();
2491
+ if (!expr) continue;
2492
+ const resolved = this.resolveExpressionValue(expr, propsContext);
2493
+ if (resolved && typeof resolved === 'object' && !Array.isArray(resolved)) {
2494
+ for (const [key, value] of Object.entries(resolved)) {
2495
+ props[key] = value;
2496
+ }
2497
+ }
2498
+ continue;
2499
+ }
2500
+
2501
+ const nameNode = (attr as any).getNameNode?.();
2502
+ if (!nameNode) continue;
2503
+ const name = nameNode.getText();
2504
+ const init = attr.getInitializer();
2505
+ if (name === 'className') {
2506
+ if (init && Node.isStringLiteral(init)) {
2507
+ props[name] = init.getLiteralValue();
2508
+ } else if (init && Node.isJsxExpression(init)) {
2509
+ const expr = init.getExpression();
2510
+ if (expr && Node.isStringLiteral(expr)) {
2511
+ props[name] = expr.getLiteralValue();
2512
+ } else if (expr) {
2513
+ const resolved = this.resolveClassNameExpression(expr, propsContext);
2514
+ if (resolved) {
2515
+ props[name] = resolved;
2516
+ }
2517
+ }
2518
+ }
2519
+ continue;
2520
+ }
2521
+
2522
+ if (!init) {
2523
+ props[name] = 'true';
2524
+ } else if (Node.isStringLiteral(init)) {
2525
+ props[name] = init.getLiteralValue();
2526
+ } else if (Node.isJsxExpression(init)) {
2527
+ const expr = init.getExpression();
2528
+ if (expr && Node.isStringLiteral(expr)) {
2529
+ props[name] = expr.getLiteralValue();
2530
+ } else if (expr && Node.isObjectLiteralExpression(expr)) {
2531
+ props[name] = this.parseObjectLiteralExpression(expr);
2532
+ } else if (expr && Node.isArrayLiteralExpression(expr)) {
2533
+ props[name] = this.parseLiteralValue(expr);
2534
+ } else if (expr) {
2535
+ const resolved = this.resolveExpressionValue(expr, propsContext);
2536
+ props[name] = resolved !== undefined ? resolved : expr.getText();
2537
+ }
2538
+ }
2539
+ }
2540
+
2541
+ return { componentName, props };
2542
+ }
2543
+
2544
+ public getIconRegistry(): Record<string, IconImportSpec> {
2545
+ const out: Record<string, IconImportSpec> = {};
2546
+ for (const [key, value] of this.iconImports.entries()) {
2547
+ out[key] = value;
2548
+ }
2549
+ return out;
2550
+ }
2551
+
2552
+ private collectReactIconImports(sourceFile: SourceFile): Map<string, IconImportSpec> {
2553
+ const imports = new Map<string, IconImportSpec>();
2554
+ const iconPackages = this.config.iconPackages || ['lucide-react', 'react-icons/'];
2555
+ for (const decl of sourceFile.getImportDeclarations()) {
2556
+ const moduleSpec = decl.getModuleSpecifierValue();
2557
+ if (!moduleSpec) continue;
2558
+ const isIconModule = iconPackages.some(pkg =>
2559
+ pkg.endsWith('/') ? moduleSpec.startsWith(pkg) : moduleSpec === pkg
2560
+ );
2561
+ if (!isIconModule) continue;
2562
+ for (const named of decl.getNamedImports()) {
2563
+ const exportName = named.getNameNode().getText();
2564
+ const aliasNode = named.getAliasNode();
2565
+ const localName = aliasNode ? aliasNode.getText() : exportName;
2566
+ imports.set(localName, { module: moduleSpec, exportName });
2567
+ }
2568
+ }
2569
+ return imports;
2570
+ }
2571
+
2572
+ private collectIconUsage(node: JsxNode | undefined, iconImports: Map<string, IconImportSpec>): void {
2573
+ if (!node) return;
2574
+ if (node.type === 'element') {
2575
+ const el = node as JsxElement;
2576
+ const icon = iconImports.get(el.tagName);
2577
+ if (icon) {
2578
+ this.iconImports.set(el.tagName, icon);
2579
+ }
2580
+ for (const child of el.children || []) {
2581
+ this.collectIconUsage(child, iconImports);
2582
+ }
2583
+ }
2584
+ }
2585
+
2586
+ // ===========================================================================
2587
+ // Helper Methods
2588
+ // ===========================================================================
2589
+
2590
+ /**
2591
+ * Find all component declarations in a file
2592
+ */
2593
+ private findComponentDeclarations(sourceFile: SourceFile): Array<{ name: string; node: Node }> {
2594
+ const components: Array<{ name: string; node: Node }> = [];
2595
+
2596
+ // Find variable statements that export components
2597
+ const variableStatements = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement);
2598
+
2599
+ for (const statement of variableStatements) {
2600
+ const declarations = statement.getDeclarationList().getDeclarations();
2601
+ for (const decl of declarations) {
2602
+ const name = decl.getName();
2603
+ // Component names start with uppercase
2604
+ if (/^[A-Z]/.test(name)) {
2605
+ components.push({ name, node: decl });
2606
+ }
2607
+ }
2608
+ }
2609
+
2610
+ return components;
2611
+ }
2612
+
2613
+ /**
2614
+ * Extract Tailwind classes from all string-like expressions in a node.
2615
+ * Handles: string literals, template literals (with and without interpolation),
2616
+ * ternary expressions with class strings, and clsx/cn/cva calls.
2617
+ */
2618
+ private extractClassesFromNode(node: Node): string[] {
2619
+ const classes: string[] = [];
2620
+ this.collectClassesFromNode(node, classes);
2621
+ return [...new Set(classes)];
2622
+ }
2623
+
2624
+ /**
2625
+ * Extract all Tailwind classes from the entire source file
2626
+ */
2627
+ private extractAllClassesFromFile(sourceFile: SourceFile): string[] {
2628
+ const classes: string[] = [];
2629
+ this.collectClassesFromNode(sourceFile, classes);
2630
+ return [...new Set(classes)];
2631
+ }
2632
+
2633
+ /**
2634
+ * Recursively collect Tailwind classes from a node and all its descendants.
2635
+ * Extracts from:
2636
+ * - String literals
2637
+ * - Template literals (no interpolation)
2638
+ * - Template expressions (head + spans - extracts static parts + ternary branches)
2639
+ * - clsx() / cn() call arguments
2640
+ */
2641
+ private collectClassesFromNode(node: Node, classes: string[]): void {
2642
+ const CLASS_UTIL_NAMES = new Set(['cn', 'clsx', 'cva', 'twMerge', 'tv', 'cx', 'ctl']);
2643
+
2644
+ // 1. className / class JSX attributes
2645
+ const jsxAttrs = node.getDescendantsOfKind(SyntaxKind.JsxAttribute);
2646
+ for (const attr of jsxAttrs) {
2647
+ const nameNode = attr.getNameNode();
2648
+ const attrName = Node.isIdentifier(nameNode) ? nameNode.getText() : '';
2649
+ if (attrName !== 'className' && attrName !== 'class') continue;
2650
+ const initializer = attr.getInitializer();
2651
+ if (!initializer) continue;
2652
+ if (Node.isStringLiteral(initializer)) {
2653
+ const value = initializer.getLiteralValue();
2654
+ classes.push(...value.split(/\s+/).filter(Boolean));
2655
+ } else if (Node.isJsxExpression(initializer)) {
2656
+ const expr = initializer.getExpression();
2657
+ if (expr) this.extractClassesFromExpression(expr, classes);
2658
+ }
2659
+ }
2660
+
2661
+ // 2. cn() / clsx() / cva() / twMerge() call arguments
2662
+ const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
2663
+ for (const call of calls) {
2664
+ const expr = call.getExpression();
2665
+ const fnName = Node.isIdentifier(expr) ? expr.getText() :
2666
+ Node.isPropertyAccessExpression(expr) ? expr.getName() : '';
2667
+ if (!CLASS_UTIL_NAMES.has(fnName)) continue;
2668
+ for (const arg of call.getArguments()) {
2669
+ this.extractClassesFromExpression(arg, classes);
2670
+ }
2671
+ }
2672
+
2673
+ // 3. NoSubstitutionTemplateLiteral directly assigned to className (caught via JSX above),
2674
+ // but also handle bare template literals inside class utility calls (already covered by
2675
+ // extractClassesFromExpression). Nothing extra needed here.
2676
+ }
2677
+
2678
+ /**
2679
+ * Extract Tailwind classes from an expression node.
2680
+ * Follows ternary branches and collects string literals from both sides.
2681
+ */
2682
+ private extractClassesFromExpression(expr: Node, classes: string[]): void {
2683
+ // Ternary (ConditionalExpression): condition ? "classes-a" : "classes-b"
2684
+ if (Node.isConditionalExpression(expr)) {
2685
+ this.extractClassesFromExpression(expr.getWhenTrue(), classes);
2686
+ this.extractClassesFromExpression(expr.getWhenFalse(), classes);
2687
+ return;
2688
+ }
2689
+
2690
+ // String literal value
2691
+ if (Node.isStringLiteral(expr)) {
2692
+ const value = expr.getLiteralValue();
2693
+ if (this.looksLikeTailwindClasses(value)) {
2694
+ classes.push(...value.split(/\s+/).filter(Boolean));
2695
+ }
2696
+ return;
2697
+ }
2698
+
2699
+ // Template literal without interpolation
2700
+ if (Node.isNoSubstitutionTemplateLiteral(expr)) {
2701
+ const value = expr.getLiteralValue();
2702
+ if (this.looksLikeTailwindClasses(value)) {
2703
+ classes.push(...value.split(/\s+/).filter(Boolean));
2704
+ }
2705
+ return;
2706
+ }
2707
+
2708
+ // Template literal with ${} interpolations: `flex ${condition ? 'gap-4' : 'gap-2'}`
2709
+ if (Node.isTemplateExpression(expr)) {
2710
+ const headText = expr.getHead().getLiteralText();
2711
+ if (headText.trim()) classes.push(...headText.split(/\s+/).filter(Boolean));
2712
+ for (const span of expr.getTemplateSpans()) {
2713
+ const tailText = span.getLiteral().getLiteralText();
2714
+ if (tailText.trim()) classes.push(...tailText.split(/\s+/).filter(Boolean));
2715
+ this.extractClassesFromExpression(span.getExpression(), classes);
2716
+ }
2717
+ return;
2718
+ }
2719
+
2720
+ // Parenthesized expression: unwrap
2721
+ if (Node.isParenthesizedExpression(expr)) {
2722
+ this.extractClassesFromExpression(expr.getExpression(), classes);
2723
+ return;
2724
+ }
2725
+
2726
+ // Binary expression with + concatenation: "class-a " + variable
2727
+ if (Node.isBinaryExpression(expr)) {
2728
+ this.extractClassesFromExpression(expr.getLeft(), classes);
2729
+ this.extractClassesFromExpression(expr.getRight(), classes);
2730
+ return;
2731
+ }
2732
+
2733
+ // Call expression: cn(...), clsx(...) - extract string arguments
2734
+ if (Node.isCallExpression(expr)) {
2735
+ const callee = expr.getExpression().getText();
2736
+ if (['cn', 'clsx', 'cva', 'twMerge'].includes(callee)) {
2737
+ for (const arg of expr.getArguments()) {
2738
+ this.extractClassesFromExpression(arg, classes);
2739
+ }
2740
+ }
2741
+ return;
2742
+ }
2743
+
2744
+ // Array literal: [condition && "class", "class-b"]
2745
+ if (Node.isArrayLiteralExpression(expr)) {
2746
+ for (const element of expr.getElements()) {
2747
+ this.extractClassesFromExpression(element, classes);
2748
+ }
2749
+ return;
2750
+ }
2751
+
2752
+ // Logical AND: condition && "classes"
2753
+ if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '&&') {
2754
+ this.extractClassesFromExpression(expr.getRight(), classes);
2755
+ return;
2756
+ }
2757
+ }
2758
+
2759
+ /**
2760
+ * Check if a string looks like it contains Tailwind classes
2761
+ */
2762
+ private looksLikeTailwindClasses(value: string): boolean {
2763
+ if (!value || value.length < 2) return false;
2764
+
2765
+ // Common Tailwind utility patterns (applied to the raw word after optional modifier stripping)
2766
+ const tailwindPatterns = [
2767
+ // Layout
2768
+ /^(flex|grid|block|inline|hidden|table|contents)/,
2769
+ /^(columns-|col-span-|row-span-)/,
2770
+ // Flexbox / Grid alignment
2771
+ /^(items-|justify-|self-|place-|content-|order-)/,
2772
+ // Spacing
2773
+ /^(bg-|text-|border-|rounded|shadow|ring-)/,
2774
+ /^(p-|px-|py-|pt-|pb-|pl-|pr-|ps-|pe-)/,
2775
+ /^(m-|mx-|my-|mt-|mb-|ml-|mr-|ms-|me-)/,
2776
+ // Sizing
2777
+ /^(w-|h-|min-w-|min-h-|max-w-|max-h-|size-)/,
2778
+ // Spacing between children
2779
+ /^(gap-|space-)/,
2780
+ // Typography
2781
+ /^(font-|leading-|tracking-|text-|truncate|line-clamp-)/,
2782
+ // Positioning
2783
+ /^(absolute|relative|fixed|sticky|static|inset-|top-|right-|bottom-|left-)/,
2784
+ // Overflow / Visibility
2785
+ /^(overflow-|z-|opacity-|visible|invisible|collapse)/,
2786
+ // Transforms / Transitions / Animations
2787
+ /^(transform|translate-|rotate-|scale-|skew-|origin-)/,
2788
+ /^(transition-|duration-|ease-|delay-|animate-)/,
2789
+ // Cursor / Pointer
2790
+ /^(cursor-|pointer-events-|select-|touch-|scroll-)/,
2791
+ // Aspect ratio / Object fit
2792
+ /^(aspect-|object-)/,
2793
+ // Modifiers (responsive, state, pseudo, dark)
2794
+ /^(sm:|md:|lg:|xl:|2xl:|max-|min-\[)/,
2795
+ /^(hover:|focus:|disabled:|active:|aria-|data-\[)/,
2796
+ /^(dark:|placeholder:|file:|selection:|before:|after:)/,
2797
+ /^(group-|peer-|first:|last:|odd:|even:)/,
2798
+ // Colors with opacity modifier (bg-muted/50)
2799
+ /\/.+$/,
2800
+ // Arbitrary values (text-[13px], left-[calc(...)])
2801
+ /\[.+\]/,
2802
+ // Important modifier
2803
+ /^!/,
2804
+ // Negative values
2805
+ /^-[a-z]/,
2806
+ // Flex shorthand
2807
+ /^(flex-1|flex-auto|flex-initial|flex-none|flex-shrink|flex-grow|flex-row|flex-col|flex-wrap|flex-nowrap)/,
2808
+ // Container
2809
+ /^container$/,
2810
+ // sr-only
2811
+ /^(sr-only|not-sr-only)$/,
2812
+ ];
2813
+
2814
+ const words = value.split(/\s+/).filter(Boolean);
2815
+ if (words.length === 0) return false;
2816
+
2817
+ // Strip modifier prefixes before matching utility core
2818
+ const stripModifiers = (word: string): string => {
2819
+ let w = word;
2820
+ // Peel off known modifier layers (e.g. sm:hover:bg-primary -> bg-primary)
2821
+ let changed = true;
2822
+ while (changed) {
2823
+ changed = false;
2824
+ // Bracket-based modifiers
2825
+ const bracketMatch = w.match(/^[a-z@-]*\[[^\]]*\]:(.*)/);
2826
+ if (bracketMatch) { w = bracketMatch[1]; changed = true; continue; }
2827
+ // Fixed modifiers (check common prefixes)
2828
+ for (const prefix of ['sm:', 'md:', 'lg:', 'xl:', '2xl:', 'dark:', 'hover:', 'focus:', 'focus-visible:', 'focus-within:', 'active:', 'disabled:', 'group-hover:', 'group-focus:', 'peer-focus:', 'peer-disabled:', 'aria-invalid:', 'aria-selected:', 'aria-expanded:', 'placeholder:', 'file:', 'selection:', 'before:', 'after:', 'first:', 'last:', 'odd:', 'even:', 'required:', 'optional:', 'valid:', 'invalid:', 'checked:', 'open:', 'print:', 'motion-safe:', 'motion-reduce:', 'ltr:', 'rtl:', 'portrait:', 'landscape:', 'contrast-more:', 'contrast-less:', 'forced-colors:', 'autofill:', 'read-only:', 'max-sm:', 'max-md:', 'max-lg:', 'max-xl:', 'max-2xl:']) {
2829
+ if (w.startsWith(prefix)) { w = w.slice(prefix.length); changed = true; break; }
2830
+ }
2831
+ }
2832
+ return w;
2833
+ };
2834
+
2835
+ // Check if at least one word matches a Tailwind pattern
2836
+ return words.some(word => {
2837
+ const stripped = stripModifiers(word);
2838
+ return tailwindPatterns.some(pattern => pattern.test(stripped)) ||
2839
+ tailwindPatterns.some(pattern => pattern.test(word));
2840
+ });
2841
+ }
2842
+
2843
+ /**
2844
+ * Extract string value from a node (handles string literals, template literals, etc.)
2845
+ */
2846
+ private extractStringValue(node: Node): string {
2847
+ if (Node.isStringLiteral(node)) {
2848
+ return node.getLiteralValue();
2849
+ }
2850
+ if (Node.isNoSubstitutionTemplateLiteral(node)) {
2851
+ return node.getLiteralValue();
2852
+ }
2853
+ if (Node.isTemplateExpression(node)) {
2854
+ // For template expressions, just get the head text
2855
+ return node.getHead().getLiteralText();
2856
+ }
2857
+ // Fallback: try to get text and strip quotes
2858
+ const text = node.getText();
2859
+ return text.replace(/^["'`]|["'`]$/g, '');
2860
+ }
2861
+
2862
+ /**
2863
+ * Infer the slot type from component name
2864
+ */
2865
+ private inferSlot(name: string): SubComponentInfo['slot'] {
2866
+ const lower = name.toLowerCase();
2867
+ if (lower.includes('header')) return 'header';
2868
+ if (lower.includes('footer')) return 'footer';
2869
+ if (lower.includes('title')) return 'title';
2870
+ if (lower.includes('description')) return 'description';
2871
+ if (lower.includes('content')) return 'content';
2872
+ if (lower.includes('trigger')) return 'trigger';
2873
+ if (lower.includes('item')) return 'item';
2874
+ return 'container';
2875
+ }
2876
+ }