inkbridge 0.1.0-beta.2 → 0.1.0-beta.21
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.
- package/README.md +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Simple: Components with static classes
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { Project, SyntaxKind, Node, SourceFile } from 'ts-morph';
|
|
11
|
+
import { Project, SyntaxKind, Node, SourceFile, type JsxAttribute } from 'ts-morph';
|
|
12
12
|
import * as path from 'path';
|
|
13
13
|
import * as fs from 'fs';
|
|
14
14
|
import type {
|
|
@@ -27,7 +27,24 @@ import type {
|
|
|
27
27
|
JsxText,
|
|
28
28
|
IconImportSpec,
|
|
29
29
|
} from './types';
|
|
30
|
-
import { groupClassesByState,
|
|
30
|
+
import { groupClassesByState, OWN_STATE_MODIFIERS } from './tailwind-parser';
|
|
31
|
+
import { twMerge } from 'tailwind-merge';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply tailwind-merge to a space-joined class string to resolve conflicting utilities
|
|
35
|
+
* (e.g. `w-3/4 w-full` → `w-full`). Runs on the output of resolved `cn()` / `clsx()` /
|
|
36
|
+
* `twMerge()` / `cva()` call expressions so Figma renders the same class set the browser
|
|
37
|
+
* would. Falls back to the raw input on any parsing error to stay defensive.
|
|
38
|
+
*/
|
|
39
|
+
function resolveClassConflicts(input: string): string {
|
|
40
|
+
const trimmed = input.trim();
|
|
41
|
+
if (!trimmed) return '';
|
|
42
|
+
try {
|
|
43
|
+
return twMerge(trimmed);
|
|
44
|
+
} catch {
|
|
45
|
+
return trimmed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
|
|
32
49
|
// ============================================================================
|
|
33
50
|
// Helpers
|
|
@@ -43,6 +60,46 @@ function decodeHtmlEntities(text: string): string {
|
|
|
43
60
|
return text.replace(/&[a-zA-Z]+;/g, (entity) => HTML_ENTITIES[entity] ?? entity);
|
|
44
61
|
}
|
|
45
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Normalize JsxText whitespace the same way Babel/React do.
|
|
65
|
+
*
|
|
66
|
+
* IMPORTANT: callers must pass `child.getFullText()`, NOT `child.getText()`.
|
|
67
|
+
* ts-morph's `getText()` strips leading trivia from JsxText nodes — for the
|
|
68
|
+
* JsxText between `</span>` and `are`, getText() returns "are the design..."
|
|
69
|
+
* with the leading space LOST. That breaks inter-segment spacing
|
|
70
|
+
* (`Your <span>foo</span> bar` collapses to `Your foobar`). `getFullText()`
|
|
71
|
+
* preserves all whitespace, which is exactly what Babel's transform needs.
|
|
72
|
+
*
|
|
73
|
+
* Algorithm (mirrors babel's cleanJSXElementLiteralChild):
|
|
74
|
+
* - Tabs become spaces.
|
|
75
|
+
* - Leading whitespace is stripped on every line EXCEPT the first.
|
|
76
|
+
* - Trailing whitespace is stripped on every line EXCEPT the last.
|
|
77
|
+
* - Whitespace-only lines are dropped.
|
|
78
|
+
* - Non-empty lines are joined with a single space, except the last
|
|
79
|
+
* non-empty line which is appended without a trailing separator.
|
|
80
|
+
*
|
|
81
|
+
* Result: `Your\n bar` → "Your bar"; ` are the design\n system.` →
|
|
82
|
+
* " are the design system." (leading space preserved).
|
|
83
|
+
*/
|
|
84
|
+
function normalizeJsxText(raw: string): string {
|
|
85
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
86
|
+
let lastNonEmpty = -1;
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
if (/[^ \t]/.test(lines[i])) lastNonEmpty = i;
|
|
89
|
+
}
|
|
90
|
+
if (lastNonEmpty < 0) return '';
|
|
91
|
+
let out = '';
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
let line = lines[i].replace(/\t/g, ' ');
|
|
94
|
+
if (i !== 0) line = line.replace(/^[ ]+/, '');
|
|
95
|
+
if (i !== lines.length - 1) line = line.replace(/[ ]+$/, '');
|
|
96
|
+
if (!line) continue;
|
|
97
|
+
out += line;
|
|
98
|
+
if (i !== lastNonEmpty) out += ' ';
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
46
103
|
// ============================================================================
|
|
47
104
|
// Component to HTML Element Mapping
|
|
48
105
|
// ============================================================================
|
|
@@ -55,6 +112,21 @@ const COMPONENT_TO_HTML_MAP: Record<string, string> = {
|
|
|
55
112
|
Link: 'a', // next/link
|
|
56
113
|
};
|
|
57
114
|
|
|
115
|
+
// Internal-only marker variant of JsxElement used as a transient sentinel
|
|
116
|
+
// when expanding portal trees: nodes flagged with `__portalSkip` are
|
|
117
|
+
// dropped before the JSX tree is returned to consumers, so the flag
|
|
118
|
+
// never surfaces in scanner output.
|
|
119
|
+
type PortalSkipFlagged = { __portalSkip?: boolean };
|
|
120
|
+
|
|
121
|
+
// Generic expression-evaluation result used by the prop-context resolution path.
|
|
122
|
+
// The scanner threads JS literal values (string | number | boolean | null | array
|
|
123
|
+
// | object) through the AST walk; consumers later branch on `typeof` / `Array.isArray`.
|
|
124
|
+
// Kept as `any` so the call sites can index / access without a manual narrowing
|
|
125
|
+
// dance — the alias documents intent and keeps the literal `any` keyword out of
|
|
126
|
+
// the rest of the file.
|
|
127
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
128
|
+
type ResolvedExpressionValue = any;
|
|
129
|
+
|
|
58
130
|
// ============================================================================
|
|
59
131
|
// Scanner Class
|
|
60
132
|
// ============================================================================
|
|
@@ -66,17 +138,39 @@ export class ComponentScanner {
|
|
|
66
138
|
// Cache for resolved imported component JSX trees
|
|
67
139
|
private importedComponentCache: Map<string, { sourceFile: SourceFile; componentName: string }>;
|
|
68
140
|
// Cache for array values found in source files (for .map() expansion)
|
|
69
|
-
private arrayValueCache: Map<string,
|
|
141
|
+
private arrayValueCache: Map<string, ResolvedExpressionValue[]>;
|
|
142
|
+
// Cache: component definition node → set of prop names that the
|
|
143
|
+
// component cascades to its children via a `<*.Provider value={{ ... }}>`
|
|
144
|
+
// wrapper. Keyed by Node identity (not name) so two different `Group`
|
|
145
|
+
// functions in unrelated source files don't collide. Empty set means
|
|
146
|
+
// "analyzed, no cascade detected" — still cached to skip the body walk
|
|
147
|
+
// on subsequent invocations of the same component.
|
|
148
|
+
private providerCascadeNamesCache: WeakMap<Node, Set<string>>;
|
|
149
|
+
// Re-entry guard for `resolveLocalIdentifier` — prevents infinite recursion
|
|
150
|
+
// when a local const's initializer references another local const that
|
|
151
|
+
// (directly or transitively) references the first one. Keyed by
|
|
152
|
+
// `${filePath}:${name}` so unrelated identifiers don't block each other.
|
|
153
|
+
private localResolutionStack: Set<string> = new Set();
|
|
70
154
|
|
|
71
155
|
constructor(config: ScannerConfig) {
|
|
72
156
|
this.config = config;
|
|
73
157
|
this.project = new Project({
|
|
74
158
|
tsConfigFilePath: path.resolve(process.cwd(), 'tsconfig.json'),
|
|
75
159
|
skipAddingFilesFromTsConfig: true,
|
|
160
|
+
// Scanner doesn't need full TS semantic analysis — it operates on
|
|
161
|
+
// the AST shape (`getJsxChildren`, `getOpeningElement`, etc.) and
|
|
162
|
+
// resolves imports by walking ts-morph's source files directly.
|
|
163
|
+
// Default settings load DOM / Node typings + traverse every
|
|
164
|
+
// import for type-checking, which is the lion's share of scan
|
|
165
|
+
// time. Skipping them cuts scan time substantially with no loss
|
|
166
|
+
// of scan correctness (regression suite covers the AST traversal).
|
|
167
|
+
skipFileDependencyResolution: true,
|
|
168
|
+
skipLoadingLibFiles: true,
|
|
76
169
|
});
|
|
77
170
|
this.iconImports = new Map();
|
|
78
171
|
this.importedComponentCache = new Map();
|
|
79
172
|
this.arrayValueCache = new Map();
|
|
173
|
+
this.providerCascadeNamesCache = new WeakMap();
|
|
80
174
|
}
|
|
81
175
|
|
|
82
176
|
/**
|
|
@@ -105,9 +199,17 @@ export class ComponentScanner {
|
|
|
105
199
|
try {
|
|
106
200
|
const analysis = this.analyzeFile(filePath);
|
|
107
201
|
if (analysis) {
|
|
108
|
-
// Check for co-located story file
|
|
109
|
-
|
|
110
|
-
|
|
202
|
+
// Check for co-located story file — accept either `.stories.tsx`
|
|
203
|
+
// or `.stories.ts`. Storybook's `npx storybook init` for React
|
|
204
|
+
// projects emits `.stories.ts` by default; older Inkbridge
|
|
205
|
+
// versions only matched `.tsx` and silently reported "0 stories".
|
|
206
|
+
// The story parser is JSX-aware (ts-morph) and handles both.
|
|
207
|
+
const storyPathTsx = filePath.replace(/\.tsx$/, '.stories.tsx');
|
|
208
|
+
const storyPathTs = filePath.replace(/\.tsx$/, '.stories.ts');
|
|
209
|
+
const storyPath = fs.existsSync(storyPathTsx)
|
|
210
|
+
? storyPathTsx
|
|
211
|
+
: (fs.existsSync(storyPathTs) ? storyPathTs : null);
|
|
212
|
+
if (storyPath) {
|
|
111
213
|
analysis.hasStory = true;
|
|
112
214
|
try {
|
|
113
215
|
analysis.stories = this.parseStories(storyPath);
|
|
@@ -202,7 +304,6 @@ export class ComponentScanner {
|
|
|
202
304
|
*/
|
|
203
305
|
analyzeFile(filePath: string): ComponentAnalysis | null {
|
|
204
306
|
const sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
205
|
-
const fileName = path.basename(filePath, '.tsx');
|
|
206
307
|
|
|
207
308
|
// Check for CVA usage first (most structured)
|
|
208
309
|
const cvaAnalysis = this.analyzeCVA(sourceFile, filePath);
|
|
@@ -232,6 +333,10 @@ export class ComponentScanner {
|
|
|
232
333
|
// ===========================================================================
|
|
233
334
|
|
|
234
335
|
private analyzeCVA(sourceFile: SourceFile, filePath: string): CVAComponentAnalysis | null {
|
|
336
|
+
if (this.shouldDeferCvaToCompound(sourceFile, filePath)) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
235
340
|
// Find cva() call expressions
|
|
236
341
|
const cvaCalls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
237
342
|
.filter(call => call.getExpression().getText() === 'cva');
|
|
@@ -376,15 +481,20 @@ export class ComponentScanner {
|
|
|
376
481
|
// ===========================================================================
|
|
377
482
|
|
|
378
483
|
private analyzeState(sourceFile: SourceFile, filePath: string): StateComponentAnalysis | null {
|
|
379
|
-
const
|
|
484
|
+
const rootClasses = this.getRootClassesForStateAnalysis(sourceFile, filePath);
|
|
485
|
+
const allClasses = rootClasses.length > 0
|
|
486
|
+
? rootClasses
|
|
487
|
+
: this.extractAllClassesFromFile(sourceFile);
|
|
380
488
|
|
|
381
489
|
if (allClasses.length === 0) {
|
|
382
490
|
return null;
|
|
383
491
|
}
|
|
384
492
|
|
|
385
|
-
// Check if any classes have state
|
|
493
|
+
// Check if any classes have an OWN state modifier (excludes
|
|
494
|
+
// `group-*:` / `peer-*:` — those are passive reactions to a
|
|
495
|
+
// parent's state, not the component's own variant axis).
|
|
386
496
|
const hasStateModifiers = allClasses.some(cls =>
|
|
387
|
-
|
|
497
|
+
OWN_STATE_MODIFIERS.some(mod => cls.startsWith(mod))
|
|
388
498
|
);
|
|
389
499
|
|
|
390
500
|
if (!hasStateModifiers) {
|
|
@@ -403,15 +513,19 @@ export class ComponentScanner {
|
|
|
403
513
|
}
|
|
404
514
|
|
|
405
515
|
const states: Record<string, StateInfo> = {};
|
|
516
|
+
const hasDataDisabledState = allClasses.some(cls => cls.startsWith('data-[disabled]:') || cls.startsWith('group-data-[disabled]:'));
|
|
517
|
+
const hasNativeDisabledState = allClasses.some(cls => cls.startsWith('disabled:') || cls.startsWith('peer-disabled:'));
|
|
518
|
+
const hasDataCheckedState = allClasses.some(cls => cls.startsWith('data-[checked]:') || cls.startsWith('data-[state=checked]:') || cls.startsWith('group-data-[checked]:'));
|
|
519
|
+
const hasNativeCheckedState = allClasses.some(cls => cls.startsWith('checked:'));
|
|
406
520
|
|
|
407
521
|
for (const [stateName, classes] of Object.entries(groupedClasses)) {
|
|
408
522
|
let trigger = '';
|
|
409
523
|
if (stateName === 'hover') trigger = 'hover:';
|
|
410
524
|
else if (stateName === 'focus') trigger = 'focus-visible:';
|
|
411
|
-
else if (stateName === 'disabled') trigger = 'disabled:';
|
|
525
|
+
else if (stateName === 'disabled') trigger = hasDataDisabledState ? 'data-[disabled]:' : (hasNativeDisabledState ? 'disabled:' : 'disabled:');
|
|
412
526
|
else if (stateName === 'error') trigger = 'aria-invalid:';
|
|
413
527
|
else if (stateName === 'active') trigger = 'active:';
|
|
414
|
-
else if (stateName === 'checked') trigger = 'data-[
|
|
528
|
+
else if (stateName === 'checked') trigger = hasDataCheckedState ? 'data-[checked]:' : (hasNativeCheckedState ? 'checked:' : 'data-[checked]:');
|
|
415
529
|
else if (stateName === 'open') trigger = 'data-[state=open]:';
|
|
416
530
|
|
|
417
531
|
states[stateName] = {
|
|
@@ -431,6 +545,25 @@ export class ComponentScanner {
|
|
|
431
545
|
};
|
|
432
546
|
}
|
|
433
547
|
|
|
548
|
+
private getRootClassesForStateAnalysis(sourceFile: SourceFile, filePath: string): string[] {
|
|
549
|
+
const nameCandidates = this.getComponentNameCandidates(filePath);
|
|
550
|
+
let jsxTree: JsxNode | null = null;
|
|
551
|
+
for (const candidate of nameCandidates) {
|
|
552
|
+
jsxTree = this.extractComponentJsxTree(sourceFile, candidate);
|
|
553
|
+
if (jsxTree) break;
|
|
554
|
+
}
|
|
555
|
+
if (!jsxTree || jsxTree.type !== 'element') {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const root = jsxTree as JsxElement;
|
|
560
|
+
const className = root.props && typeof root.props.className === 'string'
|
|
561
|
+
? root.props.className
|
|
562
|
+
: '';
|
|
563
|
+
if (!className) return [];
|
|
564
|
+
return className.split(/\s+/).filter(Boolean);
|
|
565
|
+
}
|
|
566
|
+
|
|
434
567
|
private hasRootStateModifier(sourceFile: SourceFile, filePath: string): boolean {
|
|
435
568
|
const nameCandidates = this.getComponentNameCandidates(filePath);
|
|
436
569
|
let jsxTree: JsxNode | null = null;
|
|
@@ -447,9 +580,14 @@ export class ComponentScanner {
|
|
|
447
580
|
? root.props.className
|
|
448
581
|
: '';
|
|
449
582
|
if (!className) {
|
|
450
|
-
// Wrapper components (root tag is another component like <Hero>)
|
|
451
|
-
//
|
|
452
|
-
|
|
583
|
+
// Wrapper components (root tag is another component like <Hero>) and
|
|
584
|
+
// fragment wrappers (`<>...</>`, emitted as an element with empty
|
|
585
|
+
// tagName) are not state primitives — any nested hover/focus class
|
|
586
|
+
// belongs to a deeply nested element (e.g. a chip with hover styles
|
|
587
|
+
// inside a marketing section), not to the component itself. This
|
|
588
|
+
// prevents marketing molecules like HeroSection from being wrongly
|
|
589
|
+
// classified as `state` and rendered as a symbol/state matrix.
|
|
590
|
+
if (root.tagName === '' || /^[A-Z]/.test(root.tagName)) {
|
|
453
591
|
return false;
|
|
454
592
|
}
|
|
455
593
|
// Keep state analysis for primitives that use dynamic className builders
|
|
@@ -459,7 +597,7 @@ export class ComponentScanner {
|
|
|
459
597
|
|
|
460
598
|
const classes = className.split(/\s+/).filter(Boolean);
|
|
461
599
|
for (const cls of classes) {
|
|
462
|
-
for (const modifier of
|
|
600
|
+
for (const modifier of OWN_STATE_MODIFIERS) {
|
|
463
601
|
if (cls.startsWith(modifier)) {
|
|
464
602
|
return true;
|
|
465
603
|
}
|
|
@@ -554,11 +692,77 @@ export class ComponentScanner {
|
|
|
554
692
|
|
|
555
693
|
/**
|
|
556
694
|
* Extract JSX tree from a function body (arrow function, function expression, or function declaration).
|
|
695
|
+
* `propsContext` is the outer-scope context. The function's own parameters
|
|
696
|
+
* (e.g. `field` in `<FormField render={({ field }) => ...} />`) shadow but
|
|
697
|
+
* the rest of the outer scope is still resolvable via the parent's
|
|
698
|
+
* propsContext — pass it through so closure variables like `errors` keep
|
|
699
|
+
* resolving inside the callback body.
|
|
700
|
+
*/
|
|
701
|
+
/**
|
|
702
|
+
* Build a `propsContext` map from a story's `args` object. Args
|
|
703
|
+
* extracted from the source code are always strings (the scanner
|
|
704
|
+
* keeps `args.foo = "0.1482"`), but JSX expressions like
|
|
705
|
+
* `{rewardSol.toFixed(4)}` need a numeric receiver to evaluate. Try
|
|
706
|
+
* to parse each value as a number first; fall back to the literal
|
|
707
|
+
* string for non-numeric args (`label = "Click me"`). Booleans are
|
|
708
|
+
* matched by exact "true" / "false".
|
|
557
709
|
*/
|
|
710
|
+
private buildArgsPropsContext(args: Record<string, string>): Map<string, ResolvedExpressionValue> {
|
|
711
|
+
const out = new Map<string, ResolvedExpressionValue>();
|
|
712
|
+
for (const key in args) {
|
|
713
|
+
if (!Object.prototype.hasOwnProperty.call(args, key)) continue;
|
|
714
|
+
const raw = args[key];
|
|
715
|
+
if (raw === 'true') { out.set(key, true); continue; }
|
|
716
|
+
if (raw === 'false') { out.set(key, false); continue; }
|
|
717
|
+
if (raw !== '' && !isNaN(Number(raw))) { out.set(key, Number(raw)); continue; }
|
|
718
|
+
out.set(key, raw);
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Open the imported component's source file (already discovered via
|
|
725
|
+
* `relativeImports`), find its function declaration, and walk its
|
|
726
|
+
* JSX body with `argsContext` so per-story expressions like
|
|
727
|
+
* `{rewardSol.toFixed(4)}` resolve to concrete text. Returns `null`
|
|
728
|
+
* when the file can't be loaded or no exported component matches.
|
|
729
|
+
*/
|
|
730
|
+
private buildStoryJsxTreeFromImportedComponent(
|
|
731
|
+
importedPath: string,
|
|
732
|
+
componentName: string,
|
|
733
|
+
argsContext: Map<string, ResolvedExpressionValue>,
|
|
734
|
+
): JsxNode | null {
|
|
735
|
+
let sourceFile: SourceFile | null = null;
|
|
736
|
+
try {
|
|
737
|
+
sourceFile = this.project.addSourceFileAtPathIfExists(importedPath) || null;
|
|
738
|
+
} catch (_e) {
|
|
739
|
+
sourceFile = null;
|
|
740
|
+
}
|
|
741
|
+
if (!sourceFile) return null;
|
|
742
|
+
|
|
743
|
+
const localComponents = new Map<string, Node>();
|
|
744
|
+
for (const func of sourceFile.getFunctions()) {
|
|
745
|
+
const name = func.getName();
|
|
746
|
+
if (name) localComponents.set(name, func);
|
|
747
|
+
}
|
|
748
|
+
for (const varStmt of sourceFile.getVariableStatements()) {
|
|
749
|
+
for (const decl of varStmt.getDeclarationList().getDeclarations()) {
|
|
750
|
+
const name = decl.getName();
|
|
751
|
+
const init = decl.getInitializer();
|
|
752
|
+
if (init) localComponents.set(name, init);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const relativeImports = this.collectComponentImports(sourceFile);
|
|
756
|
+
const compNode = localComponents.get(componentName);
|
|
757
|
+
if (!compNode) return null;
|
|
758
|
+
return this.extractJsxTreeFromFunctionBody(compNode, relativeImports, localComponents, argsContext);
|
|
759
|
+
}
|
|
760
|
+
|
|
558
761
|
private extractJsxTreeFromFunctionBody(
|
|
559
762
|
funcNode: Node,
|
|
560
763
|
relativeImports: Map<string, string>,
|
|
561
|
-
localComponents: Map<string, Node
|
|
764
|
+
localComponents: Map<string, Node>,
|
|
765
|
+
propsContext: Map<string, ResolvedExpressionValue> = new Map()
|
|
562
766
|
): JsxNode | null {
|
|
563
767
|
const jsxElements = funcNode.getDescendantsOfKind(SyntaxKind.JsxElement);
|
|
564
768
|
const selfClosing = funcNode.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
|
|
@@ -574,7 +778,7 @@ export class ComponentScanner {
|
|
|
574
778
|
}
|
|
575
779
|
}
|
|
576
780
|
|
|
577
|
-
return this.buildJsxTree(rootJsx, localComponents, relativeImports);
|
|
781
|
+
return this.buildJsxTree(rootJsx, localComponents, relativeImports, propsContext);
|
|
578
782
|
}
|
|
579
783
|
|
|
580
784
|
// ===========================================================================
|
|
@@ -637,12 +841,19 @@ export class ComponentScanner {
|
|
|
637
841
|
}
|
|
638
842
|
}
|
|
639
843
|
|
|
640
|
-
// Find the meta's component name (from `component: Button`)
|
|
844
|
+
// Find the meta's component name (from `component: Button`).
|
|
845
|
+
// Unwrap `as` and `satisfies` casts — Storybook templates ship with
|
|
846
|
+
// `const meta = { ... } satisfies Meta<typeof X>`, which would
|
|
847
|
+
// otherwise hide the ObjectLiteralExpression we walk to find
|
|
848
|
+
// `component`. Missing this meant args-only stories synthesised no
|
|
849
|
+
// instance, so "simple"-typed components (e.g. a plain function
|
|
850
|
+
// component with props rendered via `args`) showed up empty on the
|
|
851
|
+
// generated design system page.
|
|
641
852
|
let metaComponentName: string | undefined;
|
|
642
853
|
for (const varStmt of sourceFile.getVariableStatements()) {
|
|
643
854
|
for (const decl of varStmt.getDeclarationList().getDeclarations()) {
|
|
644
855
|
if (decl.getName() === 'meta') {
|
|
645
|
-
const init = decl.getInitializer();
|
|
856
|
+
const init = this.unwrapStaticValueExpression(decl.getInitializer()) || decl.getInitializer();
|
|
646
857
|
if (init && Node.isObjectLiteralExpression(init)) {
|
|
647
858
|
const compProp = init.getProperty('component');
|
|
648
859
|
if (compProp && Node.isPropertyAssignment(compProp)) {
|
|
@@ -663,7 +874,8 @@ export class ComponentScanner {
|
|
|
663
874
|
const storyName = decl.getName();
|
|
664
875
|
if (storyName === 'meta' || storyName === 'default') continue;
|
|
665
876
|
|
|
666
|
-
|
|
877
|
+
// Unwrap `as`/`satisfies` casts (same reason as `meta` above).
|
|
878
|
+
const init = this.unwrapStaticValueExpression(decl.getInitializer()) || decl.getInitializer();
|
|
667
879
|
if (!init || !Node.isObjectLiteralExpression(init)) continue;
|
|
668
880
|
|
|
669
881
|
const story: StoryInfo = {
|
|
@@ -699,21 +911,70 @@ export class ComponentScanner {
|
|
|
699
911
|
delete instance.props.children;
|
|
700
912
|
story.instances.push(instance);
|
|
701
913
|
|
|
702
|
-
// For args-based stories, build
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
914
|
+
// For args-based stories, build a story-specific jsxTree
|
|
915
|
+
// with the story's args plumbed through as `propsContext`.
|
|
916
|
+
// This lets JSX expressions like `{rewardSol.toFixed(4)}`
|
|
917
|
+
// resolve to concrete text in the rendered design system
|
|
918
|
+
// page (previously they were silently dropped, leaving the
|
|
919
|
+
// component looking empty even when args were supplied).
|
|
920
|
+
//
|
|
921
|
+
// Two source-locations to handle:
|
|
922
|
+
// 1. `localComponents.get(name)` — component defined in
|
|
923
|
+
// the same story file (unusual, but supported).
|
|
924
|
+
// 2. The imported component's source file (typical
|
|
925
|
+
// shadcn / project layout: `<Component>.stories.tsx`
|
|
926
|
+
// next to `<Component>.tsx`). Resolved via
|
|
927
|
+
// `relativeImports`.
|
|
928
|
+
if (!story.jsxTree) {
|
|
929
|
+
const argsContext = this.buildArgsPropsContext(args);
|
|
930
|
+
// Walk the args initializer AST so prop values that are
|
|
931
|
+
// arrays, objects, or identifiers referencing such
|
|
932
|
+
// (e.g. `blocks: mockBlocks`) resolve to their real
|
|
933
|
+
// structure — not the string-coerced text. Without
|
|
934
|
+
// this an args-based BlockTable story would see
|
|
935
|
+
// `blocks = "mockBlocks"` and the .map call inside the
|
|
936
|
+
// component couldn't resolve the iteration source.
|
|
937
|
+
for (const prop of argsInit.getProperties()) {
|
|
938
|
+
if (!Node.isPropertyAssignment(prop)) continue;
|
|
939
|
+
const propInit = prop.getInitializer();
|
|
940
|
+
if (!propInit) continue;
|
|
941
|
+
const resolved = this.resolveExpressionValue(propInit, new Map());
|
|
942
|
+
if (resolved !== undefined && resolved !== null) {
|
|
943
|
+
argsContext.set(prop.getName(), resolved);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const localCompBody = localComponents.get(metaComponentName);
|
|
947
|
+
if (localCompBody) {
|
|
948
|
+
story.jsxTree = this.extractJsxTreeFromFunctionBody(
|
|
949
|
+
localCompBody, relativeImports, localComponents, argsContext,
|
|
950
|
+
) || undefined;
|
|
951
|
+
} else {
|
|
952
|
+
const importedPath = relativeImports.get(metaComponentName);
|
|
953
|
+
if (importedPath) {
|
|
954
|
+
story.jsxTree = this.buildStoryJsxTreeFromImportedComponent(
|
|
955
|
+
importedPath, metaComponentName, argsContext,
|
|
956
|
+
) || undefined;
|
|
715
957
|
}
|
|
716
|
-
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Inject `args.children` as a text node when the
|
|
961
|
+
// built tree has no element/text children of its own.
|
|
962
|
+
// Many shadcn primitives use `<Primitive {...props} />`
|
|
963
|
+
// to forward children, so the scanner sees a bare
|
|
964
|
+
// element — the children value lives in the args
|
|
965
|
+
// object but never makes it into the rendered tree.
|
|
966
|
+
// Without this an args-based `<Label>Email</Label>`
|
|
967
|
+
// story renders as an empty Label frame in Figma.
|
|
968
|
+
if (
|
|
969
|
+
story.jsxTree
|
|
970
|
+
&& story.jsxTree.type === 'element'
|
|
971
|
+
&& (!story.jsxTree.children || story.jsxTree.children.length === 0)
|
|
972
|
+
&& typeof args.children === 'string'
|
|
973
|
+
&& args.children.length > 0
|
|
974
|
+
) {
|
|
975
|
+
story.jsxTree.children = [
|
|
976
|
+
{ type: 'text', content: args.children },
|
|
977
|
+
];
|
|
717
978
|
}
|
|
718
979
|
}
|
|
719
980
|
}
|
|
@@ -786,14 +1047,14 @@ export class ComponentScanner {
|
|
|
786
1047
|
story.jsxTree = this.buildJsxTree(root, localComponents, relativeImports);
|
|
787
1048
|
|
|
788
1049
|
// Extract layout classes from root element (any tag, not just div)
|
|
789
|
-
let attrs:
|
|
1050
|
+
let attrs: JsxAttribute[] = [];
|
|
790
1051
|
if (Node.isJsxElement(root)) {
|
|
791
1052
|
attrs = root.getOpeningElement().getDescendantsOfKind(SyntaxKind.JsxAttribute);
|
|
792
1053
|
} else if (Node.isJsxSelfClosingElement(root)) {
|
|
793
1054
|
attrs = root.getDescendantsOfKind(SyntaxKind.JsxAttribute);
|
|
794
1055
|
}
|
|
795
1056
|
const classAttr = attrs.find(a => {
|
|
796
|
-
const nameNode =
|
|
1057
|
+
const nameNode = a.getNameNode();
|
|
797
1058
|
return nameNode ? nameNode.getText() === 'className' : false;
|
|
798
1059
|
});
|
|
799
1060
|
if (classAttr) {
|
|
@@ -812,13 +1073,23 @@ export class ComponentScanner {
|
|
|
812
1073
|
el.getOpeningElement(),
|
|
813
1074
|
tagName
|
|
814
1075
|
);
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1076
|
+
const structuredChildren = this.extractStructuredChildrenFromJsxElement(
|
|
1077
|
+
el,
|
|
1078
|
+
localComponents,
|
|
1079
|
+
relativeImports,
|
|
1080
|
+
new Map()
|
|
1081
|
+
);
|
|
1082
|
+
if (structuredChildren.length > 0) {
|
|
1083
|
+
instance.props.__jsxChildren = structuredChildren;
|
|
1084
|
+
}
|
|
1085
|
+
const literalChildren = this.extractLiteralChildrenText(el.getJsxChildren());
|
|
1086
|
+
if (literalChildren) {
|
|
1087
|
+
instance.children = literalChildren;
|
|
1088
|
+
} else {
|
|
1089
|
+
const contextualLabel = this.extractContextualInstanceLabel(el);
|
|
1090
|
+
if (contextualLabel) {
|
|
1091
|
+
instance.children = contextualLabel;
|
|
1092
|
+
}
|
|
822
1093
|
}
|
|
823
1094
|
story.instances.push(instance);
|
|
824
1095
|
}
|
|
@@ -828,11 +1099,226 @@ export class ComponentScanner {
|
|
|
828
1099
|
const tagName = el.getTagNameNode().getText();
|
|
829
1100
|
if (importedComponents.has(tagName)) {
|
|
830
1101
|
const instance = this.extractInstanceFromSelfClosing(el, tagName);
|
|
1102
|
+
const contextualLabel = this.extractContextualInstanceLabel(el);
|
|
1103
|
+
if (contextualLabel) {
|
|
1104
|
+
instance.children = contextualLabel;
|
|
1105
|
+
}
|
|
831
1106
|
story.instances.push(instance);
|
|
832
1107
|
}
|
|
833
1108
|
}
|
|
834
1109
|
}
|
|
835
1110
|
|
|
1111
|
+
private extractStructuredChildrenFromJsxElement(
|
|
1112
|
+
element: Node,
|
|
1113
|
+
localComponents: Map<string, Node>,
|
|
1114
|
+
relativeImports: Map<string, string>,
|
|
1115
|
+
propsContext: Map<string, ResolvedExpressionValue> = new Map()
|
|
1116
|
+
): JsxNode[] {
|
|
1117
|
+
if (!Node.isJsxElement(element)) return [];
|
|
1118
|
+
|
|
1119
|
+
const out: JsxNode[] = [];
|
|
1120
|
+
for (const child of element.getJsxChildren()) {
|
|
1121
|
+
if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
|
|
1122
|
+
const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext);
|
|
1123
|
+
if ((childNode as JsxNode & PortalSkipFlagged).__portalSkip) continue;
|
|
1124
|
+
out.push(childNode);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (Node.isJsxText(child)) {
|
|
1129
|
+
// JSX collapses internal whitespace (including newlines) to a single
|
|
1130
|
+
// space — matching Babel / React's transform. Naive `.trim()` would
|
|
1131
|
+
// leave a literal newline embedded between e.g. "design\nsystem",
|
|
1132
|
+
// which the Figma plugin renders as a hard line break. Naive
|
|
1133
|
+
// `.replace(/\s+/g, ' ').trim()` drops boundary whitespace and
|
|
1134
|
+
// breaks inter-segment spacing for `Your <span>foo</span> bar`.
|
|
1135
|
+
const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
|
|
1136
|
+
if (text) out.push({ type: 'text', content: text });
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (!Node.isJsxExpression(child)) continue;
|
|
1141
|
+
const expr = child.getExpression();
|
|
1142
|
+
if (!expr) continue;
|
|
1143
|
+
|
|
1144
|
+
if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '??') {
|
|
1145
|
+
let chosen: Node = expr.getLeft();
|
|
1146
|
+
const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
1147
|
+
if (leftValue === undefined || leftValue === null) {
|
|
1148
|
+
chosen = expr.getRight();
|
|
1149
|
+
}
|
|
1150
|
+
if (Node.isParenthesizedExpression(chosen)) {
|
|
1151
|
+
chosen = chosen.getExpression();
|
|
1152
|
+
}
|
|
1153
|
+
if (Node.isJsxElement(chosen) || Node.isJsxSelfClosingElement(chosen)) {
|
|
1154
|
+
const chosenNode = this.buildJsxTree(chosen, localComponents, relativeImports, propsContext);
|
|
1155
|
+
if (!(chosenNode as JsxNode & PortalSkipFlagged).__portalSkip) out.push(chosenNode);
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const resolvedChosen = this.resolveExpressionValue(chosen, propsContext);
|
|
1159
|
+
if (resolvedChosen !== undefined && resolvedChosen !== null) {
|
|
1160
|
+
this.pushResolvedPropValue(out, resolvedChosen);
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
|
|
1166
|
+
out.push({ type: 'text', content: decodeHtmlEntities(expr.getLiteralValue()) });
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (Node.isCallExpression(expr)) {
|
|
1171
|
+
const callExpr = expr.getExpression();
|
|
1172
|
+
if (Node.isPropertyAccessExpression(callExpr) && callExpr.getName() === 'map') {
|
|
1173
|
+
const expandedChildren = this.expandMapCall(
|
|
1174
|
+
expr,
|
|
1175
|
+
element.getSourceFile(),
|
|
1176
|
+
localComponents,
|
|
1177
|
+
relativeImports,
|
|
1178
|
+
propsContext
|
|
1179
|
+
);
|
|
1180
|
+
if (expandedChildren.length > 0) out.push(...expandedChildren);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (Node.isIdentifier(expr) && propsContext.has(expr.getText())) {
|
|
1186
|
+
this.pushResolvedPropValue(out, propsContext.get(expr.getText()));
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (Node.isPropertyAccessExpression(expr)) {
|
|
1191
|
+
const objExpr = expr.getExpression();
|
|
1192
|
+
const propName = expr.getName();
|
|
1193
|
+
if (Node.isIdentifier(objExpr) && propsContext.has(objExpr.getText())) {
|
|
1194
|
+
const obj = propsContext.get(objExpr.getText());
|
|
1195
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj) && propName in obj) {
|
|
1196
|
+
this.pushResolvedPropValue(out, obj[propName]);
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const resolved = this.resolveExpressionValue(expr, propsContext);
|
|
1203
|
+
if (resolved !== undefined) {
|
|
1204
|
+
this.pushResolvedPropValue(out, resolved);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const exprText = expr.getText();
|
|
1209
|
+
const isConditional = exprText.includes('?') || exprText.includes('&&') || exprText.includes('||');
|
|
1210
|
+
const isFunction = exprText.includes('=>') || exprText.includes('function');
|
|
1211
|
+
if (exprText && !isConditional && !isFunction) {
|
|
1212
|
+
out.push({ type: 'text', content: `{${exprText}}` });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return out;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
private extractLiteralChildrenText(children: Node[]): string {
|
|
1219
|
+
if (!Array.isArray(children) || children.length === 0) return '';
|
|
1220
|
+
const parts: string[] = [];
|
|
1221
|
+
for (const child of children) {
|
|
1222
|
+
const text = this.extractLiteralTextFromJsxNode(child, { includeComponentSubtrees: false });
|
|
1223
|
+
if (text) parts.push(text);
|
|
1224
|
+
}
|
|
1225
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private extractContextualInstanceLabel(node: Node): string | null {
|
|
1229
|
+
const parent = node.getParent();
|
|
1230
|
+
if (!parent || !Node.isJsxElement(parent)) return null;
|
|
1231
|
+
|
|
1232
|
+
const parentTagName = parent.getOpeningElement().getTagNameNode().getText().toLowerCase();
|
|
1233
|
+
const jsxChildren = parent.getJsxChildren();
|
|
1234
|
+
const parts: string[] = [];
|
|
1235
|
+
let siblingComponentCount = 0;
|
|
1236
|
+
|
|
1237
|
+
for (const child of jsxChildren) {
|
|
1238
|
+
if (child.getPos() === node.getPos() && child.getEnd() === node.getEnd()) continue;
|
|
1239
|
+
|
|
1240
|
+
if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
|
|
1241
|
+
const tagName = Node.isJsxElement(child)
|
|
1242
|
+
? child.getOpeningElement().getTagNameNode().getText()
|
|
1243
|
+
: child.getTagNameNode().getText();
|
|
1244
|
+
const isComponentTag = /^[A-Z]/.test(tagName) || tagName.includes('.');
|
|
1245
|
+
if (isComponentTag) {
|
|
1246
|
+
siblingComponentCount++;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const nestedText = this.extractLiteralTextFromJsxNode(child);
|
|
1250
|
+
if (nestedText) parts.push(nestedText);
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (Node.isJsxText(child)) {
|
|
1255
|
+
const text = decodeHtmlEntities(child.getText().replace(/\s+/g, ' ').trim());
|
|
1256
|
+
if (text) parts.push(text);
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (Node.isJsxExpression(child)) {
|
|
1261
|
+
const expr = child.getExpression();
|
|
1262
|
+
if (!expr) continue;
|
|
1263
|
+
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
|
|
1264
|
+
const text = decodeHtmlEntities(expr.getLiteralValue().trim());
|
|
1265
|
+
if (text) parts.push(text);
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const resolved = this.resolveExpressionValue(expr, new Map());
|
|
1269
|
+
if (resolved == null) continue;
|
|
1270
|
+
const text = decodeHtmlEntities(String(resolved).replace(/\s+/g, ' ').trim());
|
|
1271
|
+
if (text) parts.push(text);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const label = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
1276
|
+
if (!label) return null;
|
|
1277
|
+
|
|
1278
|
+
if (parentTagName === 'label') return label;
|
|
1279
|
+
if (siblingComponentCount === 0) return label;
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
private extractLiteralTextFromJsxNode(
|
|
1284
|
+
node: Node,
|
|
1285
|
+
options?: { includeComponentSubtrees?: boolean }
|
|
1286
|
+
): string {
|
|
1287
|
+
if (Node.isJsxText(node)) {
|
|
1288
|
+
return decodeHtmlEntities(node.getText().replace(/\s+/g, ' ').trim());
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (Node.isJsxExpression(node)) {
|
|
1292
|
+
const expr = node.getExpression();
|
|
1293
|
+
if (!expr) return '';
|
|
1294
|
+
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
|
|
1295
|
+
return decodeHtmlEntities(expr.getLiteralValue().replace(/\s+/g, ' ').trim());
|
|
1296
|
+
}
|
|
1297
|
+
const resolved = this.resolveExpressionValue(expr, new Map());
|
|
1298
|
+
if (resolved == null) return '';
|
|
1299
|
+
return decodeHtmlEntities(String(resolved).replace(/\s+/g, ' ').trim());
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (Node.isJsxElement(node)) {
|
|
1303
|
+
const includeComponentSubtrees = options && options.includeComponentSubtrees === false ? false : true;
|
|
1304
|
+
if (!includeComponentSubtrees) {
|
|
1305
|
+
const tagName = node.getOpeningElement().getTagNameNode().getText();
|
|
1306
|
+
if (/^[A-Z]/.test(tagName)) {
|
|
1307
|
+
return '';
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const parts: string[] = [];
|
|
1311
|
+
const children = node.getJsxChildren();
|
|
1312
|
+
for (const child of children) {
|
|
1313
|
+
const text = this.extractLiteralTextFromJsxNode(child, options);
|
|
1314
|
+
if (text) parts.push(text);
|
|
1315
|
+
}
|
|
1316
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
return '';
|
|
1320
|
+
}
|
|
1321
|
+
|
|
836
1322
|
private extractDecoratorWrappers(
|
|
837
1323
|
decoratorsProp: Node | undefined
|
|
838
1324
|
): Array<{ tagName: string; classes: string[] }> {
|
|
@@ -906,7 +1392,7 @@ export class ComponentScanner {
|
|
|
906
1392
|
|
|
907
1393
|
private extractLayoutClassesFromRoot(root: Node): { tagName: string; classes: string[] } | null {
|
|
908
1394
|
let tagName = '';
|
|
909
|
-
let attrs:
|
|
1395
|
+
let attrs: JsxAttribute[] = [];
|
|
910
1396
|
if (Node.isJsxElement(root)) {
|
|
911
1397
|
tagName = root.getOpeningElement().getTagNameNode().getText();
|
|
912
1398
|
attrs = root.getOpeningElement().getDescendantsOfKind(SyntaxKind.JsxAttribute);
|
|
@@ -916,7 +1402,7 @@ export class ComponentScanner {
|
|
|
916
1402
|
}
|
|
917
1403
|
if (!ComponentScanner.DECORATOR_BLOCK_TAGS.has(tagName.toLowerCase())) return null;
|
|
918
1404
|
const classAttr = attrs.find(a => {
|
|
919
|
-
const nameNode =
|
|
1405
|
+
const nameNode = a.getNameNode();
|
|
920
1406
|
return nameNode ? nameNode.getText() === 'className' : false;
|
|
921
1407
|
});
|
|
922
1408
|
if (!classAttr) return null;
|
|
@@ -933,7 +1419,11 @@ export class ComponentScanner {
|
|
|
933
1419
|
|
|
934
1420
|
if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
|
|
935
1421
|
importBasePath = path.resolve(baseDir, moduleSpec);
|
|
936
|
-
} else if (moduleSpec.startsWith('~/')) {
|
|
1422
|
+
} else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
|
|
1423
|
+
// Both ~/* and @/* are common conventions for the `src/*` path alias in
|
|
1424
|
+
// Next.js projects. inkbridge uses ~/, inkbridge-starter uses @/. Treat
|
|
1425
|
+
// them identically so the scanner resolves component imports in either
|
|
1426
|
+
// layout.
|
|
937
1427
|
importBasePath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
|
|
938
1428
|
}
|
|
939
1429
|
|
|
@@ -993,7 +1483,8 @@ export class ComponentScanner {
|
|
|
993
1483
|
node: Node,
|
|
994
1484
|
localComponents: Map<string, Node>,
|
|
995
1485
|
relativeImports: Map<string, string> = new Map(),
|
|
996
|
-
propsContext: Map<string,
|
|
1486
|
+
propsContext: Map<string, ResolvedExpressionValue> = new Map(),
|
|
1487
|
+
providerStack: ReadonlyArray<Record<string, ResolvedExpressionValue>> = []
|
|
997
1488
|
): JsxNode {
|
|
998
1489
|
if (Node.isJsxElement(node)) {
|
|
999
1490
|
const rawTagName = node.getOpeningElement().getTagNameNode().getText();
|
|
@@ -1002,40 +1493,116 @@ export class ComponentScanner {
|
|
|
1002
1493
|
const props = this.extractPropsFromNode(node.getOpeningElement(), rawTagName, propsContext).props;
|
|
1003
1494
|
const children: JsxNode[] = [];
|
|
1004
1495
|
|
|
1496
|
+
// If this is a component invocation whose body wraps children in
|
|
1497
|
+
// `<*.Provider value={{ ... }}>`, pre-resolve the cascade values
|
|
1498
|
+
// from this invocation's props and push them onto the providerStack
|
|
1499
|
+
// so descendants pick them up as defaults. This statically models
|
|
1500
|
+
// the React Context cascade pattern shadcn uses for `ToggleGroup`'s
|
|
1501
|
+
// variant/size, `RadioGroup`'s value, etc.
|
|
1502
|
+
let childProviderStack: ReadonlyArray<Record<string, ResolvedExpressionValue>> = providerStack;
|
|
1503
|
+
if (/^[A-Z]/.test(rawTagName)) {
|
|
1504
|
+
const cascadeNames = this.detectProviderCascadeNamesForComponent(rawTagName, localComponents, relativeImports);
|
|
1505
|
+
if (cascadeNames.size > 0) {
|
|
1506
|
+
const earlyEvaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
|
|
1507
|
+
const cascadeValues: Record<string, ResolvedExpressionValue> = {};
|
|
1508
|
+
let hasAny = false;
|
|
1509
|
+
for (const name of cascadeNames) {
|
|
1510
|
+
if (earlyEvaluatedProps[name] !== undefined) {
|
|
1511
|
+
cascadeValues[name] = earlyEvaluatedProps[name];
|
|
1512
|
+
hasAny = true;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
if (hasAny) {
|
|
1516
|
+
childProviderStack = providerStack.concat([cascadeValues]);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1005
1521
|
for (const child of node.getJsxChildren()) {
|
|
1006
1522
|
if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
|
|
1007
|
-
const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext);
|
|
1008
|
-
if ((childNode as
|
|
1009
|
-
|
|
1523
|
+
const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext, childProviderStack);
|
|
1524
|
+
if ((childNode as JsxNode & PortalSkipFlagged).__portalSkip) continue;
|
|
1525
|
+
this.pushFlatteningTransparentProvider(children, childNode);
|
|
1010
1526
|
} else if (Node.isJsxText(child)) {
|
|
1011
|
-
const text = decodeHtmlEntities(child.
|
|
1527
|
+
const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
|
|
1012
1528
|
if (text) {
|
|
1013
1529
|
children.push({ type: 'text', content: text });
|
|
1014
1530
|
}
|
|
1015
1531
|
} else if (Node.isJsxExpression(child)) {
|
|
1016
1532
|
const expr = child.getExpression();
|
|
1017
|
-
if (expr && Node.
|
|
1533
|
+
if (expr && Node.isBinaryExpression(expr)) {
|
|
1534
|
+
const op = expr.getOperatorToken().getText();
|
|
1535
|
+
if (op === '??') {
|
|
1536
|
+
const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
1537
|
+
const chosen = (leftValue === undefined || leftValue === null)
|
|
1538
|
+
? expr.getRight()
|
|
1539
|
+
: expr.getLeft();
|
|
1540
|
+
this.pushChosenJsxBranch(chosen, children, localComponents, relativeImports, propsContext);
|
|
1541
|
+
} else if (op === '&&') {
|
|
1542
|
+
// Short-circuit: render the right side only when the left is
|
|
1543
|
+
// statically truthy. If the left can't be resolved, skip — the
|
|
1544
|
+
// design-system render shouldn't speculate about runtime state.
|
|
1545
|
+
const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
1546
|
+
if (this.isExpressionTruthy(leftValue)) {
|
|
1547
|
+
this.pushChosenJsxBranch(expr.getRight(), children, localComponents, relativeImports, propsContext);
|
|
1548
|
+
}
|
|
1549
|
+
} else if (op === '||') {
|
|
1550
|
+
const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
1551
|
+
if (this.isExpressionTruthy(leftValue)) {
|
|
1552
|
+
this.pushChosenJsxBranch(expr.getLeft(), children, localComponents, relativeImports, propsContext);
|
|
1553
|
+
} else if (leftValue !== undefined) {
|
|
1554
|
+
this.pushChosenJsxBranch(expr.getRight(), children, localComponents, relativeImports, propsContext);
|
|
1555
|
+
}
|
|
1556
|
+
// Unresolvable left → skip
|
|
1557
|
+
}
|
|
1558
|
+
} else if (expr && Node.isConditionalExpression(expr)) {
|
|
1559
|
+
// Ternary `cond ? a : b` — pick a branch when the condition is
|
|
1560
|
+
// statically resolvable. Same conservative policy as `&&`/`||`:
|
|
1561
|
+
// unresolvable condition skips both branches.
|
|
1562
|
+
const condValue = this.resolveExpressionValue(expr.getCondition(), propsContext);
|
|
1563
|
+
if (condValue !== undefined) {
|
|
1564
|
+
const chosen = this.isExpressionTruthy(condValue)
|
|
1565
|
+
? expr.getWhenTrue()
|
|
1566
|
+
: expr.getWhenFalse();
|
|
1567
|
+
this.pushChosenJsxBranch(chosen, children, localComponents, relativeImports, propsContext);
|
|
1568
|
+
}
|
|
1569
|
+
} else if (expr && Node.isStringLiteral(expr)) {
|
|
1018
1570
|
children.push({ type: 'text', content: expr.getLiteralValue() });
|
|
1019
1571
|
} else if (expr && Node.isCallExpression(expr)) {
|
|
1020
|
-
//
|
|
1572
|
+
// .map() calls expand into multiple children; every other
|
|
1573
|
+
// CallExpression (e.g. `{rewardSol.toFixed(4)}`) is resolved
|
|
1574
|
+
// via `resolveExpressionValue` which knows how to evaluate
|
|
1575
|
+
// common Number/String methods against the prop values in
|
|
1576
|
+
// `propsContext`. Without this fallback the expression was
|
|
1577
|
+
// silently dropped and args-based stories rendered the
|
|
1578
|
+
// component shell with the runtime values missing.
|
|
1021
1579
|
const callExpr = expr.getExpression();
|
|
1022
1580
|
if (Node.isPropertyAccessExpression(callExpr) && callExpr.getName() === 'map') {
|
|
1023
|
-
// Try to expand the .map() call, passing propsContext for array resolution
|
|
1024
1581
|
const expandedChildren = this.expandMapCall(expr, node.getSourceFile(), localComponents, relativeImports, propsContext);
|
|
1025
1582
|
if (expandedChildren.length > 0) {
|
|
1026
1583
|
children.push(...expandedChildren);
|
|
1027
1584
|
}
|
|
1585
|
+
} else {
|
|
1586
|
+
const resolved = this.resolveExpressionValue(expr, propsContext);
|
|
1587
|
+
if (resolved !== undefined && resolved !== null) {
|
|
1588
|
+
this.pushResolvedPropValue(children, resolved);
|
|
1589
|
+
}
|
|
1028
1590
|
}
|
|
1029
1591
|
} else if (expr && Node.isIdentifier(expr) && propsContext.has(expr.getText())) {
|
|
1030
1592
|
this.pushResolvedPropValue(children, propsContext.get(expr.getText()));
|
|
1031
1593
|
} else if (expr && Node.isIdentifier(expr)) {
|
|
1032
|
-
//
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1594
|
+
// Delegate to resolveExpressionValue so function-body-local `const`
|
|
1595
|
+
// declarations (e.g. `const statusText = sourceLabel === "db" ? ...`)
|
|
1596
|
+
// resolve the same way they do for className identifiers. Without
|
|
1597
|
+
// this, a JSX text child like `<span>{statusText}</span>` falls
|
|
1598
|
+
// through to the "unresolved" branch and renders nothing — even
|
|
1599
|
+
// when the local const's initializer would resolve fine against
|
|
1600
|
+
// the current propsContext. Same scope-walk fix as the className
|
|
1601
|
+
// path; the bug only shows on JSX *children* because the children
|
|
1602
|
+
// pipeline used a custom (module-level-only) lookup here.
|
|
1603
|
+
const resolved = this.resolveExpressionValue(expr, propsContext);
|
|
1604
|
+
if (resolved !== undefined && resolved !== null) {
|
|
1605
|
+
this.pushResolvedPropValue(children, resolved);
|
|
1039
1606
|
}
|
|
1040
1607
|
} else if (expr && Node.isPropertyAccessExpression(expr)) {
|
|
1041
1608
|
// Resolve {plan.name} style expressions when `plan` is an object in propsContext
|
|
@@ -1060,12 +1627,87 @@ export class ComponentScanner {
|
|
|
1060
1627
|
}
|
|
1061
1628
|
}
|
|
1062
1629
|
|
|
1630
|
+
// Selection-match pressed-state injection: when an element has a
|
|
1631
|
+
// `defaultValue` (or `value`) prop containing an array or string,
|
|
1632
|
+
// mark each child whose own `value` prop matches with
|
|
1633
|
+
// `defaultPressed="true"`. The variant engine's data-pressed alias
|
|
1634
|
+
// then activates `data-[pressed]:*` CSS at render time. Mirrors
|
|
1635
|
+
// base-ui's ToggleGroup / RadioGroup runtime behaviour, which
|
|
1636
|
+
// compares the group's value(s) against each item's value and
|
|
1637
|
+
// pushes `data-pressed`/`data-checked` to the matching items.
|
|
1638
|
+
//
|
|
1639
|
+
// Only fires when defaultValue/value is a parsed array or string —
|
|
1640
|
+
// i.e. the scanner could statically resolve the prop. Dynamic
|
|
1641
|
+
// values stay unhandled (acceptable for design-system previews).
|
|
1642
|
+
const selectionDefault =
|
|
1643
|
+
(props as Record<string, unknown>).defaultValue
|
|
1644
|
+
?? (props as Record<string, unknown>).value;
|
|
1645
|
+
if (selectionDefault !== undefined && selectionDefault !== null) {
|
|
1646
|
+
const acceptableArray = Array.isArray(selectionDefault)
|
|
1647
|
+
? selectionDefault.map((v) => String(v))
|
|
1648
|
+
: (typeof selectionDefault === 'string' || typeof selectionDefault === 'number')
|
|
1649
|
+
? [String(selectionDefault)]
|
|
1650
|
+
: null;
|
|
1651
|
+
if (acceptableArray && acceptableArray.length > 0) {
|
|
1652
|
+
for (let i = 0; i < children.length; i++) {
|
|
1653
|
+
const child = children[i];
|
|
1654
|
+
if (!child || child.type !== 'element' || !child.props) continue;
|
|
1655
|
+
const childValue = (child.props as Record<string, unknown>).value;
|
|
1656
|
+
if (childValue === undefined || childValue === null) continue;
|
|
1657
|
+
if (acceptableArray.indexOf(String(childValue)) === -1) continue;
|
|
1658
|
+
if (child.props.defaultPressed !== undefined || child.props['data-pressed'] !== undefined) continue;
|
|
1659
|
+
child.props.defaultPressed = 'true';
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// React Context.Provider is a non-rendering wrapper (it only sets a
|
|
1665
|
+
// context value). Unwrap to its single child so the plugin doesn't
|
|
1666
|
+
// emit an empty frame around the actual content. Common after
|
|
1667
|
+
// expanding shadcn primitives like <FormItem> →
|
|
1668
|
+
// <FormItemContext.Provider><div/></FormItemContext.Provider>.
|
|
1669
|
+
if (rawTagName.endsWith('.Provider') && children.length === 1 && children[0].type === 'element') {
|
|
1670
|
+
return children[0];
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1063
1673
|
if (/^[A-Z]/.test(rawTagName)) {
|
|
1064
1674
|
const evaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
|
|
1675
|
+
// Apply provider-cascade defaults: for each provider value pushed
|
|
1676
|
+
// by an ancestor invocation (see childProviderStack assembly
|
|
1677
|
+
// above), inject any prop the invocation hasn't already set. This
|
|
1678
|
+
// is the static analog of React's `useContext` — at runtime
|
|
1679
|
+
// descendants would read the context value; here we propagate it
|
|
1680
|
+
// through the expansion's resolved props instead.
|
|
1681
|
+
if (providerStack.length > 0) {
|
|
1682
|
+
for (let i = 0; i < providerStack.length; i++) {
|
|
1683
|
+
const provider = providerStack[i];
|
|
1684
|
+
for (const key in provider) {
|
|
1685
|
+
if (!Object.prototype.hasOwnProperty.call(provider, key)) continue;
|
|
1686
|
+
if (evaluatedProps[key] === undefined) {
|
|
1687
|
+
evaluatedProps[key] = provider[key];
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1065
1692
|
if (children.length > 0) {
|
|
1066
1693
|
evaluatedProps.children = children;
|
|
1067
1694
|
}
|
|
1068
1695
|
|
|
1696
|
+
// Pre-expansion: a callback-style render prop (e.g. shadcn's <FormField
|
|
1697
|
+
// render={({ field }) => <FormItem/>}>) becomes invisible after
|
|
1698
|
+
// expansion because the wrapper expands to a Controller-like internal
|
|
1699
|
+
// tree that has no Trigger leaf for attachRenderPropToExpanded to fill.
|
|
1700
|
+
// Use the callback's JSX directly instead.
|
|
1701
|
+
const callbackContent = this.extractRenderPropCallbackJsxNode(
|
|
1702
|
+
node.getOpeningElement(),
|
|
1703
|
+
localComponents,
|
|
1704
|
+
relativeImports,
|
|
1705
|
+
propsContext
|
|
1706
|
+
);
|
|
1707
|
+
if (callbackContent) {
|
|
1708
|
+
return callbackContent;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1069
1711
|
const localDef = localComponents.get(rawTagName);
|
|
1070
1712
|
if (localDef) {
|
|
1071
1713
|
const expandedLocal = this.expandLocalComponentWithProps(localDef, evaluatedProps, localComponents, relativeImports);
|
|
@@ -1075,26 +1717,89 @@ export class ComponentScanner {
|
|
|
1075
1717
|
}
|
|
1076
1718
|
|
|
1077
1719
|
const importedFilePath = relativeImports.get(rawTagName);
|
|
1078
|
-
const SKIP_EXPANSION_MAIN = ['Skeleton'
|
|
1720
|
+
const SKIP_EXPANSION_MAIN = ['Skeleton'];
|
|
1079
1721
|
if (importedFilePath && !SKIP_EXPANSION_MAIN.includes(rawTagName)) {
|
|
1080
|
-
const resolvedProps = new Map<string,
|
|
1722
|
+
const resolvedProps = new Map<string, ResolvedExpressionValue>();
|
|
1081
1723
|
for (const [key, value] of Object.entries(evaluatedProps)) {
|
|
1082
1724
|
resolvedProps.set(key, value);
|
|
1083
1725
|
}
|
|
1084
1726
|
const expandedImported = this.expandImportedComponent(rawTagName, importedFilePath, relativeImports, resolvedProps);
|
|
1085
1727
|
if (expandedImported) {
|
|
1086
1728
|
if (this.isPortalRootedNode(expandedImported)) {
|
|
1087
|
-
|
|
1729
|
+
// Unwrap portal: return the content node(s) inside the portal (skipping overlays).
|
|
1730
|
+
// This allows stories to use <SheetContent>, <DialogContent> etc. directly.
|
|
1731
|
+
// Nodes are marked __fromPortal so the plugin can filter them in trigger-only stories.
|
|
1732
|
+
const contentNodes = this.extractPortalContentNodes(expandedImported);
|
|
1733
|
+
if (contentNodes.length > 0) {
|
|
1734
|
+
const contentNode: JsxElement = contentNodes.length === 1
|
|
1735
|
+
? contentNodes[0] as JsxElement
|
|
1736
|
+
: { type: 'element', tagName: 'div', isComponent: false, props: {}, children: contentNodes };
|
|
1737
|
+
// Strip portal-positioning classes before marking — fixed/translate/z-index/
|
|
1738
|
+
// animate etc. are meaningless in Figma and cause width/layout bugs.
|
|
1739
|
+
const cleanedClassName = this.stripPortalPositioningClasses(contentNode.props?.className || '');
|
|
1740
|
+
contentNode.props = Object.assign({}, contentNode.props || {}, {
|
|
1741
|
+
__fromPortal: true,
|
|
1742
|
+
className: cleanedClassName,
|
|
1743
|
+
});
|
|
1744
|
+
return contentNode as JsxNode;
|
|
1745
|
+
}
|
|
1746
|
+
return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as JsxElement & PortalSkipFlagged;
|
|
1088
1747
|
}
|
|
1089
1748
|
if (!this.isUnresolvedExpandedComponent(expandedImported, localComponents, relativeImports)) {
|
|
1749
|
+
this.attachRenderPropToExpanded(
|
|
1750
|
+
expandedImported,
|
|
1751
|
+
node.getOpeningElement(),
|
|
1752
|
+
localComponents,
|
|
1753
|
+
relativeImports,
|
|
1754
|
+
propsContext
|
|
1755
|
+
);
|
|
1090
1756
|
return expandedImported;
|
|
1091
1757
|
}
|
|
1758
|
+
// Expansion produced an unresolved wrapper (e.g. <Slot> from
|
|
1759
|
+
// @radix-ui — used internally by shadcn's <FormControl>). If the
|
|
1760
|
+
// user wrapped exactly one child and added no visual className,
|
|
1761
|
+
// the wrapper is a pass-through. Drop it and return the child so
|
|
1762
|
+
// the inner primitive (e.g. <Input>) renders normally.
|
|
1763
|
+
if (this.isPassThroughInvocation(props, children)) {
|
|
1764
|
+
return children[0];
|
|
1765
|
+
}
|
|
1766
|
+
// Otherwise: the expansion may still contain useful content
|
|
1767
|
+
// beneath an unresolved root wrapper (e.g. <Form><form>...).
|
|
1768
|
+
// Walk down through the unresolved single-child wrappers and
|
|
1769
|
+
// use the resolved inner node so the story tree gets the real
|
|
1770
|
+
// content instead of the bare `<Component/>` placeholder.
|
|
1771
|
+
const innerImported = this.unwrapUnresolvedSingleChildRoots(
|
|
1772
|
+
expandedImported,
|
|
1773
|
+
localComponents,
|
|
1774
|
+
relativeImports
|
|
1775
|
+
);
|
|
1776
|
+
if (innerImported && innerImported !== expandedImported) {
|
|
1777
|
+
this.attachRenderPropToExpanded(
|
|
1778
|
+
innerImported,
|
|
1779
|
+
node.getOpeningElement(),
|
|
1780
|
+
localComponents,
|
|
1781
|
+
relativeImports,
|
|
1782
|
+
propsContext
|
|
1783
|
+
);
|
|
1784
|
+
return innerImported;
|
|
1785
|
+
}
|
|
1092
1786
|
}
|
|
1093
1787
|
}
|
|
1094
1788
|
}
|
|
1095
1789
|
|
|
1096
|
-
|
|
1790
|
+
if (children.length === 0) {
|
|
1791
|
+
const renderChild = this.extractRenderPropJsxNode(
|
|
1792
|
+
node.getOpeningElement(),
|
|
1793
|
+
localComponents,
|
|
1794
|
+
relativeImports,
|
|
1795
|
+
propsContext
|
|
1796
|
+
);
|
|
1797
|
+
if (renderChild) children.push(renderChild);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
|
|
1097
1801
|
delete outputProps.children;
|
|
1802
|
+
delete outputProps.render;
|
|
1098
1803
|
return {
|
|
1099
1804
|
type: 'element',
|
|
1100
1805
|
tagName,
|
|
@@ -1108,11 +1813,37 @@ export class ComponentScanner {
|
|
|
1108
1813
|
const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
|
|
1109
1814
|
const props = this.extractPropsFromNode(node, rawTagName, propsContext).props;
|
|
1110
1815
|
const evaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
|
|
1816
|
+
// Apply provider-cascade defaults: see the JsxElement branch above
|
|
1817
|
+
// for the full rationale. Self-closing elements are the common
|
|
1818
|
+
// shape for cascade-receiving items (e.g. `<ToggleGroupItem />`
|
|
1819
|
+
// inside a `<ToggleGroup variant="outline">`).
|
|
1820
|
+
if (providerStack.length > 0) {
|
|
1821
|
+
for (let i = 0; i < providerStack.length; i++) {
|
|
1822
|
+
const provider = providerStack[i];
|
|
1823
|
+
for (const key in provider) {
|
|
1824
|
+
if (!Object.prototype.hasOwnProperty.call(provider, key)) continue;
|
|
1825
|
+
if (evaluatedProps[key] === undefined) {
|
|
1826
|
+
evaluatedProps[key] = provider[key];
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1111
1831
|
const children: JsxNode[] = [];
|
|
1112
1832
|
if (Object.prototype.hasOwnProperty.call(evaluatedProps, 'children')) {
|
|
1113
1833
|
this.pushResolvedPropValue(children, evaluatedProps.children);
|
|
1114
1834
|
}
|
|
1115
1835
|
|
|
1836
|
+
// Pre-expansion: see JsxElement branch above for rationale.
|
|
1837
|
+
const callbackContent = this.extractRenderPropCallbackJsxNode(
|
|
1838
|
+
node,
|
|
1839
|
+
localComponents,
|
|
1840
|
+
relativeImports,
|
|
1841
|
+
propsContext
|
|
1842
|
+
);
|
|
1843
|
+
if (callbackContent) {
|
|
1844
|
+
return callbackContent;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1116
1847
|
// Check if this is a local component that should be expanded
|
|
1117
1848
|
const localDef = localComponents.get(rawTagName);
|
|
1118
1849
|
if (localDef) {
|
|
@@ -1125,10 +1856,10 @@ export class ComponentScanner {
|
|
|
1125
1856
|
// Check if this is an imported component from a relative path
|
|
1126
1857
|
// Skip expansion for simple components that the ui-builder handles directly
|
|
1127
1858
|
// (e.g., Skeleton uses clsx with props that we can't statically evaluate)
|
|
1128
|
-
const SKIP_EXPANSION_MAIN = ['Skeleton'
|
|
1859
|
+
const SKIP_EXPANSION_MAIN = ['Skeleton'];
|
|
1129
1860
|
const importedFilePath = relativeImports.get(rawTagName);
|
|
1130
1861
|
if (importedFilePath && !SKIP_EXPANSION_MAIN.includes(rawTagName)) {
|
|
1131
|
-
const resolvedProps = new Map<string,
|
|
1862
|
+
const resolvedProps = new Map<string, ResolvedExpressionValue>();
|
|
1132
1863
|
for (const [key, value] of Object.entries(evaluatedProps)) {
|
|
1133
1864
|
resolvedProps.set(key, value);
|
|
1134
1865
|
}
|
|
@@ -1136,16 +1867,70 @@ export class ComponentScanner {
|
|
|
1136
1867
|
const expandedJsx = this.expandImportedComponent(rawTagName, importedFilePath, relativeImports, resolvedProps);
|
|
1137
1868
|
if (expandedJsx) {
|
|
1138
1869
|
if (this.isPortalRootedNode(expandedJsx)) {
|
|
1139
|
-
|
|
1870
|
+
const contentNodes = this.extractPortalContentNodes(expandedJsx);
|
|
1871
|
+
if (contentNodes.length > 0) {
|
|
1872
|
+
const contentNode: JsxElement = contentNodes.length === 1
|
|
1873
|
+
? contentNodes[0] as JsxElement
|
|
1874
|
+
: { type: 'element', tagName: 'div', isComponent: false, props: {}, children: contentNodes };
|
|
1875
|
+
const cleanedClassName = this.stripPortalPositioningClasses(contentNode.props?.className || '');
|
|
1876
|
+
contentNode.props = Object.assign({}, contentNode.props || {}, {
|
|
1877
|
+
__fromPortal: true,
|
|
1878
|
+
className: cleanedClassName,
|
|
1879
|
+
});
|
|
1880
|
+
return contentNode as JsxNode;
|
|
1881
|
+
}
|
|
1882
|
+
return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as JsxElement & PortalSkipFlagged;
|
|
1140
1883
|
}
|
|
1141
1884
|
if (!this.isUnresolvedExpandedComponent(expandedJsx, localComponents, relativeImports)) {
|
|
1885
|
+
// Base UI asChild pattern: the outer JSX may have `render={<Button/>}`
|
|
1886
|
+
// which expandImportedComponent loses through the {...props} spread
|
|
1887
|
+
// (extractPropsFromNode captured `render` as a text string). Pull
|
|
1888
|
+
// the JSX value off the outer AST and inject it as the deepest
|
|
1889
|
+
// empty trigger's child so the trigger content shows in Figma.
|
|
1890
|
+
this.attachRenderPropToExpanded(
|
|
1891
|
+
expandedJsx,
|
|
1892
|
+
node,
|
|
1893
|
+
localComponents,
|
|
1894
|
+
relativeImports,
|
|
1895
|
+
propsContext
|
|
1896
|
+
);
|
|
1142
1897
|
return expandedJsx;
|
|
1143
1898
|
}
|
|
1899
|
+
// Pass-through wrapper unwrap: see JsxElement branch above.
|
|
1900
|
+
if (this.isPassThroughInvocation(props, children)) {
|
|
1901
|
+
return children[0];
|
|
1902
|
+
}
|
|
1903
|
+
const innerSelfClosing = this.unwrapUnresolvedSingleChildRoots(
|
|
1904
|
+
expandedJsx,
|
|
1905
|
+
localComponents,
|
|
1906
|
+
relativeImports
|
|
1907
|
+
);
|
|
1908
|
+
if (innerSelfClosing && innerSelfClosing !== expandedJsx) {
|
|
1909
|
+
this.attachRenderPropToExpanded(
|
|
1910
|
+
innerSelfClosing,
|
|
1911
|
+
node,
|
|
1912
|
+
localComponents,
|
|
1913
|
+
relativeImports,
|
|
1914
|
+
propsContext
|
|
1915
|
+
);
|
|
1916
|
+
return innerSelfClosing;
|
|
1917
|
+
}
|
|
1144
1918
|
}
|
|
1145
1919
|
}
|
|
1146
1920
|
|
|
1147
|
-
|
|
1921
|
+
if (children.length === 0) {
|
|
1922
|
+
const renderChild = this.extractRenderPropJsxNode(
|
|
1923
|
+
node,
|
|
1924
|
+
localComponents,
|
|
1925
|
+
relativeImports,
|
|
1926
|
+
propsContext
|
|
1927
|
+
);
|
|
1928
|
+
if (renderChild) children.push(renderChild);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
|
|
1148
1932
|
delete outputProps.children;
|
|
1933
|
+
delete outputProps.render;
|
|
1149
1934
|
return {
|
|
1150
1935
|
type: 'element',
|
|
1151
1936
|
tagName,
|
|
@@ -1153,12 +1938,176 @@ export class ComponentScanner {
|
|
|
1153
1938
|
props: outputProps,
|
|
1154
1939
|
children,
|
|
1155
1940
|
};
|
|
1941
|
+
} else if (Node.isJsxFragment(node)) {
|
|
1942
|
+
// `<>...</>` — no wrapper element. Emit an element with an empty tagName
|
|
1943
|
+
// so the NodeIR converter collapses it into a transparent fragment node.
|
|
1944
|
+
const children: JsxNode[] = [];
|
|
1945
|
+
for (const child of node.getJsxChildren()) {
|
|
1946
|
+
if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child) || Node.isJsxFragment(child)) {
|
|
1947
|
+
const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext);
|
|
1948
|
+
if ((childNode as JsxNode & PortalSkipFlagged).__portalSkip) continue;
|
|
1949
|
+
children.push(childNode);
|
|
1950
|
+
} else if (Node.isJsxText(child)) {
|
|
1951
|
+
const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
|
|
1952
|
+
if (text) children.push({ type: 'text', content: text });
|
|
1953
|
+
} else if (Node.isJsxExpression(child)) {
|
|
1954
|
+
const expr = child.getExpression();
|
|
1955
|
+
if (expr && Node.isCallExpression(expr)) {
|
|
1956
|
+
const mapped = this.expandMapCall(expr, node.getSourceFile(), localComponents, relativeImports, propsContext);
|
|
1957
|
+
for (const m of mapped) children.push(m);
|
|
1958
|
+
} else if (expr) {
|
|
1959
|
+
const resolved = this.resolveExpressionValue(expr, propsContext);
|
|
1960
|
+
if (resolved !== undefined && resolved !== null) {
|
|
1961
|
+
this.pushResolvedPropValue(children, resolved);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
return {
|
|
1967
|
+
type: 'element',
|
|
1968
|
+
tagName: '',
|
|
1969
|
+
isComponent: false,
|
|
1970
|
+
props: {},
|
|
1971
|
+
children,
|
|
1972
|
+
};
|
|
1156
1973
|
}
|
|
1157
1974
|
|
|
1158
1975
|
// Fallback for unexpected node types
|
|
1159
1976
|
return { type: 'text', content: '' };
|
|
1160
1977
|
}
|
|
1161
1978
|
|
|
1979
|
+
/**
|
|
1980
|
+
* Walk an expanded JSX tree and inject the outer `render` prop's JSX
|
|
1981
|
+
* value into the first empty trigger-like descendant. Used when the outer
|
|
1982
|
+
* call site has `render={<Button/>}` but expansion flattens it away through
|
|
1983
|
+
* {...props} spread — extractPropsFromNode can only capture the raw source
|
|
1984
|
+
* text, so we parse the JSX off the original AST and graft it onto the
|
|
1985
|
+
* expansion result.
|
|
1986
|
+
*/
|
|
1987
|
+
private attachRenderPropToExpanded(
|
|
1988
|
+
expanded: JsxNode,
|
|
1989
|
+
outerAstNode: Node,
|
|
1990
|
+
localComponents: Map<string, Node>,
|
|
1991
|
+
relativeImports: Map<string, string>,
|
|
1992
|
+
propsContext: Map<string, ResolvedExpressionValue>
|
|
1993
|
+
): void {
|
|
1994
|
+
const renderChild = this.extractRenderPropJsxNode(
|
|
1995
|
+
outerAstNode,
|
|
1996
|
+
localComponents,
|
|
1997
|
+
relativeImports,
|
|
1998
|
+
propsContext
|
|
1999
|
+
);
|
|
2000
|
+
if (!renderChild) return;
|
|
2001
|
+
const target = this.findFirstEmptyTriggerNode(expanded);
|
|
2002
|
+
if (target) {
|
|
2003
|
+
target.children = [renderChild];
|
|
2004
|
+
if (target.props) {
|
|
2005
|
+
delete target.props.render;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
private findFirstEmptyTriggerNode(node: JsxNode): JsxElement | null {
|
|
2011
|
+
if (!node || node.type !== 'element') return null;
|
|
2012
|
+
const el = node;
|
|
2013
|
+
const tag = String(el.tagName || '').toLowerCase();
|
|
2014
|
+
if (tag.endsWith('trigger') && (!el.children || el.children.length === 0)) {
|
|
2015
|
+
return el;
|
|
2016
|
+
}
|
|
2017
|
+
if (Array.isArray(el.children)) {
|
|
2018
|
+
for (let i = 0; i < el.children.length; i++) {
|
|
2019
|
+
const found = this.findFirstEmptyTriggerNode(el.children[i]);
|
|
2020
|
+
if (found) return found;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return null;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/**
|
|
2027
|
+
* Like `extractRenderPropJsxNode` but only matches *callback*-style render
|
|
2028
|
+
* props (arrow functions / function expressions). Used as a pre-expansion
|
|
2029
|
+
* guard: if a component has a callback render prop, expansion of the
|
|
2030
|
+
* wrapper would discard the render content (see <FormField> from
|
|
2031
|
+
* react-hook-form: it expands to `<FormFieldContext.Provider><Controller/></...>`,
|
|
2032
|
+
* and `attachRenderPropToExpanded` only injects literals into Trigger leaves).
|
|
2033
|
+
* Returning the callback's JSX directly skips expansion and matches React's
|
|
2034
|
+
* actual visual output.
|
|
2035
|
+
*/
|
|
2036
|
+
private extractRenderPropCallbackJsxNode(
|
|
2037
|
+
openingOrSelfClosing: Node,
|
|
2038
|
+
localComponents: Map<string, Node>,
|
|
2039
|
+
relativeImports: Map<string, string>,
|
|
2040
|
+
propsContext: Map<string, ResolvedExpressionValue>
|
|
2041
|
+
): JsxNode | null {
|
|
2042
|
+
const attrs =
|
|
2043
|
+
Node.isJsxOpeningElement(openingOrSelfClosing) || Node.isJsxSelfClosingElement(openingOrSelfClosing)
|
|
2044
|
+
? openingOrSelfClosing.getAttributes()
|
|
2045
|
+
: [];
|
|
2046
|
+
for (const attr of attrs) {
|
|
2047
|
+
if (!Node.isJsxAttribute(attr)) continue;
|
|
2048
|
+
const nameNode = attr.getNameNode();
|
|
2049
|
+
if (!nameNode || nameNode.getText() !== 'render') continue;
|
|
2050
|
+
const init = attr.getInitializer();
|
|
2051
|
+
if (!init || !Node.isJsxExpression(init)) continue;
|
|
2052
|
+
let expr = init.getExpression();
|
|
2053
|
+
if (!expr) continue;
|
|
2054
|
+
if (Node.isParenthesizedExpression(expr)) expr = expr.getExpression();
|
|
2055
|
+
if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) {
|
|
2056
|
+
const built = this.extractJsxTreeFromFunctionBody(expr, relativeImports, localComponents, propsContext);
|
|
2057
|
+
if (built && !(built as JsxNode & PortalSkipFlagged).__portalSkip) return built;
|
|
2058
|
+
}
|
|
2059
|
+
return null;
|
|
2060
|
+
}
|
|
2061
|
+
return null;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* Components can expose a `render` prop in two shapes:
|
|
2066
|
+
*
|
|
2067
|
+
* 1. JSX literal (Base UI / Radix asChild pattern):
|
|
2068
|
+
* <PopoverPrimitive.Trigger render={<Button>Open</Button>} />
|
|
2069
|
+
*
|
|
2070
|
+
* 2. Render-prop callback (react-hook-form's <FormField>, headless UI patterns):
|
|
2071
|
+
* <FormField render={({ field }) => (<FormItem>...</FormItem>)} />
|
|
2072
|
+
*
|
|
2073
|
+
* Extract the inner JSX in either case so the plugin can render the wrapper's
|
|
2074
|
+
* actual content. For callbacks the inner-scope arguments (e.g. `field`) are
|
|
2075
|
+
* not resolvable, so the JSX is rendered with whatever literal props it has.
|
|
2076
|
+
*/
|
|
2077
|
+
private extractRenderPropJsxNode(
|
|
2078
|
+
openingOrSelfClosing: Node,
|
|
2079
|
+
localComponents: Map<string, Node>,
|
|
2080
|
+
relativeImports: Map<string, string>,
|
|
2081
|
+
propsContext: Map<string, ResolvedExpressionValue>
|
|
2082
|
+
): JsxNode | null {
|
|
2083
|
+
const attrs =
|
|
2084
|
+
Node.isJsxOpeningElement(openingOrSelfClosing) || Node.isJsxSelfClosingElement(openingOrSelfClosing)
|
|
2085
|
+
? openingOrSelfClosing.getAttributes()
|
|
2086
|
+
: [];
|
|
2087
|
+
for (const attr of attrs) {
|
|
2088
|
+
if (!Node.isJsxAttribute(attr)) continue;
|
|
2089
|
+
const nameNode = attr.getNameNode();
|
|
2090
|
+
if (!nameNode || nameNode.getText() !== 'render') continue;
|
|
2091
|
+
const init = attr.getInitializer();
|
|
2092
|
+
if (!init || !Node.isJsxExpression(init)) continue;
|
|
2093
|
+
let expr = init.getExpression();
|
|
2094
|
+
if (!expr) continue;
|
|
2095
|
+
if (Node.isParenthesizedExpression(expr)) expr = expr.getExpression();
|
|
2096
|
+
if (Node.isJsxElement(expr) || Node.isJsxSelfClosingElement(expr) || Node.isJsxFragment(expr)) {
|
|
2097
|
+
const built = this.buildJsxTree(expr, localComponents, relativeImports, propsContext);
|
|
2098
|
+
if (built && !(built as JsxNode & PortalSkipFlagged).__portalSkip) return built;
|
|
2099
|
+
return null;
|
|
2100
|
+
}
|
|
2101
|
+
if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) {
|
|
2102
|
+
const built = this.extractJsxTreeFromFunctionBody(expr, relativeImports, localComponents, propsContext);
|
|
2103
|
+
if (built && !(built as JsxNode & PortalSkipFlagged).__portalSkip) return built;
|
|
2104
|
+
return null;
|
|
2105
|
+
}
|
|
2106
|
+
return null;
|
|
2107
|
+
}
|
|
2108
|
+
return null;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1162
2111
|
private cloneJsxNode(node: JsxNode): JsxNode {
|
|
1163
2112
|
if (node.type === 'text') {
|
|
1164
2113
|
return { type: 'text', content: (node as JsxText).content };
|
|
@@ -1177,7 +2126,7 @@ export class ComponentScanner {
|
|
|
1177
2126
|
};
|
|
1178
2127
|
}
|
|
1179
2128
|
|
|
1180
|
-
private pushResolvedPropValue(children: JsxNode[], value:
|
|
2129
|
+
private pushResolvedPropValue(children: JsxNode[], value: ResolvedExpressionValue): void {
|
|
1181
2130
|
if (value == null || value === '') return;
|
|
1182
2131
|
if (Array.isArray(value)) {
|
|
1183
2132
|
for (let i = 0; i < value.length; i++) {
|
|
@@ -1201,9 +2150,9 @@ export class ComponentScanner {
|
|
|
1201
2150
|
private resolveInvocationProps(
|
|
1202
2151
|
props: Record<string, string>,
|
|
1203
2152
|
sourceFile: SourceFile,
|
|
1204
|
-
propsContext: Map<string,
|
|
1205
|
-
): Record<string,
|
|
1206
|
-
const resolved: Record<string,
|
|
2153
|
+
propsContext: Map<string, ResolvedExpressionValue>
|
|
2154
|
+
): Record<string, ResolvedExpressionValue> {
|
|
2155
|
+
const resolved: Record<string, ResolvedExpressionValue> = {};
|
|
1207
2156
|
for (const [propName, propValue] of Object.entries(props)) {
|
|
1208
2157
|
if (typeof propValue !== 'string') {
|
|
1209
2158
|
resolved[propName] = propValue;
|
|
@@ -1231,8 +2180,18 @@ export class ComponentScanner {
|
|
|
1231
2180
|
* For a normal param (`item`): adds `item → itemValue` so `item.selected` resolves.
|
|
1232
2181
|
* For destructured params (`__destructured__`): adds each key directly.
|
|
1233
2182
|
*/
|
|
1234
|
-
private buildItemPropsContext(
|
|
1235
|
-
|
|
2183
|
+
private buildItemPropsContext(
|
|
2184
|
+
itemParamName: string,
|
|
2185
|
+
itemValue: ResolvedExpressionValue,
|
|
2186
|
+
extraContext?: Map<string, ResolvedExpressionValue>
|
|
2187
|
+
): Map<string, ResolvedExpressionValue> {
|
|
2188
|
+
const ctx = new Map<string, ResolvedExpressionValue>();
|
|
2189
|
+
// Start with any outer context (propsContext from map callbacks, etc.) so
|
|
2190
|
+
// the callback body can resolve identifiers that aren't the item itself
|
|
2191
|
+
// (e.g. `widths`, `WIDTH_CLASSNAMES`, the iteration index).
|
|
2192
|
+
if (extraContext) {
|
|
2193
|
+
for (const [k, v] of extraContext) ctx.set(k, v);
|
|
2194
|
+
}
|
|
1236
2195
|
if (!itemParamName || itemValue == null) return ctx;
|
|
1237
2196
|
if (itemParamName === '__destructured__') {
|
|
1238
2197
|
if (typeof itemValue === 'object') {
|
|
@@ -1245,13 +2204,13 @@ export class ComponentScanner {
|
|
|
1245
2204
|
}
|
|
1246
2205
|
|
|
1247
2206
|
private resolveSubstitutionProps(
|
|
1248
|
-
props: Record<string,
|
|
2207
|
+
props: Record<string, ResolvedExpressionValue>,
|
|
1249
2208
|
itemParamName: string,
|
|
1250
|
-
itemValue:
|
|
1251
|
-
): Record<string,
|
|
1252
|
-
const resolved: Record<string,
|
|
2209
|
+
itemValue: ResolvedExpressionValue
|
|
2210
|
+
): Record<string, ResolvedExpressionValue> {
|
|
2211
|
+
const resolved: Record<string, ResolvedExpressionValue> = {};
|
|
1253
2212
|
for (const [propName, propValue] of Object.entries(props)) {
|
|
1254
|
-
if (propValue.startsWith(itemParamName + '.')) {
|
|
2213
|
+
if (typeof propValue === 'string' && propValue.startsWith(itemParamName + '.')) {
|
|
1255
2214
|
const propPath = propValue.slice(itemParamName.length + 1);
|
|
1256
2215
|
resolved[propName] = itemValue && typeof itemValue === 'object'
|
|
1257
2216
|
? itemValue[propPath]
|
|
@@ -1304,7 +2263,7 @@ export class ComponentScanner {
|
|
|
1304
2263
|
sourceFile: SourceFile,
|
|
1305
2264
|
localComponents: Map<string, Node>,
|
|
1306
2265
|
relativeImports: Map<string, string>,
|
|
1307
|
-
propsContext: Map<string,
|
|
2266
|
+
propsContext: Map<string, ResolvedExpressionValue> = new Map()
|
|
1308
2267
|
): JsxNode[] {
|
|
1309
2268
|
const results: JsxNode[] = [];
|
|
1310
2269
|
|
|
@@ -1315,9 +2274,55 @@ export class ComponentScanner {
|
|
|
1315
2274
|
const propAccess = callExpr.getExpression();
|
|
1316
2275
|
if (!Node.isPropertyAccessExpression(propAccess)) return results;
|
|
1317
2276
|
|
|
1318
|
-
// Get the array expression (may be a variable name or an inline array literal)
|
|
1319
|
-
|
|
1320
|
-
|
|
2277
|
+
// Get the array expression (may be a variable name or an inline array literal).
|
|
2278
|
+
// Unwrap ParenthesizedExpression and AsExpression so patterns like
|
|
2279
|
+
// `(["a", "b"] as const).map(...)` resolve to the inline array literal.
|
|
2280
|
+
let arrayExpr: Node = propAccess.getExpression();
|
|
2281
|
+
while (
|
|
2282
|
+
Node.isParenthesizedExpression(arrayExpr)
|
|
2283
|
+
|| Node.isAsExpression(arrayExpr)
|
|
2284
|
+
|| Node.isTypeAssertion(arrayExpr)
|
|
2285
|
+
|| Node.isNonNullExpression(arrayExpr)
|
|
2286
|
+
) {
|
|
2287
|
+
arrayExpr = arrayExpr.getExpression();
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// Detect `Array.from({ length: N })` as a synthetic array source. Used
|
|
2291
|
+
// by patterns like `Array.from({length: lines}).map((_, i) => <Row>)`.
|
|
2292
|
+
// We only care about the length; items are placeholder nulls and the
|
|
2293
|
+
// callback must derive values from `index` or surrounding propsContext.
|
|
2294
|
+
let syntheticArrayLength: number | null = null;
|
|
2295
|
+
if (Node.isCallExpression(arrayExpr)) {
|
|
2296
|
+
const arrayFromCallee = arrayExpr.getExpression();
|
|
2297
|
+
if (
|
|
2298
|
+
Node.isPropertyAccessExpression(arrayFromCallee)
|
|
2299
|
+
&& arrayFromCallee.getExpression().getText() === 'Array'
|
|
2300
|
+
&& arrayFromCallee.getName() === 'from'
|
|
2301
|
+
) {
|
|
2302
|
+
const fromArgs = arrayExpr.getArguments();
|
|
2303
|
+
const firstArg = fromArgs[0];
|
|
2304
|
+
if (firstArg && Node.isObjectLiteralExpression(firstArg)) {
|
|
2305
|
+
for (const prop of firstArg.getProperties()) {
|
|
2306
|
+
if (!Node.isPropertyAssignment(prop)) continue;
|
|
2307
|
+
const key = prop.getName();
|
|
2308
|
+
if (key !== 'length') continue;
|
|
2309
|
+
const init = prop.getInitializer();
|
|
2310
|
+
if (!init) continue;
|
|
2311
|
+
const resolved = this.resolveExpressionValue(init, propsContext);
|
|
2312
|
+
const num = typeof resolved === 'number'
|
|
2313
|
+
? resolved
|
|
2314
|
+
: (typeof resolved === 'string' && /^\d+$/.test(resolved) ? parseInt(resolved, 10) : NaN);
|
|
2315
|
+
if (Number.isFinite(num) && num >= 0) {
|
|
2316
|
+
syntheticArrayLength = Math.min(num as number, 7);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const arrayName = Node.isArrayLiteralExpression(arrayExpr)
|
|
2324
|
+
? '__inline__'
|
|
2325
|
+
: (syntheticArrayLength != null ? '__synthetic__' : arrayExpr.getText());
|
|
1321
2326
|
|
|
1322
2327
|
// Get the map callback function
|
|
1323
2328
|
const args = callExpr.getArguments();
|
|
@@ -1349,6 +2354,17 @@ export class ComponentScanner {
|
|
|
1349
2354
|
itemParamName = params[0].getName();
|
|
1350
2355
|
}
|
|
1351
2356
|
|
|
2357
|
+
// Second parameter is the iteration index (`(item, index)` or `(_, i)`).
|
|
2358
|
+
// Pattern is common for callbacks that derive per-iteration values from
|
|
2359
|
+
// the index — e.g. `widths[index % widths.length]` in SkeletonText.
|
|
2360
|
+
let indexParamName: string | null = null;
|
|
2361
|
+
if (params.length >= 2) {
|
|
2362
|
+
const secondNameNode = params[1].getNameNode();
|
|
2363
|
+
if (secondNameNode && Node.isIdentifier(secondNameNode)) {
|
|
2364
|
+
indexParamName = secondNameNode.getText();
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
1352
2368
|
// Find the JSX in the callback body
|
|
1353
2369
|
const body = callback.getBody();
|
|
1354
2370
|
if (!body) return results;
|
|
@@ -1380,11 +2396,36 @@ export class ComponentScanner {
|
|
|
1380
2396
|
|
|
1381
2397
|
if (!callbackJsx) return results;
|
|
1382
2398
|
|
|
2399
|
+
// Collect `const X = <expr>;` declarations in the callback body so they
|
|
2400
|
+
// are available as additional context per iteration (e.g.
|
|
2401
|
+
// `const width = widths[index % widths.length];`). The initializers are
|
|
2402
|
+
// re-evaluated per iteration because they often depend on `index`.
|
|
2403
|
+
const callbackConstDecls: { name: string; initializer: Node }[] = [];
|
|
2404
|
+
if (Node.isBlock(body)) {
|
|
2405
|
+
const statements = body.getStatements();
|
|
2406
|
+
for (const stmt of statements) {
|
|
2407
|
+
if (!Node.isVariableStatement(stmt)) continue;
|
|
2408
|
+
const declList = stmt.getDeclarationList();
|
|
2409
|
+
for (const decl of declList.getDeclarations()) {
|
|
2410
|
+
const nameNode = decl.getNameNode();
|
|
2411
|
+
if (!Node.isIdentifier(nameNode)) continue;
|
|
2412
|
+
const init = decl.getInitializer();
|
|
2413
|
+
if (!init) continue;
|
|
2414
|
+
callbackConstDecls.push({ name: nameNode.getText(), initializer: init });
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
1383
2419
|
// Find the array's value - inline literal first, then propsContext, then source file
|
|
1384
|
-
let arrayValue:
|
|
2420
|
+
let arrayValue: ResolvedExpressionValue[] | null = null;
|
|
2421
|
+
|
|
2422
|
+
// Synthetic array from `Array.from({length: N})` — items are placeholders.
|
|
2423
|
+
if (arrayName === '__synthetic__' && syntheticArrayLength != null) {
|
|
2424
|
+
arrayValue = new Array(syntheticArrayLength).fill(null);
|
|
2425
|
+
}
|
|
1385
2426
|
|
|
1386
2427
|
// Inline array literal: [{ ... }, ...].map(...)
|
|
1387
|
-
if (arrayName === '__inline__' && Node.isArrayLiteralExpression(arrayExpr)) {
|
|
2428
|
+
if (!arrayValue && arrayName === '__inline__' && Node.isArrayLiteralExpression(arrayExpr)) {
|
|
1388
2429
|
arrayValue = this.parseArrayLiteral(arrayExpr);
|
|
1389
2430
|
}
|
|
1390
2431
|
|
|
@@ -1431,18 +2472,41 @@ export class ComponentScanner {
|
|
|
1431
2472
|
let item = arrayValue[i];
|
|
1432
2473
|
// When the callback uses destructuring, flatten the item to use local names as keys
|
|
1433
2474
|
if (destructuringBindings && typeof item === 'object' && item !== null) {
|
|
1434
|
-
const flatItem: Record<string,
|
|
2475
|
+
const flatItem: Record<string, ResolvedExpressionValue> = {};
|
|
1435
2476
|
for (const [localName, sourceKey] of destructuringBindings) {
|
|
1436
2477
|
flatItem[localName] = item[sourceKey];
|
|
1437
2478
|
}
|
|
1438
2479
|
item = flatItem;
|
|
1439
2480
|
}
|
|
2481
|
+
// Carry the outer propsContext plus the iteration index through the
|
|
2482
|
+
// recursion so dynamic per-line expressions (e.g. `widths[index % N]`,
|
|
2483
|
+
// `WIDTH_CLASSNAMES[width]`) can resolve against the caller's props.
|
|
2484
|
+
const iterationContext = new Map<string, ResolvedExpressionValue>(propsContext);
|
|
2485
|
+
if (indexParamName) {
|
|
2486
|
+
iterationContext.set(indexParamName, i);
|
|
2487
|
+
}
|
|
2488
|
+
// Include the callback's local `const` declarations (e.g.
|
|
2489
|
+
// `const width = widths[index % widths.length];`) so the JSX body
|
|
2490
|
+
// can reference them. Evaluated per-iteration because they usually
|
|
2491
|
+
// depend on `index` or the current item.
|
|
2492
|
+
if (itemParamName && itemParamName !== '__destructured__' && item != null) {
|
|
2493
|
+
iterationContext.set(itemParamName, item);
|
|
2494
|
+
} else if (itemParamName === '__destructured__' && typeof item === 'object' && item !== null) {
|
|
2495
|
+
for (const [k, v] of Object.entries(item)) iterationContext.set(k, v);
|
|
2496
|
+
}
|
|
2497
|
+
for (const decl of callbackConstDecls) {
|
|
2498
|
+
const resolved = this.resolveExpressionValue(decl.initializer, iterationContext);
|
|
2499
|
+
if (resolved !== undefined) {
|
|
2500
|
+
iterationContext.set(decl.name, resolved);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
1440
2503
|
const itemJsx = this.buildJsxTreeWithSubstitution(
|
|
1441
2504
|
callbackJsx,
|
|
1442
2505
|
itemParamName,
|
|
1443
2506
|
item,
|
|
1444
2507
|
mergedLocalComponents,
|
|
1445
|
-
relativeImports
|
|
2508
|
+
relativeImports,
|
|
2509
|
+
iterationContext
|
|
1446
2510
|
);
|
|
1447
2511
|
if (itemJsx) {
|
|
1448
2512
|
results.push(itemJsx);
|
|
@@ -1459,27 +2523,51 @@ export class ComponentScanner {
|
|
|
1459
2523
|
* Find the value of an array variable in the source file.
|
|
1460
2524
|
* Looks for const declarations and default prop values.
|
|
1461
2525
|
*/
|
|
1462
|
-
private findArrayValue(arrayName: string, sourceFile: SourceFile):
|
|
2526
|
+
private findArrayValue(arrayName: string, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
|
|
1463
2527
|
// Check cache first
|
|
1464
2528
|
const cacheKey = `${sourceFile.getFilePath()}:${arrayName}`;
|
|
1465
2529
|
if (this.arrayValueCache.has(cacheKey)) {
|
|
1466
2530
|
return this.arrayValueCache.get(cacheKey)!;
|
|
1467
2531
|
}
|
|
1468
2532
|
|
|
1469
|
-
// Look for const
|
|
1470
|
-
|
|
2533
|
+
// Look for `const features = [...]` style declarations. Walks ALL
|
|
2534
|
+
// variable statements in the source file (top-level AND inside
|
|
2535
|
+
// function bodies) so locally-scoped arrays like
|
|
2536
|
+
// `function BlockTable() { const sortedBlocks = [...blocks].sort(...); ... }`
|
|
2537
|
+
// resolve too — the canonical case is greenhouse-app's block-table
|
|
2538
|
+
// where the .map source is a local sort over an imported JSON file.
|
|
2539
|
+
//
|
|
2540
|
+
// Initializer may be a direct array literal, a spread literal
|
|
2541
|
+
// (`[...other]`), a method chain on an existing array
|
|
2542
|
+
// (`[...blocks].sort(...)`, `data.filter(...).slice(0, N)`), or
|
|
2543
|
+
// simply an identifier that points at another array (re-export).
|
|
2544
|
+
// All of those routes funnel into `resolveArrayFromExpression`.
|
|
2545
|
+
const allVarStatements = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement);
|
|
2546
|
+
for (const varStmt of allVarStatements) {
|
|
1471
2547
|
for (const decl of varStmt.getDeclarationList().getDeclarations()) {
|
|
1472
2548
|
if (decl.getName() === arrayName) {
|
|
1473
|
-
const init = this.unwrapStaticValueExpression(decl.getInitializer());
|
|
1474
|
-
if (init
|
|
1475
|
-
const value = this.
|
|
1476
|
-
|
|
1477
|
-
|
|
2549
|
+
const init = this.unwrapStaticValueExpression(decl.getInitializer()) || decl.getInitializer();
|
|
2550
|
+
if (init) {
|
|
2551
|
+
const value = this.resolveArrayFromExpression(init, sourceFile);
|
|
2552
|
+
if (value) {
|
|
2553
|
+
this.arrayValueCache.set(cacheKey, value);
|
|
2554
|
+
return value;
|
|
2555
|
+
}
|
|
1478
2556
|
}
|
|
1479
2557
|
}
|
|
1480
2558
|
}
|
|
1481
2559
|
}
|
|
1482
2560
|
|
|
2561
|
+
// If the name is a default import of a `.json` file, load it from
|
|
2562
|
+
// disk. Covers patterns like `import data from '~/constants/blocks.json'`
|
|
2563
|
+
// followed by `.map(item => …)` over the imported array (block-table
|
|
2564
|
+
// is the canonical case in greenhouse-app).
|
|
2565
|
+
const jsonImported = this.resolveImportedJsonValue(arrayName, sourceFile);
|
|
2566
|
+
if (Array.isArray(jsonImported)) {
|
|
2567
|
+
this.arrayValueCache.set(cacheKey, jsonImported);
|
|
2568
|
+
return jsonImported;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
1483
2571
|
// Look for default parameter value in function: function Comp({ features = defaultFeatures })
|
|
1484
2572
|
for (const func of sourceFile.getFunctions()) {
|
|
1485
2573
|
const params = func.getParameters();
|
|
@@ -1542,13 +2630,107 @@ export class ComponentScanner {
|
|
|
1542
2630
|
return null;
|
|
1543
2631
|
}
|
|
1544
2632
|
|
|
2633
|
+
/**
|
|
2634
|
+
* Resolve an expression to an array of items. Handles:
|
|
2635
|
+
*
|
|
2636
|
+
* - `[a, b, c]` — direct array literal (parsed verbatim)
|
|
2637
|
+
* - `[...items]` — spread of another array (resolved
|
|
2638
|
+
* recursively against the source file)
|
|
2639
|
+
* - `items.sort(...)` — array-returning method chains
|
|
2640
|
+
* (`sort`, `filter`, `slice`, `reverse`,
|
|
2641
|
+
* `toSorted`, `toReversed`, `with`) are
|
|
2642
|
+
* unwrapped to the receiver. We do not
|
|
2643
|
+
* *apply* the operation — for design-
|
|
2644
|
+
* system rendering, any non-empty sample
|
|
2645
|
+
* of items is good enough.
|
|
2646
|
+
* - identifier — recurses through `findArrayValue`
|
|
2647
|
+
* (local var) and JSON-import resolution.
|
|
2648
|
+
*
|
|
2649
|
+
* Returns `null` when nothing resolvable is found; the caller then
|
|
2650
|
+
* falls back to its placeholder-iteration path.
|
|
2651
|
+
*/
|
|
2652
|
+
private resolveArrayFromExpression(expr: Node, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
|
|
2653
|
+
const unwrapped = this.unwrapStaticValueExpression(expr) || expr;
|
|
2654
|
+
|
|
2655
|
+
if (Node.isArrayLiteralExpression(unwrapped)) {
|
|
2656
|
+
const elements = unwrapped.getElements();
|
|
2657
|
+
if (elements.length === 1 && Node.isSpreadElement(elements[0])) {
|
|
2658
|
+
// `[...items]` — unwrap to `items` and resolve that.
|
|
2659
|
+
const spreadInner = (elements[0] as import('ts-morph').SpreadElement).getExpression();
|
|
2660
|
+
return this.resolveArrayFromExpression(spreadInner, sourceFile);
|
|
2661
|
+
}
|
|
2662
|
+
return this.parseArrayLiteral(unwrapped);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
if (Node.isCallExpression(unwrapped)) {
|
|
2666
|
+
const callee = unwrapped.getExpression();
|
|
2667
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
2668
|
+
const methodName = callee.getName();
|
|
2669
|
+
const PASS_THROUGH_METHODS = new Set([
|
|
2670
|
+
'sort', 'filter', 'slice', 'reverse',
|
|
2671
|
+
'toSorted', 'toReversed', 'with', 'concat', 'flat', 'flatMap',
|
|
2672
|
+
]);
|
|
2673
|
+
if (PASS_THROUGH_METHODS.has(methodName)) {
|
|
2674
|
+
return this.resolveArrayFromExpression(callee.getExpression(), sourceFile);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
if (Node.isIdentifier(unwrapped)) {
|
|
2680
|
+
const name = unwrapped.getText();
|
|
2681
|
+
const fromCache = this.findArrayValue(name, sourceFile);
|
|
2682
|
+
if (fromCache) return fromCache;
|
|
2683
|
+
const fromJson = this.resolveImportedJsonValue(name, sourceFile);
|
|
2684
|
+
if (Array.isArray(fromJson)) return fromJson;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
return null;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/**
|
|
2691
|
+
* If `name` is a default-imported JSON file in `sourceFile` (e.g.
|
|
2692
|
+
* `import blocks from '~/constants/blocks.json'`), load + parse the
|
|
2693
|
+
* file from disk and return the value. Returns `null` for any other
|
|
2694
|
+
* import shape (named imports, non-JSON files, unresolvable paths).
|
|
2695
|
+
*
|
|
2696
|
+
* Path-alias resolution piggybacks on the existing
|
|
2697
|
+
* `resolveImportedComponentPath`, plus a JSON-only branch that probes
|
|
2698
|
+
* the literal path (without `.tsx`/`.ts` candidates).
|
|
2699
|
+
*/
|
|
2700
|
+
private resolveImportedJsonValue(name: string, sourceFile: SourceFile): unknown {
|
|
2701
|
+
const fileDir = path.dirname(sourceFile.getFilePath());
|
|
2702
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
2703
|
+
const defaultImport = imp.getDefaultImport();
|
|
2704
|
+
if (!defaultImport || defaultImport.getText() !== name) continue;
|
|
2705
|
+
const moduleSpec = imp.getModuleSpecifierValue();
|
|
2706
|
+
if (!moduleSpec.endsWith('.json')) return null;
|
|
2707
|
+
|
|
2708
|
+
let absPath: string | null = null;
|
|
2709
|
+
if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
|
|
2710
|
+
absPath = path.resolve(fileDir, moduleSpec);
|
|
2711
|
+
} else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
|
|
2712
|
+
absPath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
|
|
2713
|
+
} else {
|
|
2714
|
+
return null;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
if (!fs.existsSync(absPath)) return null;
|
|
2718
|
+
try {
|
|
2719
|
+
const raw = fs.readFileSync(absPath, 'utf-8');
|
|
2720
|
+
return JSON.parse(raw);
|
|
2721
|
+
} catch (_e) {
|
|
2722
|
+
return null;
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
return null;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
1545
2728
|
/**
|
|
1546
2729
|
* Unwrap syntax wrappers around static values so declarations like
|
|
1547
2730
|
* `const FAQS = [...] as const` still resolve to the underlying literal.
|
|
1548
2731
|
*/
|
|
1549
2732
|
private unwrapStaticValueExpression(expr: Node | undefined): Node | undefined {
|
|
1550
2733
|
let current = expr;
|
|
1551
|
-
const nodeApi = Node as any;
|
|
1552
2734
|
|
|
1553
2735
|
while (current) {
|
|
1554
2736
|
if (Node.isParenthesizedExpression(current)) {
|
|
@@ -1559,11 +2741,11 @@ export class ComponentScanner {
|
|
|
1559
2741
|
current = current.getExpression();
|
|
1560
2742
|
continue;
|
|
1561
2743
|
}
|
|
1562
|
-
if (
|
|
2744
|
+
if (Node.isTypeAssertion(current)) {
|
|
1563
2745
|
current = current.getExpression();
|
|
1564
2746
|
continue;
|
|
1565
2747
|
}
|
|
1566
|
-
if (
|
|
2748
|
+
if (Node.isSatisfiesExpression(current)) {
|
|
1567
2749
|
current = current.getExpression();
|
|
1568
2750
|
continue;
|
|
1569
2751
|
}
|
|
@@ -1576,14 +2758,14 @@ export class ComponentScanner {
|
|
|
1576
2758
|
/**
|
|
1577
2759
|
* Parse an array literal expression into JavaScript values.
|
|
1578
2760
|
*/
|
|
1579
|
-
private parseArrayLiteral(arrayLit: Node):
|
|
1580
|
-
const result:
|
|
2761
|
+
private parseArrayLiteral(arrayLit: Node): ResolvedExpressionValue[] {
|
|
2762
|
+
const result: ResolvedExpressionValue[] = [];
|
|
1581
2763
|
|
|
1582
2764
|
if (!Node.isArrayLiteralExpression(arrayLit)) return result;
|
|
1583
2765
|
|
|
1584
2766
|
for (const element of arrayLit.getElements()) {
|
|
1585
2767
|
if (Node.isObjectLiteralExpression(element)) {
|
|
1586
|
-
const obj: Record<string,
|
|
2768
|
+
const obj: Record<string, ResolvedExpressionValue> = {};
|
|
1587
2769
|
for (const prop of element.getProperties()) {
|
|
1588
2770
|
if (Node.isPropertyAssignment(prop)) {
|
|
1589
2771
|
const name = prop.getName();
|
|
@@ -1621,40 +2803,59 @@ export class ComponentScanner {
|
|
|
1621
2803
|
private buildJsxTreeWithSubstitution(
|
|
1622
2804
|
node: Node,
|
|
1623
2805
|
itemParamName: string,
|
|
1624
|
-
itemValue:
|
|
2806
|
+
itemValue: ResolvedExpressionValue,
|
|
1625
2807
|
localComponents: Map<string, Node>,
|
|
1626
|
-
relativeImports: Map<string, string
|
|
2808
|
+
relativeImports: Map<string, string>,
|
|
2809
|
+
extraContext?: Map<string, ResolvedExpressionValue>
|
|
1627
2810
|
): JsxNode | null {
|
|
1628
2811
|
if (Node.isJsxElement(node)) {
|
|
1629
2812
|
const rawTagName = node.getOpeningElement().getTagNameNode().getText();
|
|
1630
2813
|
const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
|
|
1631
|
-
const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue);
|
|
2814
|
+
const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue, extraContext);
|
|
1632
2815
|
const props = this.extractPropsFromNode(node.getOpeningElement(), rawTagName, itemPropsContext).props;
|
|
1633
2816
|
const children: JsxNode[] = [];
|
|
1634
2817
|
|
|
1635
2818
|
for (const child of node.getJsxChildren()) {
|
|
1636
2819
|
if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
|
|
1637
|
-
const childJsx = this.buildJsxTreeWithSubstitution(child, itemParamName, itemValue, localComponents, relativeImports);
|
|
2820
|
+
const childJsx = this.buildJsxTreeWithSubstitution(child, itemParamName, itemValue, localComponents, relativeImports, extraContext);
|
|
1638
2821
|
if (childJsx) children.push(childJsx);
|
|
1639
2822
|
} else if (Node.isJsxText(child)) {
|
|
1640
|
-
const text = decodeHtmlEntities(child.
|
|
2823
|
+
const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
|
|
1641
2824
|
if (text) {
|
|
1642
2825
|
children.push({ type: 'text', content: text });
|
|
1643
2826
|
}
|
|
1644
2827
|
} else if (Node.isJsxExpression(child)) {
|
|
1645
2828
|
const expr = child.getExpression();
|
|
1646
2829
|
if (expr) {
|
|
2830
|
+
if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '??') {
|
|
2831
|
+
let chosen: Node = expr.getLeft();
|
|
2832
|
+
const leftValue = this.substituteExpression(expr.getLeft(), itemParamName, itemValue, extraContext);
|
|
2833
|
+
if (leftValue === null || leftValue === undefined) {
|
|
2834
|
+
chosen = expr.getRight();
|
|
2835
|
+
}
|
|
2836
|
+
if (Node.isParenthesizedExpression(chosen)) {
|
|
2837
|
+
chosen = chosen.getExpression();
|
|
2838
|
+
}
|
|
2839
|
+
if (Node.isJsxElement(chosen) || Node.isJsxSelfClosingElement(chosen)) {
|
|
2840
|
+
const jsxChild = this.buildJsxTreeWithSubstitution(chosen, itemParamName, itemValue, localComponents, relativeImports, extraContext);
|
|
2841
|
+
if (jsxChild) children.push(jsxChild);
|
|
2842
|
+
} else {
|
|
2843
|
+
const substituted = this.substituteExpression(chosen, itemParamName, itemValue, extraContext);
|
|
2844
|
+
if (substituted !== null && substituted !== undefined && substituted !== '') {
|
|
2845
|
+
children.push({ type: 'text', content: String(substituted) });
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
1647
2848
|
// Handle conditional JSX: {condition && <JSX>} or {condition && (<JSX>)}
|
|
1648
|
-
if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '&&') {
|
|
2849
|
+
} else if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '&&') {
|
|
1649
2850
|
const left = expr.getLeft();
|
|
1650
2851
|
let right = expr.getRight();
|
|
1651
2852
|
// Unwrap parenthesized expression
|
|
1652
2853
|
if (Node.isParenthesizedExpression(right)) {
|
|
1653
2854
|
right = right.getExpression();
|
|
1654
2855
|
}
|
|
1655
|
-
const condValue = this.substituteExpression(left, itemParamName, itemValue);
|
|
2856
|
+
const condValue = this.substituteExpression(left, itemParamName, itemValue, extraContext);
|
|
1656
2857
|
if (condValue && (Node.isJsxElement(right) || Node.isJsxSelfClosingElement(right))) {
|
|
1657
|
-
const jsxChild = this.buildJsxTreeWithSubstitution(right, itemParamName, itemValue, localComponents, relativeImports);
|
|
2858
|
+
const jsxChild = this.buildJsxTreeWithSubstitution(right, itemParamName, itemValue, localComponents, relativeImports, extraContext);
|
|
1658
2859
|
if (jsxChild) children.push(jsxChild);
|
|
1659
2860
|
}
|
|
1660
2861
|
// Handle ternary JSX: {condition ? <JSX1> : <JSX2>}
|
|
@@ -1669,20 +2870,40 @@ export class ComponentScanner {
|
|
|
1669
2870
|
if (Node.isParenthesizedExpression(whenFalse)) {
|
|
1670
2871
|
whenFalse = whenFalse.getExpression();
|
|
1671
2872
|
}
|
|
1672
|
-
const condValue = this.substituteExpression(condition, itemParamName, itemValue);
|
|
2873
|
+
const condValue = this.substituteExpression(condition, itemParamName, itemValue, extraContext);
|
|
1673
2874
|
const branch = condValue ? whenTrue : whenFalse;
|
|
1674
2875
|
if (Node.isJsxElement(branch) || Node.isJsxSelfClosingElement(branch)) {
|
|
1675
|
-
const jsxChild = this.buildJsxTreeWithSubstitution(branch, itemParamName, itemValue, localComponents, relativeImports);
|
|
2876
|
+
const jsxChild = this.buildJsxTreeWithSubstitution(branch, itemParamName, itemValue, localComponents, relativeImports, extraContext);
|
|
1676
2877
|
if (jsxChild) children.push(jsxChild);
|
|
1677
2878
|
} else {
|
|
1678
2879
|
// Non-JSX ternary result - substitute as text
|
|
1679
|
-
const substituted = this.substituteExpression(branch, itemParamName, itemValue);
|
|
2880
|
+
const substituted = this.substituteExpression(branch, itemParamName, itemValue, extraContext);
|
|
1680
2881
|
if (substituted !== null && substituted !== undefined && substituted !== '') {
|
|
1681
2882
|
children.push({ type: 'text', content: String(substituted) });
|
|
1682
2883
|
}
|
|
1683
2884
|
}
|
|
2885
|
+
} else if (Node.isCallExpression(expr)) {
|
|
2886
|
+
// Support nested `.map()` calls inside iteration callbacks so
|
|
2887
|
+
// patterns like Array.from({length}).map(...) expand through the
|
|
2888
|
+
// current propsContext (items/index) to resolve dynamic classes.
|
|
2889
|
+
const callee = expr.getExpression();
|
|
2890
|
+
const isMapCall = Node.isPropertyAccessExpression(callee)
|
|
2891
|
+
&& callee.getName() === 'map';
|
|
2892
|
+
if (isMapCall) {
|
|
2893
|
+
const nestedPropsContext = new Map<string, ResolvedExpressionValue>(extraContext);
|
|
2894
|
+
if (itemParamName && itemParamName !== '__destructured__' && itemValue != null) {
|
|
2895
|
+
nestedPropsContext.set(itemParamName, itemValue);
|
|
2896
|
+
}
|
|
2897
|
+
const nestedChildren = this.expandMapCall(expr, node.getSourceFile(), localComponents, relativeImports, nestedPropsContext);
|
|
2898
|
+
for (const nested of nestedChildren) children.push(nested);
|
|
2899
|
+
continue;
|
|
2900
|
+
}
|
|
2901
|
+
const substituted = this.substituteExpression(expr, itemParamName, itemValue, extraContext);
|
|
2902
|
+
if (substituted !== null && substituted !== undefined && substituted !== '') {
|
|
2903
|
+
children.push({ type: 'text', content: String(substituted) });
|
|
2904
|
+
}
|
|
1684
2905
|
} else {
|
|
1685
|
-
const substituted = this.substituteExpression(expr, itemParamName, itemValue);
|
|
2906
|
+
const substituted = this.substituteExpression(expr, itemParamName, itemValue, extraContext);
|
|
1686
2907
|
if (substituted !== null && substituted !== undefined && substituted !== '') {
|
|
1687
2908
|
children.push({ type: 'text', content: String(substituted) });
|
|
1688
2909
|
}
|
|
@@ -1711,9 +2932,9 @@ export class ComponentScanner {
|
|
|
1711
2932
|
}
|
|
1712
2933
|
|
|
1713
2934
|
const importedFilePath = relativeImports.get(rawTagName);
|
|
1714
|
-
const SKIP_EXPANSION = ['Skeleton'
|
|
2935
|
+
const SKIP_EXPANSION = ['Skeleton'];
|
|
1715
2936
|
if (importedFilePath && !SKIP_EXPANSION.includes(rawTagName)) {
|
|
1716
|
-
const resolvedProps = new Map<string,
|
|
2937
|
+
const resolvedProps = new Map<string, ResolvedExpressionValue>();
|
|
1717
2938
|
for (const [key, value] of Object.entries(evaluatedProps)) {
|
|
1718
2939
|
resolvedProps.set(key, value);
|
|
1719
2940
|
}
|
|
@@ -1729,7 +2950,7 @@ export class ComponentScanner {
|
|
|
1729
2950
|
}
|
|
1730
2951
|
}
|
|
1731
2952
|
|
|
1732
|
-
const outputProps = { ...props } as Record<string,
|
|
2953
|
+
const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
|
|
1733
2954
|
delete outputProps.children;
|
|
1734
2955
|
return {
|
|
1735
2956
|
type: 'element',
|
|
@@ -1748,7 +2969,7 @@ export class ComponentScanner {
|
|
|
1748
2969
|
rawTagName = itemValue[rawTagName];
|
|
1749
2970
|
}
|
|
1750
2971
|
const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
|
|
1751
|
-
const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue);
|
|
2972
|
+
const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue, extraContext);
|
|
1752
2973
|
const props = this.extractPropsFromNode(node, rawTagName, itemPropsContext).props;
|
|
1753
2974
|
|
|
1754
2975
|
// Check for local component expansion
|
|
@@ -1766,11 +2987,11 @@ export class ComponentScanner {
|
|
|
1766
2987
|
// Check for imported component expansion
|
|
1767
2988
|
// Skip expansion for simple components that the ui-builder handles directly
|
|
1768
2989
|
// (e.g., Skeleton uses clsx with props that we can't statically evaluate)
|
|
1769
|
-
const SKIP_EXPANSION = ['Skeleton'
|
|
2990
|
+
const SKIP_EXPANSION = ['Skeleton'];
|
|
1770
2991
|
const importedFilePath = relativeImports.get(rawTagName);
|
|
1771
2992
|
if (importedFilePath && !SKIP_EXPANSION.includes(rawTagName)) {
|
|
1772
2993
|
const evaluatedProps = this.resolveSubstitutionProps(props, itemParamName, itemValue);
|
|
1773
|
-
const resolvedProps = new Map<string,
|
|
2994
|
+
const resolvedProps = new Map<string, ResolvedExpressionValue>();
|
|
1774
2995
|
for (const [key, value] of Object.entries(evaluatedProps)) {
|
|
1775
2996
|
resolvedProps.set(key, value);
|
|
1776
2997
|
}
|
|
@@ -1780,7 +3001,7 @@ export class ComponentScanner {
|
|
|
1780
3001
|
}
|
|
1781
3002
|
}
|
|
1782
3003
|
|
|
1783
|
-
const outputProps = { ...props } as Record<string,
|
|
3004
|
+
const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
|
|
1784
3005
|
delete outputProps.children;
|
|
1785
3006
|
return {
|
|
1786
3007
|
type: 'element',
|
|
@@ -1800,7 +3021,7 @@ export class ComponentScanner {
|
|
|
1800
3021
|
*/
|
|
1801
3022
|
private expandLocalComponentWithProps(
|
|
1802
3023
|
componentDef: Node,
|
|
1803
|
-
evaluatedProps: Record<string,
|
|
3024
|
+
evaluatedProps: Record<string, ResolvedExpressionValue>,
|
|
1804
3025
|
localComponents: Map<string, Node>,
|
|
1805
3026
|
relativeImports: Map<string, string>
|
|
1806
3027
|
): JsxNode | null {
|
|
@@ -1841,7 +3062,7 @@ export class ComponentScanner {
|
|
|
1841
3062
|
if (Node.isParenthesizedExpression(jsxExpr)) {
|
|
1842
3063
|
jsxExpr = jsxExpr.getExpression();
|
|
1843
3064
|
}
|
|
1844
|
-
if (Node.isJsxElement(jsxExpr) || Node.isJsxSelfClosingElement(jsxExpr)) {
|
|
3065
|
+
if (Node.isJsxElement(jsxExpr) || Node.isJsxSelfClosingElement(jsxExpr) || Node.isJsxFragment(jsxExpr)) {
|
|
1845
3066
|
jsxToExpand = jsxExpr;
|
|
1846
3067
|
break;
|
|
1847
3068
|
}
|
|
@@ -1851,7 +3072,8 @@ export class ComponentScanner {
|
|
|
1851
3072
|
if (!jsxToExpand) {
|
|
1852
3073
|
const jsxElements = componentDef.getDescendantsOfKind(SyntaxKind.JsxElement);
|
|
1853
3074
|
const selfClosing = componentDef.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
|
|
1854
|
-
const
|
|
3075
|
+
const fragments = componentDef.getDescendantsOfKind(SyntaxKind.JsxFragment);
|
|
3076
|
+
const allJsx = [...jsxElements, ...selfClosing, ...fragments];
|
|
1855
3077
|
if (allJsx.length > 0) {
|
|
1856
3078
|
jsxToExpand = allJsx[0];
|
|
1857
3079
|
for (const el of allJsx) {
|
|
@@ -1864,7 +3086,7 @@ export class ComponentScanner {
|
|
|
1864
3086
|
|
|
1865
3087
|
if (!jsxToExpand) return null;
|
|
1866
3088
|
|
|
1867
|
-
const propsMap = new Map<string,
|
|
3089
|
+
const propsMap = new Map<string, ResolvedExpressionValue>();
|
|
1868
3090
|
for (const [key, value] of Object.entries(evaluatedProps)) {
|
|
1869
3091
|
propsMap.set(key, value);
|
|
1870
3092
|
}
|
|
@@ -1876,7 +3098,45 @@ export class ComponentScanner {
|
|
|
1876
3098
|
const nameNode = firstParam.getNameNode?.();
|
|
1877
3099
|
if (nameNode && Node.isIdentifier(nameNode)) {
|
|
1878
3100
|
// Function signature uses a plain `props` identifier.
|
|
1879
|
-
|
|
3101
|
+
const paramName = nameNode.getText();
|
|
3102
|
+
propsMap.set(paramName, { ...evaluatedProps });
|
|
3103
|
+
|
|
3104
|
+
// Many components destructure inside the body with defaults, e.g.
|
|
3105
|
+
// function X(props) { const { lines = 3, widths = DEFAULT_WIDTHS } = props; ... }
|
|
3106
|
+
// Treat these destructurings equivalently to in-signature destructuring
|
|
3107
|
+
// so defaults populate propsMap and are available to the JSX tree.
|
|
3108
|
+
const bodyBlock = Node.isFunctionDeclaration(componentDef)
|
|
3109
|
+
|| Node.isFunctionExpression(componentDef)
|
|
3110
|
+
|| Node.isArrowFunction(componentDef)
|
|
3111
|
+
|| Node.isMethodDeclaration(componentDef)
|
|
3112
|
+
? componentDef.getBody()
|
|
3113
|
+
: undefined;
|
|
3114
|
+
if (bodyBlock && Node.isBlock(bodyBlock)) {
|
|
3115
|
+
for (const stmt of bodyBlock.getStatements()) {
|
|
3116
|
+
if (!Node.isVariableStatement(stmt)) continue;
|
|
3117
|
+
for (const decl of stmt.getDeclarationList().getDeclarations()) {
|
|
3118
|
+
const bindingName = decl.getNameNode();
|
|
3119
|
+
if (!Node.isObjectBindingPattern(bindingName)) continue;
|
|
3120
|
+
const sourceIdent = decl.getInitializer();
|
|
3121
|
+
if (!sourceIdent || sourceIdent.getText() !== paramName) continue;
|
|
3122
|
+
for (const element of bindingName.getElements()) {
|
|
3123
|
+
if (element.getDotDotDotToken()) continue;
|
|
3124
|
+
const sourceKey = element.getPropertyNameNode?.()?.getText() || element.getName();
|
|
3125
|
+
const localName = element.getName();
|
|
3126
|
+
if (Object.prototype.hasOwnProperty.call(evaluatedProps, sourceKey)) {
|
|
3127
|
+
propsMap.set(localName, evaluatedProps[sourceKey]);
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
const defaultInit = this.unwrapStaticValueExpression(element.getInitializer?.());
|
|
3131
|
+
if (!defaultInit) continue;
|
|
3132
|
+
const defaultValue = this.resolveExpressionValue(defaultInit, propsMap);
|
|
3133
|
+
if (defaultValue !== undefined) {
|
|
3134
|
+
propsMap.set(localName, defaultValue);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
1880
3140
|
} else if (nameNode && Node.isObjectBindingPattern(nameNode)) {
|
|
1881
3141
|
const consumedKeys = new Set<string>();
|
|
1882
3142
|
const restBindings: string[] = [];
|
|
@@ -1915,7 +3175,7 @@ export class ComponentScanner {
|
|
|
1915
3175
|
}
|
|
1916
3176
|
|
|
1917
3177
|
if (restBindings.length > 0) {
|
|
1918
|
-
const restObject: Record<string,
|
|
3178
|
+
const restObject: Record<string, ResolvedExpressionValue> = {};
|
|
1919
3179
|
for (const [key, value] of Object.entries(evaluatedProps)) {
|
|
1920
3180
|
if (!consumedKeys.has(key)) {
|
|
1921
3181
|
restObject[key] = value;
|
|
@@ -1931,9 +3191,14 @@ export class ComponentScanner {
|
|
|
1931
3191
|
return this.buildJsxTree(jsxToExpand, localComponents, relativeImports, propsMap);
|
|
1932
3192
|
}
|
|
1933
3193
|
|
|
1934
|
-
private getComponentParameterNodes(componentDef: Node):
|
|
1935
|
-
if (
|
|
1936
|
-
|
|
3194
|
+
private getComponentParameterNodes(componentDef: Node): import('ts-morph').ParameterDeclaration[] {
|
|
3195
|
+
if (
|
|
3196
|
+
Node.isFunctionDeclaration(componentDef)
|
|
3197
|
+
|| Node.isFunctionExpression(componentDef)
|
|
3198
|
+
|| Node.isArrowFunction(componentDef)
|
|
3199
|
+
|| Node.isMethodDeclaration(componentDef)
|
|
3200
|
+
) {
|
|
3201
|
+
return componentDef.getParameters();
|
|
1937
3202
|
}
|
|
1938
3203
|
|
|
1939
3204
|
if (Node.isCallExpression(componentDef)) {
|
|
@@ -1949,13 +3214,296 @@ export class ComponentScanner {
|
|
|
1949
3214
|
|
|
1950
3215
|
/**
|
|
1951
3216
|
* Returns true if the expanded node's root is a portal wrapper (renders outside the DOM tree).
|
|
1952
|
-
* Used to
|
|
3217
|
+
* Used to unwrap components like DialogContent, SelectContent, SheetContent, DropdownMenuContent.
|
|
1953
3218
|
*/
|
|
1954
3219
|
private isPortalRootedNode(node: JsxNode): boolean {
|
|
1955
3220
|
if (node.type !== 'element') return false;
|
|
1956
3221
|
return node.tagName.toLowerCase().includes('portal');
|
|
1957
3222
|
}
|
|
1958
3223
|
|
|
3224
|
+
/**
|
|
3225
|
+
* Extracts the renderable content nodes from inside a portal wrapper,
|
|
3226
|
+
* skipping overlay/backdrop elements that are irrelevant in Figma.
|
|
3227
|
+
*/
|
|
3228
|
+
private extractPortalContentNodes(portalNode: JsxNode): JsxNode[] {
|
|
3229
|
+
if (portalNode.type !== 'element') return [];
|
|
3230
|
+
const el = portalNode as JsxElement;
|
|
3231
|
+
return (el.children || []).filter((child: JsxNode) => {
|
|
3232
|
+
if (child.type !== 'element') return true;
|
|
3233
|
+
const tag = (child as JsxElement).tagName.toLowerCase();
|
|
3234
|
+
return !tag.includes('overlay') && !tag.includes('backdrop');
|
|
3235
|
+
});
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
/**
|
|
3239
|
+
* Strips portal-specific CSS classes that are meaningless or harmful in Figma:
|
|
3240
|
+
* fixed/absolute positioning, transforms, z-index, state-variant animations,
|
|
3241
|
+
* transition durations, complex calc() max-widths, and responsive max-w variants
|
|
3242
|
+
* (the story's own max-w prop is the authoritative width constraint).
|
|
3243
|
+
*
|
|
3244
|
+
* Called on the extracted portal content node so the renderer sees only the
|
|
3245
|
+
* visual layout classes (grid, gap, padding, border, shadow, bg, etc.).
|
|
3246
|
+
*/
|
|
3247
|
+
private stripPortalPositioningClasses(className: string): string {
|
|
3248
|
+
if (!className) return className;
|
|
3249
|
+
return className
|
|
3250
|
+
.split(/\s+/)
|
|
3251
|
+
.filter(cls => {
|
|
3252
|
+
if (!cls) return false;
|
|
3253
|
+
// State-variant animations: data-[state=open]:animate-in etc.
|
|
3254
|
+
if (cls.includes('[state=')) return false;
|
|
3255
|
+
// Arbitrary selector variants: strip SVG-targeting and pointer-events selectors
|
|
3256
|
+
// (e.g. [&_svg]:pointer-events-none, [&_svg]:shrink-0, [&_svg:not([class*='size-'])]:size-4)
|
|
3257
|
+
// but KEEP direct-child layout selectors like [&>*]:w-full, [&>button]:px-4.
|
|
3258
|
+
if (cls.startsWith('[&')) {
|
|
3259
|
+
const selectorMatch = cls.match(/^\[([^\]]+)\]/);
|
|
3260
|
+
const selector = selectorMatch ? selectorMatch[1] : '';
|
|
3261
|
+
if (selector.includes('svg') || selector.includes('pointer-events')) return false;
|
|
3262
|
+
}
|
|
3263
|
+
// Positioning
|
|
3264
|
+
if (cls === 'fixed' || cls === 'absolute' || cls === 'sticky') return false;
|
|
3265
|
+
// Directional offsets
|
|
3266
|
+
if (/^-?(top|left|right|bottom)-/.test(cls)) return false;
|
|
3267
|
+
// CSS transforms
|
|
3268
|
+
if (/^-?translate-/.test(cls)) return false;
|
|
3269
|
+
// Z-index
|
|
3270
|
+
if (/^z-/.test(cls)) return false;
|
|
3271
|
+
// Inset utilities
|
|
3272
|
+
if (/^-?inset/.test(cls)) return false;
|
|
3273
|
+
// Transition duration/ease (irrelevant in Figma)
|
|
3274
|
+
if (/^duration-/.test(cls)) return false;
|
|
3275
|
+
if (/^ease-/.test(cls)) return false;
|
|
3276
|
+
// Complex calc/min/max max-w (can't be evaluated to a pixel value)
|
|
3277
|
+
if (/^max-w-\[(?:calc|min|max)\(/.test(cls)) return false;
|
|
3278
|
+
// Responsive max-w variants — the story's explicit max-w is authoritative
|
|
3279
|
+
if (/^(?:sm|md|lg|xl|2xl):max-w-/.test(cls)) return false;
|
|
3280
|
+
return true;
|
|
3281
|
+
})
|
|
3282
|
+
.join(' ');
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
/**
|
|
3286
|
+
* JS-style truthiness for a statically resolved expression value.
|
|
3287
|
+
* Used by the `&&` / `||` / ternary handlers in JSX expressions to decide
|
|
3288
|
+
* which branch to render. `undefined` means unresolvable, not falsy — the
|
|
3289
|
+
* caller treats unresolvable values separately so the design-system render
|
|
3290
|
+
* doesn't speculate about runtime state.
|
|
3291
|
+
*/
|
|
3292
|
+
private isExpressionTruthy(value: unknown): boolean {
|
|
3293
|
+
if (value === undefined || value === null) return false;
|
|
3294
|
+
if (typeof value === 'boolean') return value;
|
|
3295
|
+
if (typeof value === 'number') return value !== 0 && !Number.isNaN(value);
|
|
3296
|
+
if (typeof value === 'string') return value.length > 0;
|
|
3297
|
+
return true;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
/**
|
|
3301
|
+
* Materialize a chosen JSX-expression branch as scanner children.
|
|
3302
|
+
* If the branch is a JsxElement, build its subtree; otherwise resolve it
|
|
3303
|
+
* as a value and push as text/children. Shared by the `??`/`&&`/`||` and
|
|
3304
|
+
* ternary handlers.
|
|
3305
|
+
*/
|
|
3306
|
+
private pushChosenJsxBranch(
|
|
3307
|
+
chosenIn: Node,
|
|
3308
|
+
children: JsxNode[],
|
|
3309
|
+
localComponents: Map<string, Node>,
|
|
3310
|
+
relativeImports: Map<string, string>,
|
|
3311
|
+
propsContext: Map<string, ResolvedExpressionValue>
|
|
3312
|
+
): void {
|
|
3313
|
+
let chosen: Node = chosenIn;
|
|
3314
|
+
if (Node.isParenthesizedExpression(chosen)) {
|
|
3315
|
+
chosen = chosen.getExpression();
|
|
3316
|
+
}
|
|
3317
|
+
if (Node.isJsxElement(chosen) || Node.isJsxSelfClosingElement(chosen)) {
|
|
3318
|
+
const childNode = this.buildJsxTree(chosen, localComponents, relativeImports, propsContext);
|
|
3319
|
+
if (!(childNode as JsxNode & PortalSkipFlagged).__portalSkip) {
|
|
3320
|
+
children.push(childNode);
|
|
3321
|
+
}
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
const resolved = this.resolveExpressionValue(chosen, propsContext);
|
|
3325
|
+
if (resolved !== undefined && resolved !== null) {
|
|
3326
|
+
this.pushResolvedPropValue(children, resolved);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
/**
|
|
3331
|
+
* Walk down through unresolved single-child wrappers at the root of an
|
|
3332
|
+
* expanded tree until we reach a resolved node. Returns `null` when no
|
|
3333
|
+
* resolved inner node is reachable (i.e. the whole tree is opaque).
|
|
3334
|
+
*
|
|
3335
|
+
* Why this exists: when a component imported into a story expands to
|
|
3336
|
+
* something like `<Form><form>...</form></Form>` where `<Form>` is an
|
|
3337
|
+
* alias to an external import (e.g. `const Form = FormProvider`),
|
|
3338
|
+
* `<Form>` looks "unresolved" relative to the story file's imports —
|
|
3339
|
+
* but the inner `<form>` and its descendants are perfectly fine. Without
|
|
3340
|
+
* this unwrap the whole expansion gets discarded and the story tree
|
|
3341
|
+
* keeps the `<SignupForm/>` placeholder with empty children.
|
|
3342
|
+
*/
|
|
3343
|
+
private unwrapUnresolvedSingleChildRoots(
|
|
3344
|
+
node: JsxNode,
|
|
3345
|
+
localComponents: Map<string, Node>,
|
|
3346
|
+
relativeImports: Map<string, string>
|
|
3347
|
+
): JsxNode | null {
|
|
3348
|
+
let current: JsxNode = node;
|
|
3349
|
+
let safety = 16;
|
|
3350
|
+
while (safety-- > 0 && this.isUnresolvedExpandedComponent(current, localComponents, relativeImports)) {
|
|
3351
|
+
if (current.type !== 'element') return null;
|
|
3352
|
+
const el = current as JsxElement;
|
|
3353
|
+
if (!Array.isArray(el.children) || el.children.length !== 1) return null;
|
|
3354
|
+
if (el.children[0].type !== 'element') return null;
|
|
3355
|
+
current = el.children[0];
|
|
3356
|
+
}
|
|
3357
|
+
return current;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
/**
|
|
3361
|
+
* Detect a pass-through wrapper invocation: the user wrote
|
|
3362
|
+
* `<Wrapper>{singleChild}</Wrapper>` and added no visual className.
|
|
3363
|
+
* Used after `isUnresolvedExpandedComponent` confirms the wrapper's
|
|
3364
|
+
* implementation expands to an unresolvable Slot-style root, so we
|
|
3365
|
+
* can drop the wrapper and render the inner primitive directly.
|
|
3366
|
+
*/
|
|
3367
|
+
/**
|
|
3368
|
+
* Pre-analyse a component's body for `<*.Provider value={{ ... }}>`
|
|
3369
|
+
* wrappers and return the set of prop names that get cascaded through.
|
|
3370
|
+
*
|
|
3371
|
+
* Mirrors React's Context.Provider semantics statically — the canonical
|
|
3372
|
+
* shadcn pattern is `<ToggleGroup variant={x} size={y}><...item.../>...</ToggleGroup>`
|
|
3373
|
+
* where ToggleGroup wraps its children in `<ToggleGroupContext.Provider
|
|
3374
|
+
* value={{ variant, size }}>{children}</...>`. At runtime descendants
|
|
3375
|
+
* read those values via `useContext`. Statically we can't simulate
|
|
3376
|
+
* `useContext`, but we CAN detect the wrapping Provider's value-keys and
|
|
3377
|
+
* inject the invocation's matching props into descendant invocations.
|
|
3378
|
+
*
|
|
3379
|
+
* Cached per component name — the body walk runs once.
|
|
3380
|
+
*
|
|
3381
|
+
* Returns an empty set when the component has no Provider wrapper or
|
|
3382
|
+
* the value isn't an object literal we can statically analyse.
|
|
3383
|
+
*/
|
|
3384
|
+
// Shared "no cascade" sentinel — avoids allocating a new empty Set when a
|
|
3385
|
+
// component isn't found (e.g. an imported component whose source file
|
|
3386
|
+
// doesn't load). Read-only by convention; callers only check `.size`.
|
|
3387
|
+
private static readonly EMPTY_PROVIDER_CASCADE_NAMES: Set<string> = new Set();
|
|
3388
|
+
|
|
3389
|
+
private detectProviderCascadeNamesForComponent(
|
|
3390
|
+
componentName: string,
|
|
3391
|
+
localComponents: Map<string, Node>,
|
|
3392
|
+
relativeImports: Map<string, string>
|
|
3393
|
+
): Set<string> {
|
|
3394
|
+
const bodyNode = this.findComponentDefinitionNode(componentName, localComponents, relativeImports);
|
|
3395
|
+
if (!bodyNode) return ComponentScanner.EMPTY_PROVIDER_CASCADE_NAMES;
|
|
3396
|
+
if (this.providerCascadeNamesCache.has(bodyNode)) {
|
|
3397
|
+
return this.providerCascadeNamesCache.get(bodyNode)!;
|
|
3398
|
+
}
|
|
3399
|
+
const result = new Set<string>();
|
|
3400
|
+
// Cache by Node identity so different `Group` functions in unrelated
|
|
3401
|
+
// source files don't collide. Cache early to short-circuit recursion.
|
|
3402
|
+
this.providerCascadeNamesCache.set(bodyNode, result);
|
|
3403
|
+
|
|
3404
|
+
bodyNode.forEachDescendant((node) => {
|
|
3405
|
+
if (!Node.isJsxOpeningElement(node) && !Node.isJsxSelfClosingElement(node)) return;
|
|
3406
|
+
const tagText = node.getTagNameNode().getText();
|
|
3407
|
+
if (!tagText.endsWith('.Provider')) return;
|
|
3408
|
+
const attrs = node.getAttributes();
|
|
3409
|
+
for (const attr of attrs) {
|
|
3410
|
+
if (!Node.isJsxAttribute(attr)) continue;
|
|
3411
|
+
const nameNode = attr.getNameNode();
|
|
3412
|
+
if (nameNode.getText() !== 'value') continue;
|
|
3413
|
+
const init = attr.getInitializer();
|
|
3414
|
+
if (!init || !Node.isJsxExpression(init)) continue;
|
|
3415
|
+
const expr = init.getExpression();
|
|
3416
|
+
if (!expr || !Node.isObjectLiteralExpression(expr)) continue;
|
|
3417
|
+
for (const prop of expr.getProperties()) {
|
|
3418
|
+
if (Node.isShorthandPropertyAssignment(prop)) {
|
|
3419
|
+
result.add(prop.getName());
|
|
3420
|
+
} else if (Node.isPropertyAssignment(prop)) {
|
|
3421
|
+
const propNameNode = prop.getNameNode();
|
|
3422
|
+
const propName = propNameNode.getText();
|
|
3423
|
+
if (propName) result.add(propName);
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
});
|
|
3428
|
+
return result;
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
/**
|
|
3432
|
+
* Locate a component's function-definition Node, whether the component
|
|
3433
|
+
* is defined locally in the same file (in `localComponents`) or imported
|
|
3434
|
+
* from another module (looked up via `relativeImports`).
|
|
3435
|
+
*/
|
|
3436
|
+
private findComponentDefinitionNode(
|
|
3437
|
+
componentName: string,
|
|
3438
|
+
localComponents: Map<string, Node>,
|
|
3439
|
+
relativeImports: Map<string, string>
|
|
3440
|
+
): Node | null {
|
|
3441
|
+
const localDef = localComponents.get(componentName);
|
|
3442
|
+
if (localDef) return localDef;
|
|
3443
|
+
const filePath = relativeImports.get(componentName);
|
|
3444
|
+
if (!filePath) return null;
|
|
3445
|
+
const cacheKey = `${filePath}:${componentName}`;
|
|
3446
|
+
let sourceFile: SourceFile | undefined;
|
|
3447
|
+
const cached = this.importedComponentCache.get(cacheKey);
|
|
3448
|
+
if (cached) {
|
|
3449
|
+
sourceFile = cached.sourceFile;
|
|
3450
|
+
} else {
|
|
3451
|
+
try {
|
|
3452
|
+
sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
3453
|
+
this.importedComponentCache.set(cacheKey, { sourceFile, componentName });
|
|
3454
|
+
} catch (_err) {
|
|
3455
|
+
return null;
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
if (!sourceFile) return null;
|
|
3459
|
+
for (const func of sourceFile.getFunctions()) {
|
|
3460
|
+
if (func.getName() === componentName) return func;
|
|
3461
|
+
}
|
|
3462
|
+
for (const variable of sourceFile.getVariableDeclarations()) {
|
|
3463
|
+
if (variable.getName() !== componentName) continue;
|
|
3464
|
+
const init = variable.getInitializer();
|
|
3465
|
+
if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) return init;
|
|
3466
|
+
}
|
|
3467
|
+
return null;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
/**
|
|
3471
|
+
* Recursively push `child` into `out`, expanding any transparent React
|
|
3472
|
+
* Context Providers (`*.Provider`) into their own children. Providers
|
|
3473
|
+
* exist only to inject context — they produce no DOM. Single-child
|
|
3474
|
+
* Providers are already stripped at the buildJsxTree return point;
|
|
3475
|
+
* this handles the multi-child case (e.g. a `ToggleGroup` that wraps
|
|
3476
|
+
* multiple items in a `*.Provider` to cascade `variant`/`size`). The
|
|
3477
|
+
* skipped wrapper would otherwise become an unsized inner frame that
|
|
3478
|
+
* collapses the parent's auto-layout — see toggle-group rendering bug.
|
|
3479
|
+
*/
|
|
3480
|
+
private pushFlatteningTransparentProvider(out: JsxNode[], child: JsxNode): void {
|
|
3481
|
+
if (
|
|
3482
|
+
child.type === 'element'
|
|
3483
|
+
&& child.tagName.endsWith('.Provider')
|
|
3484
|
+
&& !(typeof child.props?.className === 'string' && child.props.className.trim().length > 0)
|
|
3485
|
+
) {
|
|
3486
|
+
for (const grandchild of child.children) {
|
|
3487
|
+
this.pushFlatteningTransparentProvider(out, grandchild);
|
|
3488
|
+
}
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
out.push(child);
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
private isPassThroughInvocation(
|
|
3495
|
+
props: Record<string, ResolvedExpressionValue>,
|
|
3496
|
+
children: JsxNode[]
|
|
3497
|
+
): boolean {
|
|
3498
|
+
if (children.length !== 1) return false;
|
|
3499
|
+
if (children[0].type !== 'element') return false;
|
|
3500
|
+
const className = typeof props.className === 'string' ? props.className.trim() : '';
|
|
3501
|
+
if (className.length > 0) return false;
|
|
3502
|
+
const styleProp = typeof props.style === 'string' ? props.style.trim() : '';
|
|
3503
|
+
if (styleProp.length > 0) return false;
|
|
3504
|
+
return true;
|
|
3505
|
+
}
|
|
3506
|
+
|
|
1959
3507
|
private isUnresolvedExpandedComponent(
|
|
1960
3508
|
node: JsxNode,
|
|
1961
3509
|
localComponents: Map<string, Node>,
|
|
@@ -1997,7 +3545,12 @@ export class ComponentScanner {
|
|
|
1997
3545
|
* Substitute an expression with values from the map item.
|
|
1998
3546
|
* Handles: feature.name, feature.description, feature.free, etc.
|
|
1999
3547
|
*/
|
|
2000
|
-
private substituteExpression(
|
|
3548
|
+
private substituteExpression(
|
|
3549
|
+
expr: Node,
|
|
3550
|
+
itemParamName: string,
|
|
3551
|
+
itemValue: ResolvedExpressionValue,
|
|
3552
|
+
extraContext?: Map<string, ResolvedExpressionValue>
|
|
3553
|
+
): ResolvedExpressionValue {
|
|
2001
3554
|
const text = expr.getText();
|
|
2002
3555
|
|
|
2003
3556
|
// Handle property access: feature.name, feature.description
|
|
@@ -2008,6 +3561,13 @@ export class ComponentScanner {
|
|
|
2008
3561
|
if (objExpr.getText() === itemParamName && typeof itemValue === 'object') {
|
|
2009
3562
|
return itemValue[propName];
|
|
2010
3563
|
}
|
|
3564
|
+
// Outer context lookup: `propsContext.someObject.prop`
|
|
3565
|
+
if (extraContext && Node.isIdentifier(objExpr)) {
|
|
3566
|
+
const baseValue = extraContext.get(objExpr.getText());
|
|
3567
|
+
if (baseValue != null && typeof baseValue === 'object' && propName in baseValue) {
|
|
3568
|
+
return baseValue[propName];
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
2011
3571
|
}
|
|
2012
3572
|
|
|
2013
3573
|
// Handle simple variable reference: feature (if it's the whole item)
|
|
@@ -2022,6 +3582,36 @@ export class ComponentScanner {
|
|
|
2022
3582
|
if (val !== undefined) return val;
|
|
2023
3583
|
}
|
|
2024
3584
|
|
|
3585
|
+
// Handle identifiers bound in outer propsContext (e.g. `index`, `widths`).
|
|
3586
|
+
if (extraContext && Node.isIdentifier(expr) && extraContext.has(text)) {
|
|
3587
|
+
return extraContext.get(text);
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
// Fall back to the scanner's general expression resolver so complex
|
|
3591
|
+
// expressions — property/element access, arithmetic, ternaries, clsx —
|
|
3592
|
+
// can evaluate against the combined context.
|
|
3593
|
+
if (extraContext || itemValue != null) {
|
|
3594
|
+
const merged = new Map<string, ResolvedExpressionValue>();
|
|
3595
|
+
if (extraContext) for (const [k, v] of extraContext) merged.set(k, v);
|
|
3596
|
+
if (itemParamName && itemValue != null) {
|
|
3597
|
+
if (itemParamName === '__destructured__' && typeof itemValue === 'object') {
|
|
3598
|
+
for (const [k, v] of Object.entries(itemValue)) merged.set(k, v);
|
|
3599
|
+
} else {
|
|
3600
|
+
merged.set(itemParamName, itemValue);
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
const resolved = this.resolveExpressionValue(expr, merged);
|
|
3604
|
+
if (resolved !== undefined) return resolved;
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '??') {
|
|
3608
|
+
const leftValue = this.substituteExpression(expr.getLeft(), itemParamName, itemValue, extraContext);
|
|
3609
|
+
if (leftValue !== null && leftValue !== undefined) {
|
|
3610
|
+
return leftValue;
|
|
3611
|
+
}
|
|
3612
|
+
return this.substituteExpression(expr.getRight(), itemParamName, itemValue, extraContext);
|
|
3613
|
+
}
|
|
3614
|
+
|
|
2025
3615
|
// Handle conditional: feature.description && <p>...</p>
|
|
2026
3616
|
if (Node.isBinaryExpression(expr)) {
|
|
2027
3617
|
const left = expr.getLeft();
|
|
@@ -2029,7 +3619,7 @@ export class ComponentScanner {
|
|
|
2029
3619
|
const right = expr.getRight();
|
|
2030
3620
|
|
|
2031
3621
|
if (operator === '&&') {
|
|
2032
|
-
const leftValue = this.substituteExpression(left, itemParamName, itemValue);
|
|
3622
|
+
const leftValue = this.substituteExpression(left, itemParamName, itemValue, extraContext);
|
|
2033
3623
|
if (leftValue) {
|
|
2034
3624
|
// If right side is JSX, we'd need to render it - for now just return text
|
|
2035
3625
|
if (Node.isJsxElement(right) || Node.isJsxSelfClosingElement(right)) {
|
|
@@ -2043,7 +3633,7 @@ export class ComponentScanner {
|
|
|
2043
3633
|
for (const exprNode of exprNodes) {
|
|
2044
3634
|
const innerExpr = exprNode.getExpression();
|
|
2045
3635
|
if (innerExpr) {
|
|
2046
|
-
const val = this.substituteExpression(innerExpr, itemParamName, itemValue);
|
|
3636
|
+
const val = this.substituteExpression(innerExpr, itemParamName, itemValue, extraContext);
|
|
2047
3637
|
if (val !== null && val !== undefined) {
|
|
2048
3638
|
textContent += String(val) + ' ';
|
|
2049
3639
|
}
|
|
@@ -2051,7 +3641,7 @@ export class ComponentScanner {
|
|
|
2051
3641
|
}
|
|
2052
3642
|
return textContent.trim();
|
|
2053
3643
|
}
|
|
2054
|
-
return this.substituteExpression(right, itemParamName, itemValue);
|
|
3644
|
+
return this.substituteExpression(right, itemParamName, itemValue, extraContext);
|
|
2055
3645
|
}
|
|
2056
3646
|
return null;
|
|
2057
3647
|
}
|
|
@@ -2060,7 +3650,7 @@ export class ComponentScanner {
|
|
|
2060
3650
|
// Handle ternary: feature.free ? <Check /> : <Cross />
|
|
2061
3651
|
if (Node.isConditionalExpression(expr)) {
|
|
2062
3652
|
const condition = expr.getCondition();
|
|
2063
|
-
const condValue = this.substituteExpression(condition, itemParamName, itemValue);
|
|
3653
|
+
const condValue = this.substituteExpression(condition, itemParamName, itemValue, extraContext);
|
|
2064
3654
|
// Return indication of which branch (for components like Check/Cross)
|
|
2065
3655
|
if (condValue) {
|
|
2066
3656
|
return 'true';
|
|
@@ -2086,7 +3676,7 @@ export class ComponentScanner {
|
|
|
2086
3676
|
componentName: string,
|
|
2087
3677
|
filePath: string,
|
|
2088
3678
|
relativeImports: Map<string, string>,
|
|
2089
|
-
resolvedProps: Map<string,
|
|
3679
|
+
resolvedProps: Map<string, ResolvedExpressionValue> = new Map()
|
|
2090
3680
|
): JsxNode | null {
|
|
2091
3681
|
try {
|
|
2092
3682
|
// Check cache first
|
|
@@ -2124,7 +3714,7 @@ export class ComponentScanner {
|
|
|
2124
3714
|
sourceFile: SourceFile,
|
|
2125
3715
|
componentName: string,
|
|
2126
3716
|
relativeImports: Map<string, string>,
|
|
2127
|
-
resolvedProps: Map<string,
|
|
3717
|
+
resolvedProps: Map<string, ResolvedExpressionValue> = new Map()
|
|
2128
3718
|
): JsxNode | null {
|
|
2129
3719
|
const localComponents = this.collectLocalComponentsFromFile(sourceFile);
|
|
2130
3720
|
const mergedRelativeImports = new Map(relativeImports);
|
|
@@ -2248,15 +3838,35 @@ export class ComponentScanner {
|
|
|
2248
3838
|
* Parse an ObjectLiteralExpression AST node into a plain JS object.
|
|
2249
3839
|
* Handles string, number, boolean, nested object, and array values.
|
|
2250
3840
|
*/
|
|
2251
|
-
private parseObjectLiteralExpression(
|
|
2252
|
-
|
|
2253
|
-
|
|
3841
|
+
private parseObjectLiteralExpression(
|
|
3842
|
+
expr: Node,
|
|
3843
|
+
propsContext: Map<string, ResolvedExpressionValue> = new Map()
|
|
3844
|
+
): Record<string, ResolvedExpressionValue> {
|
|
3845
|
+
const result: Record<string, ResolvedExpressionValue> = {};
|
|
3846
|
+
if (!Node.isObjectLiteralExpression(expr)) return result;
|
|
3847
|
+
for (const prop of expr.getProperties()) {
|
|
3848
|
+
if (Node.isShorthandPropertyAssignment(prop)) {
|
|
3849
|
+
// `{ color }` shorthand: resolve the identifier against the callback's
|
|
3850
|
+
// propsContext (map iteration, etc.) so values from destructured items
|
|
3851
|
+
// are captured. Falls back to the literal identifier text if unresolved.
|
|
3852
|
+
const key = prop.getName();
|
|
3853
|
+
if (!key) continue;
|
|
3854
|
+
const nameNode = prop.getNameNode();
|
|
3855
|
+
const resolved = nameNode
|
|
3856
|
+
? this.resolveExpressionValue(nameNode, propsContext)
|
|
3857
|
+
: undefined;
|
|
3858
|
+
if (resolved !== undefined) {
|
|
3859
|
+
result[key] = resolved;
|
|
3860
|
+
}
|
|
3861
|
+
continue;
|
|
3862
|
+
}
|
|
2254
3863
|
if (!Node.isPropertyAssignment(prop)) continue;
|
|
2255
|
-
const key =
|
|
3864
|
+
const key = prop.getName();
|
|
2256
3865
|
if (!key) continue;
|
|
2257
|
-
const val =
|
|
3866
|
+
const val = prop.getInitializer();
|
|
2258
3867
|
if (!val) continue;
|
|
2259
|
-
|
|
3868
|
+
const resolved = this.resolveExpressionValue(val, propsContext);
|
|
3869
|
+
result[key] = resolved !== undefined ? resolved : this.parseLiteralValue(val);
|
|
2260
3870
|
}
|
|
2261
3871
|
return result;
|
|
2262
3872
|
}
|
|
@@ -2264,7 +3874,7 @@ export class ComponentScanner {
|
|
|
2264
3874
|
/**
|
|
2265
3875
|
* Parse a literal AST node into a plain JS value (string, number, boolean, array, object).
|
|
2266
3876
|
*/
|
|
2267
|
-
private parseLiteralValue(val: Node):
|
|
3877
|
+
private parseLiteralValue(val: Node): ResolvedExpressionValue {
|
|
2268
3878
|
const unwrapped = this.unwrapStaticValueExpression(val);
|
|
2269
3879
|
if (unwrapped && unwrapped !== val) {
|
|
2270
3880
|
return this.parseLiteralValue(unwrapped);
|
|
@@ -2275,7 +3885,7 @@ export class ComponentScanner {
|
|
|
2275
3885
|
if (text === 'true') return true;
|
|
2276
3886
|
if (text === 'false') return false;
|
|
2277
3887
|
if (Node.isArrayLiteralExpression(val)) {
|
|
2278
|
-
return
|
|
3888
|
+
return val.getElements().map((el) => this.parseLiteralValue(el));
|
|
2279
3889
|
}
|
|
2280
3890
|
if (Node.isObjectLiteralExpression(val)) {
|
|
2281
3891
|
return this.parseObjectLiteralExpression(val);
|
|
@@ -2283,7 +3893,7 @@ export class ComponentScanner {
|
|
|
2283
3893
|
return text;
|
|
2284
3894
|
}
|
|
2285
3895
|
|
|
2286
|
-
private coerceBoolean(value:
|
|
3896
|
+
private coerceBoolean(value: unknown): boolean | null {
|
|
2287
3897
|
if (typeof value === 'boolean') return value;
|
|
2288
3898
|
if (typeof value === 'number') return value !== 0;
|
|
2289
3899
|
if (typeof value === 'string') {
|
|
@@ -2298,7 +3908,60 @@ export class ComponentScanner {
|
|
|
2298
3908
|
return null;
|
|
2299
3909
|
}
|
|
2300
3910
|
|
|
2301
|
-
|
|
3911
|
+
/**
|
|
3912
|
+
* Resolve an identifier against function-body / block-scoped `const`
|
|
3913
|
+
* declarations, walking ancestor scopes from the reference site outward.
|
|
3914
|
+
* Returns `undefined` if no scope-local declaration is found so the caller
|
|
3915
|
+
* can fall back to module-level lookup.
|
|
3916
|
+
*
|
|
3917
|
+
* Necessary because `SourceFile.getVariableDeclaration` only sees top-level
|
|
3918
|
+
* statements — anything declared inside a function body (the common
|
|
3919
|
+
* `const dotClass = sourceLabel === "db" ? "..." : "..."` pattern used to
|
|
3920
|
+
* derive Tailwind classes from a prop value) is invisible to it. Without
|
|
3921
|
+
* this helper such derived classes silently collapse to "no class" in the
|
|
3922
|
+
* scanner output even though the runtime evaluates them fine.
|
|
3923
|
+
*
|
|
3924
|
+
* Forward references (a `const` declared AFTER the reference site in the
|
|
3925
|
+
* same scope) are skipped to match real JavaScript TDZ semantics.
|
|
3926
|
+
*
|
|
3927
|
+
* Cycle protection via `localResolutionStack` — re-entering the same
|
|
3928
|
+
* `${filePath}:${name}` returns `undefined` instead of recursing forever.
|
|
3929
|
+
*/
|
|
3930
|
+
private resolveLocalIdentifier(
|
|
3931
|
+
name: string,
|
|
3932
|
+
fromNode: Node,
|
|
3933
|
+
propsContext: Map<string, ResolvedExpressionValue>,
|
|
3934
|
+
): ResolvedExpressionValue {
|
|
3935
|
+
const filePath = fromNode.getSourceFile().getFilePath();
|
|
3936
|
+
const stackKey = `${filePath}:${name}`;
|
|
3937
|
+
if (this.localResolutionStack.has(stackKey)) return undefined;
|
|
3938
|
+
const fromStart = fromNode.getStart();
|
|
3939
|
+
let cursor: Node | undefined = fromNode.getParent();
|
|
3940
|
+
while (cursor && !Node.isSourceFile(cursor)) {
|
|
3941
|
+
if (Node.isBlock(cursor) || Node.isCaseClause(cursor) || Node.isDefaultClause(cursor)) {
|
|
3942
|
+
for (const stmt of cursor.getStatements()) {
|
|
3943
|
+
if (!Node.isVariableStatement(stmt)) continue;
|
|
3944
|
+
// Skip forward references — match TDZ semantics.
|
|
3945
|
+
if (stmt.getEnd() > fromStart) continue;
|
|
3946
|
+
for (const decl of stmt.getDeclarationList().getDeclarations()) {
|
|
3947
|
+
if (decl.getName() !== name) continue;
|
|
3948
|
+
const init = decl.getInitializer();
|
|
3949
|
+
if (!init) return undefined;
|
|
3950
|
+
this.localResolutionStack.add(stackKey);
|
|
3951
|
+
try {
|
|
3952
|
+
return this.resolveExpressionValue(init, propsContext);
|
|
3953
|
+
} finally {
|
|
3954
|
+
this.localResolutionStack.delete(stackKey);
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
cursor = cursor.getParent();
|
|
3960
|
+
}
|
|
3961
|
+
return undefined;
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
private resolveExpressionValue(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): ResolvedExpressionValue {
|
|
2302
3965
|
if (Node.isParenthesizedExpression(expr)) {
|
|
2303
3966
|
return this.resolveExpressionValue(expr.getExpression(), propsContext);
|
|
2304
3967
|
}
|
|
@@ -2313,22 +3976,64 @@ export class ComponentScanner {
|
|
|
2313
3976
|
if (exprText === 'false') return false;
|
|
2314
3977
|
if (Node.isIdentifier(expr)) {
|
|
2315
3978
|
if (propsContext.has(exprText)) return propsContext.get(exprText);
|
|
2316
|
-
// Resolve
|
|
3979
|
+
// Resolve function-body-local consts FIRST so they shadow module-level
|
|
3980
|
+
// names — same rule JavaScript scoping enforces. Without this, a
|
|
3981
|
+
// pattern like `const dotClass = sourceLabel === "db" ? "bg-emerald-500"
|
|
3982
|
+
// : "bg-sky-400"` followed by `<span className={cn(base, dotClass)}/>`
|
|
3983
|
+
// collapses to just `base` and the conditional color is dropped.
|
|
3984
|
+
const localValue = this.resolveLocalIdentifier(exprText, expr, propsContext);
|
|
3985
|
+
if (localValue !== undefined) return localValue;
|
|
3986
|
+
// Resolve module-level consts: strings, object literals (e.g.
|
|
3987
|
+
// `const WIDTH_CLASSNAMES = {...} as const;`), and array literals
|
|
3988
|
+
// (e.g. `const DEFAULT_WIDTHS = [...]`). Returning the parsed value
|
|
3989
|
+
// lets downstream element-access / property-access resolve correctly.
|
|
2317
3990
|
const varDecl = expr.getSourceFile().getVariableDeclaration(exprText);
|
|
2318
3991
|
if (varDecl) {
|
|
2319
3992
|
const init = varDecl.getInitializer();
|
|
2320
|
-
if (init
|
|
3993
|
+
if (init) {
|
|
3994
|
+
const unwrapped = this.unwrapStaticValueExpression(init) || init;
|
|
3995
|
+
if (Node.isStringLiteral(unwrapped) || Node.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
3996
|
+
return unwrapped.getLiteralValue();
|
|
3997
|
+
}
|
|
3998
|
+
if (Node.isNumericLiteral(unwrapped)) return unwrapped.getLiteralValue();
|
|
3999
|
+
if (Node.isArrayLiteralExpression(unwrapped)) {
|
|
4000
|
+
return this.parseArrayLiteral(unwrapped);
|
|
4001
|
+
}
|
|
4002
|
+
if (Node.isObjectLiteralExpression(unwrapped)) {
|
|
4003
|
+
return this.parseObjectLiteralExpression(unwrapped, propsContext);
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
2321
4006
|
}
|
|
2322
4007
|
return undefined;
|
|
2323
4008
|
}
|
|
2324
4009
|
if (Node.isPropertyAccessExpression(expr)) {
|
|
2325
4010
|
const baseExpr = expr.getExpression();
|
|
2326
4011
|
const propName = expr.getName();
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
if (baseValue &&
|
|
2330
|
-
|
|
4012
|
+
const baseValue = this.resolveExpressionValue(baseExpr, propsContext);
|
|
4013
|
+
if (baseValue != null) {
|
|
4014
|
+
if (Array.isArray(baseValue) && propName === 'length') return baseValue.length;
|
|
4015
|
+
if (typeof baseValue === 'object' && propName in baseValue) {
|
|
4016
|
+
return baseValue[propName];
|
|
2331
4017
|
}
|
|
4018
|
+
if (typeof baseValue === 'string') {
|
|
4019
|
+
if (propName === 'length') return baseValue.length;
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
return undefined;
|
|
4023
|
+
}
|
|
4024
|
+
if (Node.isElementAccessExpression(expr)) {
|
|
4025
|
+
// `WIDTH_CLASSNAMES[width]`, `widths[index]`, `widths[index % N]`, etc.
|
|
4026
|
+
const baseExpr = expr.getExpression();
|
|
4027
|
+
const argExpr = expr.getArgumentExpression();
|
|
4028
|
+
if (!argExpr) return undefined;
|
|
4029
|
+
const baseValue = this.resolveExpressionValue(baseExpr, propsContext);
|
|
4030
|
+
const argValue = this.resolveExpressionValue(argExpr, propsContext);
|
|
4031
|
+
if (baseValue == null || argValue == null) return undefined;
|
|
4032
|
+
if (Array.isArray(baseValue) && typeof argValue === 'number') {
|
|
4033
|
+
return baseValue[argValue];
|
|
4034
|
+
}
|
|
4035
|
+
if (typeof baseValue === 'object' && (typeof argValue === 'string' || typeof argValue === 'number')) {
|
|
4036
|
+
return baseValue[argValue as string];
|
|
2332
4037
|
}
|
|
2333
4038
|
return undefined;
|
|
2334
4039
|
}
|
|
@@ -2368,17 +4073,82 @@ export class ComponentScanner {
|
|
|
2368
4073
|
if (leftBool === false) return this.resolveExpressionValue(expr.getRight(), propsContext);
|
|
2369
4074
|
return undefined;
|
|
2370
4075
|
}
|
|
4076
|
+
if (op === '??') {
|
|
4077
|
+
const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
4078
|
+
if (leftValue !== undefined && leftValue !== null) return leftValue;
|
|
4079
|
+
return this.resolveExpressionValue(expr.getRight(), propsContext);
|
|
4080
|
+
}
|
|
2371
4081
|
if (op === '+') {
|
|
2372
4082
|
const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
2373
4083
|
const right = this.resolveExpressionValue(expr.getRight(), propsContext);
|
|
2374
4084
|
if (left === undefined || right === undefined) return undefined;
|
|
4085
|
+
if (typeof left === 'number' && typeof right === 'number') return left + right;
|
|
2375
4086
|
return String(left) + String(right);
|
|
2376
4087
|
}
|
|
4088
|
+
if (op === '-' || op === '*' || op === '/' || op === '%') {
|
|
4089
|
+
const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
4090
|
+
const right = this.resolveExpressionValue(expr.getRight(), propsContext);
|
|
4091
|
+
if (typeof left !== 'number' || typeof right !== 'number') return undefined;
|
|
4092
|
+
if (op === '-') return left - right;
|
|
4093
|
+
if (op === '*') return left * right;
|
|
4094
|
+
if (op === '/') return right === 0 ? undefined : left / right;
|
|
4095
|
+
return right === 0 ? undefined : left % right;
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
// Method-call expressions on resolvable bases — covers the common
|
|
4099
|
+
// story-args pattern `{rewardSol.toFixed(4)} SOL`. Without this the
|
|
4100
|
+
// expression returns undefined and the JSX walker drops it from
|
|
4101
|
+
// the captured tree, so a `<RewardDisplay rewardSol={0.1482}>` story
|
|
4102
|
+
// renders just " SOL" with the number missing. Limited to Number /
|
|
4103
|
+
// String built-in methods that have an obvious string output — we
|
|
4104
|
+
// are NOT a JS evaluator, just covering the high-traffic cases.
|
|
4105
|
+
if (Node.isCallExpression(expr)) {
|
|
4106
|
+
const calleeExpr = expr.getExpression();
|
|
4107
|
+
if (Node.isPropertyAccessExpression(calleeExpr)) {
|
|
4108
|
+
const methodName = calleeExpr.getName();
|
|
4109
|
+
const baseValue = this.resolveExpressionValue(calleeExpr.getExpression(), propsContext);
|
|
4110
|
+
if (baseValue == null) return undefined;
|
|
4111
|
+
const argValues = expr.getArguments().map(a => this.resolveExpressionValue(a, propsContext));
|
|
4112
|
+
const NUMBER_METHODS: Record<string, boolean> = { toFixed: true, toString: true, toPrecision: true };
|
|
4113
|
+
const STRING_METHODS: Record<string, boolean> = { toString: true, toUpperCase: true, toLowerCase: true, trim: true };
|
|
4114
|
+
if (typeof baseValue === 'number' && NUMBER_METHODS[methodName]) {
|
|
4115
|
+
const arg = argValues[0];
|
|
4116
|
+
try {
|
|
4117
|
+
if (methodName === 'toFixed' && typeof arg === 'number') return baseValue.toFixed(arg);
|
|
4118
|
+
if (methodName === 'toPrecision' && typeof arg === 'number') return baseValue.toPrecision(arg);
|
|
4119
|
+
if (methodName === 'toString') return String(baseValue);
|
|
4120
|
+
} catch (_e) { return undefined; }
|
|
4121
|
+
}
|
|
4122
|
+
if (typeof baseValue === 'string' && STRING_METHODS[methodName]) {
|
|
4123
|
+
if (methodName === 'toUpperCase') return baseValue.toUpperCase();
|
|
4124
|
+
if (methodName === 'toLowerCase') return baseValue.toLowerCase();
|
|
4125
|
+
if (methodName === 'trim') return baseValue.trim();
|
|
4126
|
+
if (methodName === 'toString') return baseValue;
|
|
4127
|
+
}
|
|
4128
|
+
} else if (Node.isIdentifier(calleeExpr)) {
|
|
4129
|
+
// User-defined function call (e.g. `truncateHash(block.blockHash)`,
|
|
4130
|
+
// `formatTimestamp(block.timestamp)`). We can't execute the
|
|
4131
|
+
// function — that'd require evaluating arbitrary JS — but we
|
|
4132
|
+
// CAN resolve the first argument and return it as a graceful
|
|
4133
|
+
// fallback. Rendering "DKjW9hX6dqBE6aDgqdX7Ytqa…" as-is is
|
|
4134
|
+
// better than rendering an empty cell. If the user's function
|
|
4135
|
+
// does important formatting (e.g. truncation) the value will
|
|
4136
|
+
// look longer than the runtime, but it's visible and obviously
|
|
4137
|
+
// points at the right field. Skip well-known *side-effect*
|
|
4138
|
+
// helpers like `console.*` so we don't surface noise.
|
|
4139
|
+
const fnName = calleeExpr.getText();
|
|
4140
|
+
if (fnName === 'console' || fnName.startsWith('use')) return undefined;
|
|
4141
|
+
const firstArg = expr.getArguments()[0];
|
|
4142
|
+
if (firstArg) {
|
|
4143
|
+
const resolved = this.resolveExpressionValue(firstArg, propsContext);
|
|
4144
|
+
if (resolved !== undefined && resolved !== null) return resolved;
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
2377
4147
|
}
|
|
2378
4148
|
return undefined;
|
|
2379
4149
|
}
|
|
2380
4150
|
|
|
2381
|
-
private resolveClassNameExpression(expr: Node, propsContext: Map<string,
|
|
4151
|
+
private resolveClassNameExpression(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): string {
|
|
2382
4152
|
if (Node.isParenthesizedExpression(expr)) {
|
|
2383
4153
|
return this.resolveClassNameExpression(expr.getExpression(), propsContext);
|
|
2384
4154
|
}
|
|
@@ -2386,15 +4156,15 @@ export class ComponentScanner {
|
|
|
2386
4156
|
return expr.getLiteralValue();
|
|
2387
4157
|
}
|
|
2388
4158
|
if (Node.isTemplateExpression(expr)) {
|
|
2389
|
-
// Use
|
|
2390
|
-
const parts: string[] = [
|
|
4159
|
+
// Use getLiteralText() to get the raw literal content of template head/middle/tail.
|
|
4160
|
+
const parts: string[] = [expr.getHead().getLiteralText() || ''];
|
|
2391
4161
|
for (const span of expr.getTemplateSpans()) {
|
|
2392
4162
|
parts.push(this.resolveClassNameExpression(span.getExpression(), propsContext));
|
|
2393
|
-
parts.push(
|
|
4163
|
+
parts.push(span.getLiteral().getLiteralText() || '');
|
|
2394
4164
|
}
|
|
2395
4165
|
return parts.join(' ').trim().replace(/\s+/g, ' ');
|
|
2396
4166
|
}
|
|
2397
|
-
if (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) {
|
|
4167
|
+
if (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr) || Node.isElementAccessExpression(expr)) {
|
|
2398
4168
|
const resolved = this.resolveExpressionValue(expr, propsContext);
|
|
2399
4169
|
return typeof resolved === 'string' ? resolved : '';
|
|
2400
4170
|
}
|
|
@@ -2403,9 +4173,11 @@ export class ComponentScanner {
|
|
|
2403
4173
|
const conditionBool = this.coerceBoolean(conditionValue);
|
|
2404
4174
|
if (conditionBool === true) return this.resolveClassNameExpression(expr.getWhenTrue(), propsContext);
|
|
2405
4175
|
if (conditionBool === false) return this.resolveClassNameExpression(expr.getWhenFalse(), propsContext);
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
4176
|
+
// Unresolvable condition: prefer the "else" branch so components render in their
|
|
4177
|
+
// neutral/rest state (e.g. inactive nav links without forced `bg-muted`). Active /
|
|
4178
|
+
// hover / focus / error styles are expected to live on the truthy branch and
|
|
4179
|
+
// surface through the dedicated state preview blocks, not the default render.
|
|
4180
|
+
return this.resolveClassNameExpression(expr.getWhenFalse(), propsContext);
|
|
2409
4181
|
}
|
|
2410
4182
|
if (Node.isBinaryExpression(expr)) {
|
|
2411
4183
|
const op = expr.getOperatorToken().getText();
|
|
@@ -2420,6 +4192,13 @@ export class ComponentScanner {
|
|
|
2420
4192
|
if (left.trim()) return left;
|
|
2421
4193
|
return this.resolveClassNameExpression(expr.getRight(), propsContext);
|
|
2422
4194
|
}
|
|
4195
|
+
if (op === '??') {
|
|
4196
|
+
const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
4197
|
+
if (leftValue !== undefined && leftValue !== null) {
|
|
4198
|
+
return this.resolveClassNameExpression(expr.getLeft(), propsContext);
|
|
4199
|
+
}
|
|
4200
|
+
return this.resolveClassNameExpression(expr.getRight(), propsContext);
|
|
4201
|
+
}
|
|
2423
4202
|
if (op === '+') {
|
|
2424
4203
|
const left = this.resolveClassNameExpression(expr.getLeft(), propsContext);
|
|
2425
4204
|
const right = this.resolveClassNameExpression(expr.getRight(), propsContext);
|
|
@@ -2455,12 +4234,107 @@ export class ComponentScanner {
|
|
|
2455
4234
|
const value = this.resolveClassNameExpression(arg, propsContext).trim();
|
|
2456
4235
|
if (value) parts.push(value);
|
|
2457
4236
|
}
|
|
2458
|
-
return parts.join(' ')
|
|
4237
|
+
return resolveClassConflicts(parts.join(' '));
|
|
2459
4238
|
}
|
|
4239
|
+
// Try to resolve as a CVA variant function call (e.g., alertVariants({ variant }))
|
|
4240
|
+
const cvaResult = this.resolveCvaFunctionCall(expr, propsContext);
|
|
4241
|
+
if (cvaResult !== null) return cvaResult;
|
|
2460
4242
|
}
|
|
2461
4243
|
return '';
|
|
2462
4244
|
}
|
|
2463
4245
|
|
|
4246
|
+
/**
|
|
4247
|
+
* Resolve a CVA variant function call like `alertVariants({ variant })` to its
|
|
4248
|
+
* combined class string. Looks up the function definition in the same source file,
|
|
4249
|
+
* then merges base classes with the appropriate variant classes.
|
|
4250
|
+
*/
|
|
4251
|
+
private resolveCvaFunctionCall(callExpr: Node, propsContext: Map<string, ResolvedExpressionValue>): string | null {
|
|
4252
|
+
if (!Node.isCallExpression(callExpr)) return null;
|
|
4253
|
+
const funcName = callExpr.getExpression().getText();
|
|
4254
|
+
const sourceFile = callExpr.getSourceFile();
|
|
4255
|
+
|
|
4256
|
+
// Find `const funcName = cva(...)` in the source file
|
|
4257
|
+
for (const varStmt of sourceFile.getVariableStatements()) {
|
|
4258
|
+
for (const decl of varStmt.getDeclarationList().getDeclarations()) {
|
|
4259
|
+
if (decl.getName() !== funcName) continue;
|
|
4260
|
+
const init = decl.getInitializer();
|
|
4261
|
+
if (!init || !Node.isCallExpression(init)) continue;
|
|
4262
|
+
if (init.getExpression().getText() !== 'cva') continue;
|
|
4263
|
+
|
|
4264
|
+
const cvaArgs = init.getArguments();
|
|
4265
|
+
if (cvaArgs.length === 0) return '';
|
|
4266
|
+
|
|
4267
|
+
const baseClasses = this.extractStringValue(cvaArgs[0]);
|
|
4268
|
+
const classes: string[] = baseClasses ? [baseClasses] : [];
|
|
4269
|
+
|
|
4270
|
+
if (cvaArgs.length >= 2 && Node.isObjectLiteralExpression(cvaArgs[1])) {
|
|
4271
|
+
const configObj = cvaArgs[1];
|
|
4272
|
+
|
|
4273
|
+
// Parse the call argument object e.g. { variant } or { variant: "destructive" }
|
|
4274
|
+
const callArgs = callExpr.getArguments();
|
|
4275
|
+
const requestedVariants: Record<string, string> = {};
|
|
4276
|
+
if (callArgs.length > 0 && Node.isObjectLiteralExpression(callArgs[0])) {
|
|
4277
|
+
for (const prop of callArgs[0].getProperties()) {
|
|
4278
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
4279
|
+
const key = prop.getName();
|
|
4280
|
+
const valInit = prop.getInitializer();
|
|
4281
|
+
if (!valInit) continue;
|
|
4282
|
+
const resolved = this.resolveExpressionValue(valInit, propsContext);
|
|
4283
|
+
if (typeof resolved === 'string') requestedVariants[key] = resolved;
|
|
4284
|
+
else if (Node.isStringLiteral(valInit)) requestedVariants[key] = valInit.getLiteralValue();
|
|
4285
|
+
} else if (Node.isShorthandPropertyAssignment(prop)) {
|
|
4286
|
+
// { variant } shorthand — look up value from propsContext
|
|
4287
|
+
const key = prop.getName();
|
|
4288
|
+
const resolved = propsContext.get(key);
|
|
4289
|
+
if (typeof resolved === 'string') requestedVariants[key] = resolved;
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
// Extract defaultVariants
|
|
4295
|
+
const defaultVariants: Record<string, string> = {};
|
|
4296
|
+
const defaultVariantsProp = configObj.getProperty('defaultVariants');
|
|
4297
|
+
if (defaultVariantsProp && Node.isPropertyAssignment(defaultVariantsProp)) {
|
|
4298
|
+
const defaultObj = defaultVariantsProp.getInitializer();
|
|
4299
|
+
if (defaultObj && Node.isObjectLiteralExpression(defaultObj)) {
|
|
4300
|
+
for (const prop of defaultObj.getProperties()) {
|
|
4301
|
+
if (!Node.isPropertyAssignment(prop)) continue;
|
|
4302
|
+
const val = this.extractStringValue(prop.getInitializer()!);
|
|
4303
|
+
defaultVariants[prop.getName()] = val.replace(/['"]/g, '');
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
// Look up each variant and add its classes
|
|
4309
|
+
const variantsProp = configObj.getProperty('variants');
|
|
4310
|
+
if (variantsProp && Node.isPropertyAssignment(variantsProp)) {
|
|
4311
|
+
const variantsObj = variantsProp.getInitializer();
|
|
4312
|
+
if (variantsObj && Node.isObjectLiteralExpression(variantsObj)) {
|
|
4313
|
+
for (const variantProp of variantsObj.getProperties()) {
|
|
4314
|
+
if (!Node.isPropertyAssignment(variantProp)) continue;
|
|
4315
|
+
const variantName = variantProp.getName();
|
|
4316
|
+
const selectedValue = requestedVariants[variantName] ?? defaultVariants[variantName];
|
|
4317
|
+
if (!selectedValue) continue;
|
|
4318
|
+
|
|
4319
|
+
const variantValuesObj = variantProp.getInitializer();
|
|
4320
|
+
if (!variantValuesObj || !Node.isObjectLiteralExpression(variantValuesObj)) continue;
|
|
4321
|
+
|
|
4322
|
+
const matchedProp = variantValuesObj.getProperty(selectedValue);
|
|
4323
|
+
if (matchedProp && Node.isPropertyAssignment(matchedProp)) {
|
|
4324
|
+
const variantClassStr = this.extractStringValue(matchedProp.getInitializer()!);
|
|
4325
|
+
if (variantClassStr) classes.push(variantClassStr);
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4332
|
+
return resolveClassConflicts(classes.filter(Boolean).join(' '));
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
return null;
|
|
4336
|
+
}
|
|
4337
|
+
|
|
2464
4338
|
/**
|
|
2465
4339
|
* Extract props from a JSX node (opening element or self-closing element).
|
|
2466
4340
|
* Uses getDescendantsOfKind to safely get only JsxAttribute nodes,
|
|
@@ -2469,13 +4343,13 @@ export class ComponentScanner {
|
|
|
2469
4343
|
private extractPropsFromNode(
|
|
2470
4344
|
jsxNode: Node,
|
|
2471
4345
|
componentName: string,
|
|
2472
|
-
propsContext: Map<string,
|
|
4346
|
+
propsContext: Map<string, ResolvedExpressionValue> = new Map()
|
|
2473
4347
|
): ComponentInstance {
|
|
2474
|
-
const props: Record<string,
|
|
4348
|
+
const props: Record<string, ResolvedExpressionValue> = {};
|
|
2475
4349
|
|
|
2476
4350
|
const directAttributes =
|
|
2477
|
-
|
|
2478
|
-
?
|
|
4351
|
+
Node.isJsxOpeningElement(jsxNode) || Node.isJsxSelfClosingElement(jsxNode)
|
|
4352
|
+
? jsxNode.getAttributes()
|
|
2479
4353
|
: [];
|
|
2480
4354
|
const attributes = directAttributes.length > 0
|
|
2481
4355
|
? directAttributes
|
|
@@ -2498,7 +4372,8 @@ export class ComponentScanner {
|
|
|
2498
4372
|
continue;
|
|
2499
4373
|
}
|
|
2500
4374
|
|
|
2501
|
-
|
|
4375
|
+
if (!Node.isJsxAttribute(attr)) continue;
|
|
4376
|
+
const nameNode = attr.getNameNode();
|
|
2502
4377
|
if (!nameNode) continue;
|
|
2503
4378
|
const name = nameNode.getText();
|
|
2504
4379
|
const init = attr.getInitializer();
|
|
@@ -2528,12 +4403,24 @@ export class ComponentScanner {
|
|
|
2528
4403
|
if (expr && Node.isStringLiteral(expr)) {
|
|
2529
4404
|
props[name] = expr.getLiteralValue();
|
|
2530
4405
|
} else if (expr && Node.isObjectLiteralExpression(expr)) {
|
|
2531
|
-
props[name] = this.parseObjectLiteralExpression(expr);
|
|
4406
|
+
props[name] = this.parseObjectLiteralExpression(expr, propsContext);
|
|
2532
4407
|
} else if (expr && Node.isArrayLiteralExpression(expr)) {
|
|
2533
4408
|
props[name] = this.parseLiteralValue(expr);
|
|
2534
4409
|
} else if (expr) {
|
|
2535
4410
|
const resolved = this.resolveExpressionValue(expr, propsContext);
|
|
2536
|
-
|
|
4411
|
+
if (resolved !== undefined) {
|
|
4412
|
+
props[name] = resolved;
|
|
4413
|
+
}
|
|
4414
|
+
// Unresolved expression (typically an unresolved identifier like a
|
|
4415
|
+
// function parameter with no default — e.g. `data-inset={inset}`
|
|
4416
|
+
// where `inset` is undefined in the story). Falling back to
|
|
4417
|
+
// `expr.getText()` here used to serialise the identifier *name*
|
|
4418
|
+
// as the attribute's value (`"inset"`), which the variant engine
|
|
4419
|
+
// mistook for "data-inset is present" and activated every
|
|
4420
|
+
// `data-[inset]:` utility (e.g. DropdownMenuItem's
|
|
4421
|
+
// `data-[inset]:pl-8`, leaving every item indented 32px when no
|
|
4422
|
+
// story used `inset`). Omitting the prop matches React's runtime
|
|
4423
|
+
// behaviour: `data-x={undefined}` renders no attribute.
|
|
2537
4424
|
}
|
|
2538
4425
|
}
|
|
2539
4426
|
}
|
|
@@ -2587,26 +4474,73 @@ export class ComponentScanner {
|
|
|
2587
4474
|
// Helper Methods
|
|
2588
4475
|
// ===========================================================================
|
|
2589
4476
|
|
|
4477
|
+
private shouldDeferCvaToCompound(sourceFile: SourceFile, filePath: string): boolean {
|
|
4478
|
+
const topLevelNames = this.getTopLevelComponentNames(sourceFile);
|
|
4479
|
+
if (topLevelNames.length < 2) return false;
|
|
4480
|
+
|
|
4481
|
+
const fileBaseName = path.basename(filePath, '.tsx');
|
|
4482
|
+
const fileComponentName = fileBaseName
|
|
4483
|
+
.split(/[-_]/g)
|
|
4484
|
+
.filter(Boolean)
|
|
4485
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
4486
|
+
.join('');
|
|
4487
|
+
if (!fileComponentName) return false;
|
|
4488
|
+
|
|
4489
|
+
// Prefer compound analysis when the file clearly defines a main component
|
|
4490
|
+
// plus subcomponents (e.g. pagination.tsx, dialog.tsx).
|
|
4491
|
+
return topLevelNames.indexOf(fileComponentName) !== -1;
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
private getTopLevelComponentNames(sourceFile: SourceFile): string[] {
|
|
4495
|
+
const names = new Set<string>();
|
|
4496
|
+
|
|
4497
|
+
for (const statement of sourceFile.getVariableStatements()) {
|
|
4498
|
+
const declarations = statement.getDeclarationList().getDeclarations();
|
|
4499
|
+
for (const decl of declarations) {
|
|
4500
|
+
const name = decl.getName();
|
|
4501
|
+
if (/^[A-Z]/.test(name)) names.add(name);
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
for (const func of sourceFile.getFunctions()) {
|
|
4506
|
+
const name = func.getName();
|
|
4507
|
+
if (name && /^[A-Z]/.test(name)) names.add(name);
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
return Array.from(names);
|
|
4511
|
+
}
|
|
4512
|
+
|
|
2590
4513
|
/**
|
|
2591
4514
|
* Find all component declarations in a file
|
|
2592
4515
|
*/
|
|
2593
4516
|
private findComponentDeclarations(sourceFile: SourceFile): Array<{ name: string; node: Node }> {
|
|
2594
4517
|
const components: Array<{ name: string; node: Node }> = [];
|
|
4518
|
+
const seen = new Set<string>();
|
|
2595
4519
|
|
|
2596
|
-
// Find variable
|
|
4520
|
+
// Find variable declarations that look like components.
|
|
2597
4521
|
const variableStatements = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement);
|
|
2598
4522
|
|
|
2599
4523
|
for (const statement of variableStatements) {
|
|
2600
4524
|
const declarations = statement.getDeclarationList().getDeclarations();
|
|
2601
4525
|
for (const decl of declarations) {
|
|
2602
4526
|
const name = decl.getName();
|
|
2603
|
-
|
|
2604
|
-
if (/^[A-Z]/.test(name)) {
|
|
4527
|
+
if (/^[A-Z]/.test(name) && !seen.has(name)) {
|
|
2605
4528
|
components.push({ name, node: decl });
|
|
4529
|
+
seen.add(name);
|
|
2606
4530
|
}
|
|
2607
4531
|
}
|
|
2608
4532
|
}
|
|
2609
4533
|
|
|
4534
|
+
// Also include function declarations (shadcn files often use function syntax).
|
|
4535
|
+
const functions = sourceFile.getFunctions();
|
|
4536
|
+
for (const func of functions) {
|
|
4537
|
+
const name = func.getName();
|
|
4538
|
+
if (name && /^[A-Z]/.test(name) && !seen.has(name)) {
|
|
4539
|
+
components.push({ name, node: func });
|
|
4540
|
+
seen.add(name);
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
|
|
2610
4544
|
return components;
|
|
2611
4545
|
}
|
|
2612
4546
|
|