inkbridge 0.1.0-beta.21 → 0.1.0-beta.23
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 +29 -0
- package/code.js +15 -15
- package/manifest.json +1 -2
- package/package.json +40 -22
- package/scanner/border-dash-pattern-regression.ts +163 -0
- package/scanner/child-sizing-matrix-regression.ts +9 -0
- package/scanner/cli.ts +21 -5
- package/scanner/component-scanner.ts +1333 -77
- package/scanner/conditional-map-branch-regression.ts +180 -0
- package/scanner/css-token-reader.ts +66 -5
- package/scanner/dialog-content-gate-regression.ts +195 -0
- package/scanner/expression-evaluator-regression.ts +432 -0
- package/scanner/framework-adapter-shadcn-regression.ts +157 -1
- package/scanner/hidden-check-drift-regression.ts +125 -0
- package/scanner/horizontal-text-shrink-regression.ts +230 -0
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/inline-flex-regression.ts +5 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/portal-class-strip-regression.ts +109 -0
- package/scanner/responsive-hidden-inline-regression.ts +226 -0
- package/scanner/responsive-opt-in-regression.ts +212 -0
- package/scanner/select-root-flatten-regression.ts +314 -0
- package/scanner/space-between-single-child-regression.ts +163 -0
- package/scanner/story-args-resolution-regression.ts +311 -0
- package/scanner/story-dimensioning-regression.ts +76 -1
- package/scanner/style-map.ts +57 -0
- package/scanner/table-column-alignment-regression.ts +355 -0
- package/scanner/ternary-fragment-branch-regression.ts +196 -0
- package/scanner/text-truncate-regression.ts +481 -0
- package/scanner/types.ts +13 -0
- package/src/components/component-gen.ts +21 -38
- package/src/design-system/cva-master.ts +11 -18
- package/src/design-system/design-system.ts +36 -7
- package/src/design-system/frame-stabilizers.ts +109 -12
- package/src/design-system/preview-builder.ts +38 -0
- package/src/design-system/selectable-state.ts +8 -1
- package/src/design-system/story-builder.ts +62 -32
- package/src/design-system/story-dimensioning.ts +14 -3
- package/src/design-system/tag-predicates.ts +8 -0
- package/src/design-system/typography.ts +26 -0
- package/src/design-system/ui-builder.ts +188 -60
- package/src/effects/icon-builder.ts +8 -0
- package/src/framework-adapters/shadcn.ts +113 -0
- package/src/github/github.ts +22 -4
- package/src/layout/index.ts +4 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/layout-parser.ts +36 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/layout/table-layout.ts +271 -0
- package/src/layout/text-truncate-pass.ts +151 -0
- package/src/layout/width-solver.ts +63 -17
- package/src/main.ts +37 -124
- package/src/plugin/config.ts +21 -0
- package/src/plugin/packs/pack-provider.ts +20 -4
- package/src/plugin/packs/packs.ts +14 -0
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/jsx-utils.ts +39 -0
- package/src/tailwind/node-ir.ts +8 -1
- package/src/tailwind/responsive-analyzer.ts +57 -3
- package/src/tailwind/tailwind.ts +344 -51
- package/src/text/index.ts +1 -0
- package/src/text/inline-text.ts +112 -12
- package/src/text/text-builder.ts +2 -2
- package/src/text/text-truncate.ts +101 -0
- package/src/tokens/tokens.ts +107 -16
- package/src/tokens/variables.ts +203 -46
- package/templates/scan-components-route.ts +8 -0
- package/ui.html +144 -43
|
@@ -8,7 +8,23 @@
|
|
|
8
8
|
* - Simple: Components with static classes
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
Project,
|
|
13
|
+
SyntaxKind,
|
|
14
|
+
Node,
|
|
15
|
+
SourceFile,
|
|
16
|
+
type ArrowFunction,
|
|
17
|
+
type FunctionDeclaration,
|
|
18
|
+
type FunctionExpression,
|
|
19
|
+
type JsxAttribute,
|
|
20
|
+
} from 'ts-morph';
|
|
21
|
+
|
|
22
|
+
// Sentinel for the user-function interpreter: distinguishes "this statement
|
|
23
|
+
// produced no return value, continue with the next" from "explicit `return
|
|
24
|
+
// undefined`" (which IS a valid evaluated result and should bubble up).
|
|
25
|
+
const EVAL_NEXT: unique symbol = Symbol('evalNext');
|
|
26
|
+
type EvalStatementResult = ResolvedExpressionValue | typeof EVAL_NEXT;
|
|
27
|
+
type UserFunctionNode = ArrowFunction | FunctionExpression | FunctionDeclaration;
|
|
12
28
|
import * as path from 'path';
|
|
13
29
|
import * as fs from 'fs';
|
|
14
30
|
import type {
|
|
@@ -219,6 +235,7 @@ export class ComponentScanner {
|
|
|
219
235
|
}
|
|
220
236
|
}
|
|
221
237
|
|
|
238
|
+
this.applyTreeTransforms(analysis);
|
|
222
239
|
analyses.push(analysis);
|
|
223
240
|
}
|
|
224
241
|
} catch (err) {
|
|
@@ -705,13 +722,20 @@ export class ComponentScanner {
|
|
|
705
722
|
* `{rewardSol.toFixed(4)}` need a numeric receiver to evaluate. Try
|
|
706
723
|
* to parse each value as a number first; fall back to the literal
|
|
707
724
|
* string for non-numeric args (`label = "Click me"`). Booleans are
|
|
708
|
-
* matched by exact "true" / "false".
|
|
725
|
+
* matched by exact "true" / "false". `null` / `undefined` keyword
|
|
726
|
+
* literals are dropped — they leave the prop unbound, which is what
|
|
727
|
+
* a conditional like `{viewingWalletLabel ? <Banner/> : null}`
|
|
728
|
+
* expects when the consumer wrote `viewingWalletLabel: null`.
|
|
729
|
+
* Without this drop, `extractStringValue("null")` returns the
|
|
730
|
+
* truthy string `"null"` and the wallet div renders with literal
|
|
731
|
+
* "null" text.
|
|
709
732
|
*/
|
|
710
733
|
private buildArgsPropsContext(args: Record<string, string>): Map<string, ResolvedExpressionValue> {
|
|
711
734
|
const out = new Map<string, ResolvedExpressionValue>();
|
|
712
735
|
for (const key in args) {
|
|
713
736
|
if (!Object.prototype.hasOwnProperty.call(args, key)) continue;
|
|
714
737
|
const raw = args[key];
|
|
738
|
+
if (raw === 'null' || raw === 'undefined') continue;
|
|
715
739
|
if (raw === 'true') { out.set(key, true); continue; }
|
|
716
740
|
if (raw === 'false') { out.set(key, false); continue; }
|
|
717
741
|
if (raw !== '' && !isNaN(Number(raw))) { out.set(key, Number(raw)); continue; }
|
|
@@ -886,6 +910,33 @@ export class ComponentScanner {
|
|
|
886
910
|
init.getProperty('decorators')
|
|
887
911
|
);
|
|
888
912
|
|
|
913
|
+
// Check for `parameters.inkbridge.responsive` opt-in/opt-out.
|
|
914
|
+
// Storybook idiom: { parameters: { inkbridge: { responsive: true } } }.
|
|
915
|
+
// The plugin gates responsive-breakpoint frames on this field —
|
|
916
|
+
// see preview-builder.ts and story-builder.ts. Unset = default
|
|
917
|
+
// policy (only the canonical Default story gets responsive).
|
|
918
|
+
const parametersProp = init.getProperty('parameters');
|
|
919
|
+
if (parametersProp && Node.isPropertyAssignment(parametersProp)) {
|
|
920
|
+
const paramsInit = parametersProp.getInitializer();
|
|
921
|
+
if (paramsInit && Node.isObjectLiteralExpression(paramsInit)) {
|
|
922
|
+
const inkbridgeProp = paramsInit.getProperty('inkbridge');
|
|
923
|
+
if (inkbridgeProp && Node.isPropertyAssignment(inkbridgeProp)) {
|
|
924
|
+
const inkbridgeInit = inkbridgeProp.getInitializer();
|
|
925
|
+
if (inkbridgeInit && Node.isObjectLiteralExpression(inkbridgeInit)) {
|
|
926
|
+
const responsiveProp = inkbridgeInit.getProperty('responsive');
|
|
927
|
+
if (responsiveProp && Node.isPropertyAssignment(responsiveProp)) {
|
|
928
|
+
const responsiveInit = responsiveProp.getInitializer();
|
|
929
|
+
if (responsiveInit) {
|
|
930
|
+
const kind = responsiveInit.getKind();
|
|
931
|
+
if (kind === SyntaxKind.TrueKeyword) story.responsive = true;
|
|
932
|
+
else if (kind === SyntaxKind.FalseKeyword) story.responsive = false;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
889
940
|
// Check for `args` property
|
|
890
941
|
const argsProp = init.getProperty('args');
|
|
891
942
|
if (argsProp && Node.isPropertyAssignment(argsProp)) {
|
|
@@ -934,13 +985,38 @@ export class ComponentScanner {
|
|
|
934
985
|
// this an args-based BlockTable story would see
|
|
935
986
|
// `blocks = "mockBlocks"` and the .map call inside the
|
|
936
987
|
// component couldn't resolve the iteration source.
|
|
988
|
+
//
|
|
989
|
+
// Handles both literal property assignments AND spread
|
|
990
|
+
// assignments (`...BASE_ARGS`). Without spread handling,
|
|
991
|
+
// shared-args patterns like
|
|
992
|
+
// const BASE_ARGS = { marketSymbol: "SOL", ... };
|
|
993
|
+
// export const WithLegend = { args: { ...BASE_ARGS, markers: [...] } };
|
|
994
|
+
// would silently drop every base arg, so the component
|
|
995
|
+
// body sees `marketSymbol = undefined` and renders blank.
|
|
937
996
|
for (const prop of argsInit.getProperties()) {
|
|
997
|
+
if (Node.isSpreadAssignment(prop)) {
|
|
998
|
+
const spreadResolved = this.resolveExpressionValue(prop.getExpression(), new Map());
|
|
999
|
+
if (spreadResolved && typeof spreadResolved === 'object' && !Array.isArray(spreadResolved)) {
|
|
1000
|
+
for (const [k, v] of Object.entries(spreadResolved as Record<string, ResolvedExpressionValue>)) {
|
|
1001
|
+
if (v !== undefined && v !== null) argsContext.set(k, v);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
938
1006
|
if (!Node.isPropertyAssignment(prop)) continue;
|
|
939
1007
|
const propInit = prop.getInitializer();
|
|
940
1008
|
if (!propInit) continue;
|
|
941
1009
|
const resolved = this.resolveExpressionValue(propInit, new Map());
|
|
942
|
-
|
|
943
|
-
|
|
1010
|
+
// Fallback to parseLiteralValue for shapes resolveExpressionValue
|
|
1011
|
+
// doesn't cover (array literals, in particular — needed for
|
|
1012
|
+
// `markers: [...]` and similar inline-array story args).
|
|
1013
|
+
// `null` / `undefined` are deliberately skipped — they have
|
|
1014
|
+
// no propsContext-bearing value to thread through.
|
|
1015
|
+
const finalValue = resolved !== undefined
|
|
1016
|
+
? resolved
|
|
1017
|
+
: this.parseLiteralValue(propInit);
|
|
1018
|
+
if (finalValue !== undefined && finalValue !== null) {
|
|
1019
|
+
argsContext.set(prop.getName(), finalValue);
|
|
944
1020
|
}
|
|
945
1021
|
}
|
|
946
1022
|
const localCompBody = localComponents.get(metaComponentName);
|
|
@@ -2339,6 +2415,14 @@ export class ComponentScanner {
|
|
|
2339
2415
|
|
|
2340
2416
|
let itemParamName: string;
|
|
2341
2417
|
let destructuringBindings: Map<string, string> | null = null;
|
|
2418
|
+
// Array-tuple destructuring (`([mint, meta]) =>`): ordered list of
|
|
2419
|
+
// local names. Common when iterating `Object.entries(x).map(...)`
|
|
2420
|
+
// where each item is a `[key, value]` tuple. Without this branch
|
|
2421
|
+
// the param's `.getName()` returns the raw bracket text and
|
|
2422
|
+
// references to `mint` / `meta` inside the callback don't resolve,
|
|
2423
|
+
// so JSX values stay unsubstituted (e.g. `<SelectItem value={mint}>`
|
|
2424
|
+
// renders with the raw string `mint`).
|
|
2425
|
+
let arrayDestructuringNames: string[] | null = null;
|
|
2342
2426
|
const firstParamNameNode = params[0].getNameNode();
|
|
2343
2427
|
if (Node.isObjectBindingPattern(firstParamNameNode)) {
|
|
2344
2428
|
destructuringBindings = new Map();
|
|
@@ -2350,6 +2434,18 @@ export class ComponentScanner {
|
|
|
2350
2434
|
destructuringBindings.set(localName, sourceKey);
|
|
2351
2435
|
}
|
|
2352
2436
|
itemParamName = '__destructured__';
|
|
2437
|
+
} else if (Node.isArrayBindingPattern(firstParamNameNode)) {
|
|
2438
|
+
arrayDestructuringNames = [];
|
|
2439
|
+
for (const element of firstParamNameNode.getElements()) {
|
|
2440
|
+
// OmittedExpression for holes like `[, b]` — push '' to keep
|
|
2441
|
+
// index alignment but skip binding.
|
|
2442
|
+
if (Node.isBindingElement(element)) {
|
|
2443
|
+
arrayDestructuringNames.push(element.getNameNode().getText());
|
|
2444
|
+
} else {
|
|
2445
|
+
arrayDestructuringNames.push('');
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
itemParamName = '__destructured__';
|
|
2353
2449
|
} else {
|
|
2354
2450
|
itemParamName = params[0].getName();
|
|
2355
2451
|
}
|
|
@@ -2446,6 +2542,15 @@ export class ComponentScanner {
|
|
|
2446
2542
|
arrayValue = this.findArrayValue(arrayName, sourceFile);
|
|
2447
2543
|
}
|
|
2448
2544
|
|
|
2545
|
+
// Last-resort: try the full expression-level resolver. Handles
|
|
2546
|
+
// call-shape iterators like `Object.entries(X).map(...)` where
|
|
2547
|
+
// `arrayName` is the raw expression text (e.g.
|
|
2548
|
+
// "Object.entries(TOKEN_METADATA)") and `findArrayValue`'s
|
|
2549
|
+
// name-based lookup never matches.
|
|
2550
|
+
if (!arrayValue) {
|
|
2551
|
+
arrayValue = this.resolveArrayFromExpression(arrayExpr, sourceFile);
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2449
2554
|
if (!arrayValue || arrayValue.length === 0) {
|
|
2450
2555
|
// If we can't find the array value, generate one iteration as a template
|
|
2451
2556
|
const templateJsx = this.buildJsxTreeWithSubstitution(
|
|
@@ -2491,6 +2596,15 @@ export class ComponentScanner {
|
|
|
2491
2596
|
// depend on `index` or the current item.
|
|
2492
2597
|
if (itemParamName && itemParamName !== '__destructured__' && item != null) {
|
|
2493
2598
|
iterationContext.set(itemParamName, item);
|
|
2599
|
+
} else if (itemParamName === '__destructured__' && arrayDestructuringNames) {
|
|
2600
|
+
// Array-tuple destructuring: bind by index. Items shorter than
|
|
2601
|
+
// the binding list leave the trailing names undefined.
|
|
2602
|
+
if (Array.isArray(item)) {
|
|
2603
|
+
for (let bi = 0; bi < arrayDestructuringNames.length; bi++) {
|
|
2604
|
+
const name = arrayDestructuringNames[bi];
|
|
2605
|
+
if (name) iterationContext.set(name, item[bi]);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2494
2608
|
} else if (itemParamName === '__destructured__' && typeof item === 'object' && item !== null) {
|
|
2495
2609
|
for (const [k, v] of Object.entries(item)) iterationContext.set(k, v);
|
|
2496
2610
|
}
|
|
@@ -2568,6 +2682,17 @@ export class ComponentScanner {
|
|
|
2568
2682
|
return jsonImported;
|
|
2569
2683
|
}
|
|
2570
2684
|
|
|
2685
|
+
// Named import from a TypeScript / JavaScript file. Covers the common
|
|
2686
|
+
// pattern `import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants"`
|
|
2687
|
+
// followed by `PRICE_CHART_TIMEFRAMES.map(...)` in the JSX body. Without
|
|
2688
|
+
// this branch, `.map()` falls through to the placeholder-iteration path
|
|
2689
|
+
// (one synthetic item) and the rendered tree shows zero buttons.
|
|
2690
|
+
const fromNamedImport = this.findArrayInNamedImports(arrayName, sourceFile);
|
|
2691
|
+
if (fromNamedImport) {
|
|
2692
|
+
this.arrayValueCache.set(cacheKey, fromNamedImport);
|
|
2693
|
+
return fromNamedImport;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2571
2696
|
// Look for default parameter value in function: function Comp({ features = defaultFeatures })
|
|
2572
2697
|
for (const func of sourceFile.getFunctions()) {
|
|
2573
2698
|
const params = func.getParameters();
|
|
@@ -2630,6 +2755,92 @@ export class ComponentScanner {
|
|
|
2630
2755
|
return null;
|
|
2631
2756
|
}
|
|
2632
2757
|
|
|
2758
|
+
/**
|
|
2759
|
+
* Resolve a named-imported array constant by following the import
|
|
2760
|
+
* declaration to its source file and recursing into `findArrayValue`
|
|
2761
|
+
* against that file. Covers patterns like:
|
|
2762
|
+
*
|
|
2763
|
+
* import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants";
|
|
2764
|
+
* ...
|
|
2765
|
+
* {PRICE_CHART_TIMEFRAMES.map((option) => <Button>{option.label}</Button>)}
|
|
2766
|
+
*
|
|
2767
|
+
* Without this, `findArrayValue` only inspects the current source file's
|
|
2768
|
+
* declarations, returns `null` for cross-file named imports, and the
|
|
2769
|
+
* `.map()` falls through to the placeholder-iteration path (one synthetic
|
|
2770
|
+
* item) — so consumer JSX renders zero buttons instead of N.
|
|
2771
|
+
*
|
|
2772
|
+
* Returns `null` for default imports (the JSON branch handles `import x from
|
|
2773
|
+
* "./foo.json"` separately) and for any import whose target file can't be
|
|
2774
|
+
* resolved to disk via `resolveImportedComponentPath`.
|
|
2775
|
+
*/
|
|
2776
|
+
private findArrayInNamedImports(name: string, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
|
|
2777
|
+
const fileDir = path.dirname(sourceFile.getFilePath());
|
|
2778
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
2779
|
+
const named = imp.getNamedImports().find((n) => {
|
|
2780
|
+
const localName = n.getAliasNode()?.getText() || n.getName();
|
|
2781
|
+
return localName === name;
|
|
2782
|
+
});
|
|
2783
|
+
if (!named) continue;
|
|
2784
|
+
const moduleSpec = imp.getModuleSpecifierValue();
|
|
2785
|
+
const importedFile = this.openImportedSourceFile(fileDir, moduleSpec);
|
|
2786
|
+
if (!importedFile) return null;
|
|
2787
|
+
// Resolve under the IMPORTED source's name (the export may rename
|
|
2788
|
+
// via `import { Foo as Bar }` — we follow the exported name).
|
|
2789
|
+
const exportedName = named.getName();
|
|
2790
|
+
return this.findArrayValue(exportedName, importedFile);
|
|
2791
|
+
}
|
|
2792
|
+
return null;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* Try to load the source file the import declaration points at, against
|
|
2797
|
+
* both the disk (production scan) and the in-memory ts-morph project
|
|
2798
|
+
* (regression fixtures that don't touch the filesystem). Returns `null`
|
|
2799
|
+
* for imports whose target can't be located in either place.
|
|
2800
|
+
*
|
|
2801
|
+
* Built on top of `resolveImportedComponentPath`'s extension-trying logic
|
|
2802
|
+
* — we re-implement the same candidate list and probe the project's
|
|
2803
|
+
* tracked files when `fs.existsSync` says "not on disk". Keeps the
|
|
2804
|
+
* regression fixtures self-contained (no temp-dir writes) while staying
|
|
2805
|
+
* correct for real consumer scans.
|
|
2806
|
+
*/
|
|
2807
|
+
private openImportedSourceFile(baseDir: string, moduleSpec: string): SourceFile | null {
|
|
2808
|
+
// Fast path: real files on disk.
|
|
2809
|
+
const onDiskPath = this.resolveImportedComponentPath(baseDir, moduleSpec);
|
|
2810
|
+
if (onDiskPath) {
|
|
2811
|
+
try {
|
|
2812
|
+
const fromDisk = this.project.addSourceFileAtPathIfExists(onDiskPath);
|
|
2813
|
+
if (fromDisk) return fromDisk;
|
|
2814
|
+
} catch (_e) {
|
|
2815
|
+
/* fall through to in-memory probe */
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// In-memory probe: build the same candidate list (with extension
|
|
2820
|
+
// variants) and check the project's tracked files. Lets fixtures call
|
|
2821
|
+
// `project.createSourceFile(path, content)` and have those files be
|
|
2822
|
+
// findable through normal import resolution.
|
|
2823
|
+
let basePath: string | null = null;
|
|
2824
|
+
if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
|
|
2825
|
+
basePath = path.resolve(baseDir, moduleSpec);
|
|
2826
|
+
} else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
|
|
2827
|
+
basePath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
|
|
2828
|
+
}
|
|
2829
|
+
if (!basePath) return null;
|
|
2830
|
+
const candidates = [
|
|
2831
|
+
basePath,
|
|
2832
|
+
`${basePath}.tsx`,
|
|
2833
|
+
`${basePath}.ts`,
|
|
2834
|
+
path.join(basePath, 'index.tsx'),
|
|
2835
|
+
path.join(basePath, 'index.ts'),
|
|
2836
|
+
];
|
|
2837
|
+
for (const candidate of candidates) {
|
|
2838
|
+
const inProject = this.project.getSourceFile(candidate);
|
|
2839
|
+
if (inProject) return inProject;
|
|
2840
|
+
}
|
|
2841
|
+
return null;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2633
2844
|
/**
|
|
2634
2845
|
* Resolve an expression to an array of items. Handles:
|
|
2635
2846
|
*
|
|
@@ -2673,6 +2884,18 @@ export class ComponentScanner {
|
|
|
2673
2884
|
if (PASS_THROUGH_METHODS.has(methodName)) {
|
|
2674
2885
|
return this.resolveArrayFromExpression(callee.getExpression(), sourceFile);
|
|
2675
2886
|
}
|
|
2887
|
+
// `Object.entries(x)` / `Object.keys(x)` / `Object.values(x)` —
|
|
2888
|
+
// common shape transforms used inside `.map()` callbacks to
|
|
2889
|
+
// iterate POJO tables (e.g. shadcn dropdown built from
|
|
2890
|
+
// `Object.entries(TOKEN_METADATA).map(...)`). The general-purpose
|
|
2891
|
+
// evaluator handles these in `evaluateBuiltinCall`; resolve the
|
|
2892
|
+
// call expression through it and unwrap to an array.
|
|
2893
|
+
const baseExpr = callee.getExpression();
|
|
2894
|
+
if (Node.isIdentifier(baseExpr) && baseExpr.getText() === 'Object'
|
|
2895
|
+
&& (methodName === 'entries' || methodName === 'keys' || methodName === 'values')) {
|
|
2896
|
+
const resolved = this.resolveExpressionValue(unwrapped, new Map());
|
|
2897
|
+
if (Array.isArray(resolved)) return resolved as ResolvedExpressionValue[];
|
|
2898
|
+
}
|
|
2676
2899
|
}
|
|
2677
2900
|
}
|
|
2678
2901
|
|
|
@@ -2763,33 +2986,26 @@ export class ComponentScanner {
|
|
|
2763
2986
|
|
|
2764
2987
|
if (!Node.isArrayLiteralExpression(arrayLit)) return result;
|
|
2765
2988
|
|
|
2989
|
+
// Delegate each element to the full `resolveExpressionValue` resolver
|
|
2990
|
+
// so identifier references (`[USDC_MINT, USDT_MINT]` → the actual mint
|
|
2991
|
+
// strings), imports, casts, parens, template literals, and nested
|
|
2992
|
+
// arrays/objects all collapse to their static value. The previous
|
|
2993
|
+
// implementation only handled string / number / object-literal
|
|
2994
|
+
// elements directly and dropped everything else, so a story arg like
|
|
2995
|
+
// `allowedInputMints: [USDC_MINT, USDT_MINT]` came back as `[]` and a
|
|
2996
|
+
// downstream `.map()` over it expanded to a single placeholder
|
|
2997
|
+
// iteration with `value = { name: "Item", description: "Description" }`
|
|
2998
|
+
// instead of the two real mint values. Same class of bug we hit on
|
|
2999
|
+
// DecreasePositionModal with Object.entries — and on IncreasePosition
|
|
3000
|
+
// when the array was identifier-typed rather than literal-typed.
|
|
3001
|
+
//
|
|
3002
|
+
// The recursion is safe: each element is one syntactic level deeper
|
|
3003
|
+
// than the array literal, and `resolveExpressionValue` only re-enters
|
|
3004
|
+
// `parseArrayLiteral` for nested ArrayLiteralExpression elements.
|
|
2766
3005
|
for (const element of arrayLit.getElements()) {
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
if (Node.isPropertyAssignment(prop)) {
|
|
2771
|
-
const name = prop.getName();
|
|
2772
|
-
const init = prop.getInitializer();
|
|
2773
|
-
if (init) {
|
|
2774
|
-
if (Node.isStringLiteral(init)) {
|
|
2775
|
-
obj[name] = init.getLiteralValue();
|
|
2776
|
-
} else if (Node.isNumericLiteral(init)) {
|
|
2777
|
-
obj[name] = Number(init.getLiteralText());
|
|
2778
|
-
} else if (init.getKind() === SyntaxKind.TrueKeyword) {
|
|
2779
|
-
obj[name] = true;
|
|
2780
|
-
} else if (init.getKind() === SyntaxKind.FalseKeyword) {
|
|
2781
|
-
obj[name] = false;
|
|
2782
|
-
} else {
|
|
2783
|
-
obj[name] = init.getText();
|
|
2784
|
-
}
|
|
2785
|
-
}
|
|
2786
|
-
}
|
|
2787
|
-
}
|
|
2788
|
-
result.push(obj);
|
|
2789
|
-
} else if (Node.isStringLiteral(element)) {
|
|
2790
|
-
result.push(element.getLiteralValue());
|
|
2791
|
-
} else if (Node.isNumericLiteral(element)) {
|
|
2792
|
-
result.push(Number(element.getLiteralText()));
|
|
3006
|
+
const resolved = this.resolveExpressionValue(element, new Map());
|
|
3007
|
+
if (resolved !== undefined) {
|
|
3008
|
+
result.push(resolved);
|
|
2793
3009
|
}
|
|
2794
3010
|
}
|
|
2795
3011
|
|
|
@@ -3212,6 +3428,261 @@ export class ComponentScanner {
|
|
|
3212
3428
|
return [];
|
|
3213
3429
|
}
|
|
3214
3430
|
|
|
3431
|
+
/**
|
|
3432
|
+
* Apply scanner-level tree transforms to a JSX tree. Returns the
|
|
3433
|
+
* transformed tree (input is not mutated).
|
|
3434
|
+
*
|
|
3435
|
+
* Transforms run here mirror the `__fromPortal` pattern: the tree gets
|
|
3436
|
+
* annotated with data props that downstream render code reads, and
|
|
3437
|
+
* "logical-only" wrapper components (Radix `*.Root` aliases that render
|
|
3438
|
+
* `display: contents` in the browser) are flattened away so they don't
|
|
3439
|
+
* trap their children in a hugging frame at render time.
|
|
3440
|
+
*
|
|
3441
|
+
* Today: just `propagateSelectRootContext`. Extend here for Tabs,
|
|
3442
|
+
* RadioGroup, Accordion, etc.
|
|
3443
|
+
*/
|
|
3444
|
+
private transformJsxTree(tree: JsxNode | null | undefined): JsxNode | null | undefined {
|
|
3445
|
+
if (!tree || tree.type !== 'element') return tree;
|
|
3446
|
+
let next = this.propagateSelectRootContext(tree);
|
|
3447
|
+
if (next.type === 'element') {
|
|
3448
|
+
next = this.propagateDialogRootContext(next);
|
|
3449
|
+
}
|
|
3450
|
+
return next;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
/**
|
|
3454
|
+
* Apply tree-level transforms to every JSX tree carried by an
|
|
3455
|
+
* analysis: the component's own `jsxTree` and each story's
|
|
3456
|
+
* `jsxTree`. Called once per analysis right before it lands in the
|
|
3457
|
+
* scanned components list, so the persisted JSON already reflects
|
|
3458
|
+
* the transformed trees and downstream consumers (ui-builder,
|
|
3459
|
+
* symbol policy, story-builder) see a single canonical shape.
|
|
3460
|
+
*/
|
|
3461
|
+
private applyTreeTransforms(analysis: ComponentAnalysis): void {
|
|
3462
|
+
if ('jsxTree' in analysis && analysis.jsxTree) {
|
|
3463
|
+
const transformed = this.transformJsxTree(analysis.jsxTree);
|
|
3464
|
+
if (transformed && transformed.type === 'element') {
|
|
3465
|
+
analysis.jsxTree = transformed;
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
const stories = ('stories' in analysis && Array.isArray(analysis.stories))
|
|
3469
|
+
? analysis.stories
|
|
3470
|
+
: null;
|
|
3471
|
+
if (stories) {
|
|
3472
|
+
for (const story of stories) {
|
|
3473
|
+
if (story && story.jsxTree) {
|
|
3474
|
+
const transformed = this.transformJsxTree(story.jsxTree);
|
|
3475
|
+
if (transformed && transformed.type === 'element') {
|
|
3476
|
+
story.jsxTree = transformed;
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/**
|
|
3484
|
+
* Hoist children of transparent `<Select>` / `<SelectPrimitive.Root>`
|
|
3485
|
+
* wrappers up to their parent, annotating each hoisted child with
|
|
3486
|
+
* `__selectRoot*` data props that carry the Select's context
|
|
3487
|
+
* (`value`, `open`, the resolved selected label, the highlighted item).
|
|
3488
|
+
*
|
|
3489
|
+
* The render-time `isSelectRootTag` handler in `ui-builder.ts` was the
|
|
3490
|
+
* only place that set `selectIsOpen` / `selectSelectedLabel` etc. on
|
|
3491
|
+
* the render context. With the wrapper preserved, Figma would render
|
|
3492
|
+
* an empty hugging frame around `<SelectTrigger>` and the trigger's
|
|
3493
|
+
* `w-full` would only fill the wrapper — making the dropdown look
|
|
3494
|
+
* narrower than its sibling inputs in a form grid.
|
|
3495
|
+
*
|
|
3496
|
+
* Flattening here mirrors how CSS `display: contents` makes the
|
|
3497
|
+
* wrapper layout-transparent in the browser. Downstream renderers see
|
|
3498
|
+
* `<SelectTrigger>` and `<SelectContent>` as direct siblings of the
|
|
3499
|
+
* Label / form fields, so existing full-width / stretch logic applies
|
|
3500
|
+
* without special-casing the Radix wrapper.
|
|
3501
|
+
*/
|
|
3502
|
+
private propagateSelectRootContext(node: JsxNode): JsxNode {
|
|
3503
|
+
if (node.type !== 'element') return node;
|
|
3504
|
+
const el = node;
|
|
3505
|
+
const newChildren: JsxNode[] = [];
|
|
3506
|
+
for (const child of el.children || []) {
|
|
3507
|
+
const transformed = this.propagateSelectRootContext(child);
|
|
3508
|
+
if (transformed.type === 'element' && this.isTransparentSelectRoot(transformed)) {
|
|
3509
|
+
for (const hoisted of this.hoistSelectRootChildren(transformed)) {
|
|
3510
|
+
newChildren.push(hoisted);
|
|
3511
|
+
}
|
|
3512
|
+
} else {
|
|
3513
|
+
newChildren.push(transformed);
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
return Object.assign({}, el, { children: newChildren });
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
/**
|
|
3520
|
+
* Annotate every `DialogContent` descendant with an
|
|
3521
|
+
* `__dialogRootIsOpen` data prop derived from the nearest enclosing
|
|
3522
|
+
* `<Dialog>` / `<DialogPrimitive.Root>`'s `open` / `defaultOpen`
|
|
3523
|
+
* props. The render-time gate in `ui-builder.ts` uses this signal
|
|
3524
|
+
* to suppress closed nested dialogs (the info-button Dialog inside
|
|
3525
|
+
* `DialogHeader`, the secondary-confirmation Dialog, etc.).
|
|
3526
|
+
*
|
|
3527
|
+
* Without this, every nested DialogContent rendered inline next to
|
|
3528
|
+
* its parent's content — visible as an "Open position / Overview"
|
|
3529
|
+
* sliver floating top-right of `IncreasePositionModal` in Figma,
|
|
3530
|
+
* and as the vertical-letter description text on mobile.
|
|
3531
|
+
*
|
|
3532
|
+
* Why scanner-level instead of render-context inheritance: the
|
|
3533
|
+
* outer DialogContent is portal-extracted at scan time (loses its
|
|
3534
|
+
* direct child-of-Root relationship). By the time ui-builder
|
|
3535
|
+
* processes a DialogContent the parent Dialog Root is no longer
|
|
3536
|
+
* on the recursion ancestor chain — annotating at scan time, when
|
|
3537
|
+
* we still see the wrapping Root, is the universal fix.
|
|
3538
|
+
*/
|
|
3539
|
+
private propagateDialogRootContext(node: JsxNode): JsxNode {
|
|
3540
|
+
const annotate = (n: JsxNode, isOpen: boolean | null): JsxNode => {
|
|
3541
|
+
if (n.type !== 'element') return n;
|
|
3542
|
+
const el = n;
|
|
3543
|
+
const tag = el.tagName;
|
|
3544
|
+
// Entering a new Dialog Root — recompute isOpen from its props.
|
|
3545
|
+
const isDialogRoot = tag === 'Dialog' || tag === 'DialogPrimitive.Root';
|
|
3546
|
+
const nextIsOpen = isDialogRoot
|
|
3547
|
+
? (this.isScannerTruthyStateProp(el.props && el.props.open)
|
|
3548
|
+
|| this.isScannerTruthyStateProp(el.props && el.props.defaultOpen))
|
|
3549
|
+
: isOpen;
|
|
3550
|
+
const isDialogContent = tag === 'DialogContent' || tag === 'DialogPrimitive.Content';
|
|
3551
|
+
let nextProps = el.props;
|
|
3552
|
+
if (isDialogContent && nextIsOpen != null) {
|
|
3553
|
+
nextProps = Object.assign({}, el.props || {}, {
|
|
3554
|
+
__dialogRootIsOpen: nextIsOpen ? 'true' : 'false',
|
|
3555
|
+
});
|
|
3556
|
+
}
|
|
3557
|
+
const newChildren: JsxNode[] = [];
|
|
3558
|
+
for (const c of el.children || []) {
|
|
3559
|
+
newChildren.push(annotate(c, nextIsOpen));
|
|
3560
|
+
}
|
|
3561
|
+
return Object.assign({}, el, { props: nextProps, children: newChildren });
|
|
3562
|
+
};
|
|
3563
|
+
return annotate(node, null);
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
private isTransparentSelectRoot(node: JsxElement): boolean {
|
|
3567
|
+
const tag = node.tagName;
|
|
3568
|
+
if (tag !== 'Select' && tag !== 'SelectPrimitive.Root') return false;
|
|
3569
|
+
const className = node.props && node.props.className;
|
|
3570
|
+
if (typeof className === 'string' && className.trim().length > 0) return false;
|
|
3571
|
+
return Array.isArray(node.children) && node.children.length > 0;
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
private hoistSelectRootChildren(rootEl: JsxElement): JsxNode[] {
|
|
3575
|
+
const props = rootEl.props || {};
|
|
3576
|
+
const selectedValue = (props.value != null && String(props.value).length > 0)
|
|
3577
|
+
? String(props.value)
|
|
3578
|
+
: (props.defaultValue != null && String(props.defaultValue).length > 0)
|
|
3579
|
+
? String(props.defaultValue)
|
|
3580
|
+
: null;
|
|
3581
|
+
const isOpen = this.isScannerTruthyStateProp(props.open)
|
|
3582
|
+
|| this.isScannerTruthyStateProp(props.defaultOpen);
|
|
3583
|
+
const selectedLabel = selectedValue != null
|
|
3584
|
+
? this.findSelectItemLabel(rootEl, selectedValue)
|
|
3585
|
+
: null;
|
|
3586
|
+
const highlightedValue = (!selectedValue && isOpen)
|
|
3587
|
+
? this.findFirstSelectItemValue(rootEl)
|
|
3588
|
+
: null;
|
|
3589
|
+
// Annotations are strings because `JsxElement.props` is
|
|
3590
|
+
// `Record<string, string>`. Render-time consumers re-parse via
|
|
3591
|
+
// `isTruthyStateProp` / `normalizeSelectableValue`.
|
|
3592
|
+
const annotations: Record<string, string> = {};
|
|
3593
|
+
if (selectedValue != null) annotations.__selectRootSelectedValue = selectedValue;
|
|
3594
|
+
if (selectedLabel != null) annotations.__selectRootSelectedLabel = selectedLabel;
|
|
3595
|
+
if (isOpen) annotations.__selectRootIsOpen = 'true';
|
|
3596
|
+
if (highlightedValue != null) annotations.__selectRootHighlightedValue = highlightedValue;
|
|
3597
|
+
|
|
3598
|
+
const out: JsxNode[] = [];
|
|
3599
|
+
for (const child of rootEl.children) {
|
|
3600
|
+
if (child.type !== 'element') {
|
|
3601
|
+
out.push(child);
|
|
3602
|
+
continue;
|
|
3603
|
+
}
|
|
3604
|
+
const childEl = child as JsxElement;
|
|
3605
|
+
const mergedProps = Object.assign({}, childEl.props || {}, annotations);
|
|
3606
|
+
out.push(Object.assign({}, childEl, { props: mergedProps }));
|
|
3607
|
+
}
|
|
3608
|
+
return out;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
private isScannerTruthyStateProp(value: string | undefined | null): boolean {
|
|
3612
|
+
if (value == null) return false;
|
|
3613
|
+
const s = String(value).trim().toLowerCase();
|
|
3614
|
+
return s === 'true' || s === '"true"' || s === "'true'" || s === '1';
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
/**
|
|
3618
|
+
* Walk a Select Root's subtree to find the first `SelectItem` whose
|
|
3619
|
+
* `value` prop matches the requested value, and return the first text
|
|
3620
|
+
* descendant (the label). Mirrors `findSelectedSelectLabel` in
|
|
3621
|
+
* `src/design-system/render-context.ts` but operates on the static
|
|
3622
|
+
* scanner JSX tree at scan time, so the label is baked into the
|
|
3623
|
+
* `__selectRootSelectedLabel` annotation.
|
|
3624
|
+
*
|
|
3625
|
+
* The label is rarely a direct text child of `SelectItem`: shadcn's
|
|
3626
|
+
* `<SelectItem>` inlines into `<SelectPrimitive.Item>` containing a
|
|
3627
|
+
* decorative indicator span AND a `<SelectPrimitive.ItemText>` wrapper
|
|
3628
|
+
* around the actual label text. Walk the whole subtree of the matched
|
|
3629
|
+
* item rather than peeking only at direct children — anything else
|
|
3630
|
+
* silently drops the annotation for shadcn-shaped Selects and the
|
|
3631
|
+
* Trigger renders the raw value (e.g. a mint address) instead of the
|
|
3632
|
+
* symbol.
|
|
3633
|
+
*/
|
|
3634
|
+
private findSelectItemLabel(rootEl: JsxElement, value: string): string | null {
|
|
3635
|
+
const findFirstText = (node: JsxNode): string | null => {
|
|
3636
|
+
if (node.type === 'text') {
|
|
3637
|
+
const text = (node as { content?: string }).content || '';
|
|
3638
|
+
const trimmed = text.trim();
|
|
3639
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
3640
|
+
}
|
|
3641
|
+
if (node.type !== 'element') return null;
|
|
3642
|
+
for (const c of (node as JsxElement).children || []) {
|
|
3643
|
+
const t = findFirstText(c);
|
|
3644
|
+
if (t != null) return t;
|
|
3645
|
+
}
|
|
3646
|
+
return null;
|
|
3647
|
+
};
|
|
3648
|
+
const walk = (node: JsxNode): string | null => {
|
|
3649
|
+
if (node.type !== 'element') return null;
|
|
3650
|
+
const el = node as JsxElement;
|
|
3651
|
+
const tag = el.tagName;
|
|
3652
|
+
if (tag === 'SelectItem' || tag === 'SelectPrimitive.Item') {
|
|
3653
|
+
const itemValue = el.props && el.props.value;
|
|
3654
|
+
if (itemValue != null && String(itemValue) === value) {
|
|
3655
|
+
const labelText = findFirstText(el);
|
|
3656
|
+
if (labelText != null) return labelText;
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
for (const c of el.children || []) {
|
|
3660
|
+
const found = walk(c);
|
|
3661
|
+
if (found != null) return found;
|
|
3662
|
+
}
|
|
3663
|
+
return null;
|
|
3664
|
+
};
|
|
3665
|
+
return walk(rootEl);
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
private findFirstSelectItemValue(rootEl: JsxElement): string | null {
|
|
3669
|
+
const walk = (node: JsxNode): string | null => {
|
|
3670
|
+
if (node.type !== 'element') return null;
|
|
3671
|
+
const el = node as JsxElement;
|
|
3672
|
+
const tag = el.tagName;
|
|
3673
|
+
if (tag === 'SelectItem' || tag === 'SelectPrimitive.Item') {
|
|
3674
|
+
const v = el.props && el.props.value;
|
|
3675
|
+
if (v != null && String(v).length > 0) return String(v);
|
|
3676
|
+
}
|
|
3677
|
+
for (const c of el.children || []) {
|
|
3678
|
+
const found = walk(c);
|
|
3679
|
+
if (found != null) return found;
|
|
3680
|
+
}
|
|
3681
|
+
return null;
|
|
3682
|
+
};
|
|
3683
|
+
return walk(rootEl);
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3215
3686
|
/**
|
|
3216
3687
|
* Returns true if the expanded node's root is a portal wrapper (renders outside the DOM tree).
|
|
3217
3688
|
* Used to unwrap components like DialogContent, SelectContent, SheetContent, DropdownMenuContent.
|
|
@@ -3238,8 +3709,14 @@ export class ComponentScanner {
|
|
|
3238
3709
|
/**
|
|
3239
3710
|
* Strips portal-specific CSS classes that are meaningless or harmful in Figma:
|
|
3240
3711
|
* fixed/absolute positioning, transforms, z-index, state-variant animations,
|
|
3241
|
-
* transition durations, complex calc() max-widths
|
|
3242
|
-
*
|
|
3712
|
+
* transition durations, and complex calc() max-widths (can't be evaluated
|
|
3713
|
+
* to a pixel value).
|
|
3714
|
+
*
|
|
3715
|
+
* Responsive `max-w-*` variants (e.g. shadcn DialogContent's `sm:max-w-lg`)
|
|
3716
|
+
* are KEPT — they're the only signal that tells the renderer the dialog
|
|
3717
|
+
* intends to render at 512px, not the 900px Story Layout fallback. A
|
|
3718
|
+
* consumer-supplied unprefixed `max-w-*` still wins via `extractMaxWidth`
|
|
3719
|
+
* Pass 1.
|
|
3243
3720
|
*
|
|
3244
3721
|
* Called on the extracted portal content node so the renderer sees only the
|
|
3245
3722
|
* visual layout classes (grid, gap, padding, border, shadow, bg, etc.).
|
|
@@ -3275,8 +3752,6 @@ export class ComponentScanner {
|
|
|
3275
3752
|
if (/^ease-/.test(cls)) return false;
|
|
3276
3753
|
// Complex calc/min/max max-w (can't be evaluated to a pixel value)
|
|
3277
3754
|
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
3755
|
return true;
|
|
3281
3756
|
})
|
|
3282
3757
|
.join(' ');
|
|
@@ -3321,6 +3796,73 @@ export class ComponentScanner {
|
|
|
3321
3796
|
}
|
|
3322
3797
|
return;
|
|
3323
3798
|
}
|
|
3799
|
+
// Fragment branch `<>…</>` — flatten its children inline. React fragments
|
|
3800
|
+
// are layout-transparent (they have no DOM); the children participate
|
|
3801
|
+
// directly in the surrounding parent's layout. Canonical real-world
|
|
3802
|
+
// shape:
|
|
3803
|
+
// {isLong ? (<><svg/>Long / Buy</>) : (<><svg/>Short / Sell</>)}
|
|
3804
|
+
// — the submit Button's body. Without flattening, the chosen Fragment
|
|
3805
|
+
// fell through to `resolveExpressionValue` and the whole branch was
|
|
3806
|
+
// dropped, so the Button rendered empty (no icon, no label).
|
|
3807
|
+
if (Node.isJsxFragment(chosen)) {
|
|
3808
|
+
// `.getChildren()` on a JsxFragment returns syntax tokens (the
|
|
3809
|
+
// opening / closing fragment tags + a SyntaxList). `.getJsxChildren()`
|
|
3810
|
+
// is the one that returns the actual JSX children (elements, text,
|
|
3811
|
+
// expressions) — that's what we need to flatten into the parent.
|
|
3812
|
+
for (const fragChild of chosen.getJsxChildren()) {
|
|
3813
|
+
if (Node.isJsxElement(fragChild) || Node.isJsxSelfClosingElement(fragChild) || Node.isJsxFragment(fragChild)) {
|
|
3814
|
+
this.pushChosenJsxBranch(fragChild, children, localComponents, relativeImports, propsContext);
|
|
3815
|
+
} else if (Node.isJsxText(fragChild)) {
|
|
3816
|
+
const text = decodeHtmlEntities(normalizeJsxText(fragChild.getFullText()));
|
|
3817
|
+
if (text) children.push({ type: 'text', content: text });
|
|
3818
|
+
} else if (Node.isJsxExpression(fragChild)) {
|
|
3819
|
+
const inner = fragChild.getExpression();
|
|
3820
|
+
if (inner) this.pushChosenJsxBranch(inner, children, localComponents, relativeImports, propsContext);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
3825
|
+
// Nested ternary `a ? x : (b ? y : z)` — the outer branch is itself a
|
|
3826
|
+
// ConditionalExpression that the .map-context handler would unpack
|
|
3827
|
+
// recursively. Mirror that here so multi-level conditionals don't get
|
|
3828
|
+
// silently dropped. Canonical real-world shape (IncreasePositionModal
|
|
3829
|
+
// Position impact card):
|
|
3830
|
+
// {previewLoading ? <skel/> : previewMetrics ? <metrics/> : <empty/>}
|
|
3831
|
+
// — the outer-false branch is `previewMetrics ? metrics : empty`,
|
|
3832
|
+
// another ConditionalExpression. Without recursion it landed in
|
|
3833
|
+
// `resolveExpressionValue` which returned `undefined` for JSX
|
|
3834
|
+
// expressions, and the entire card body disappeared.
|
|
3835
|
+
if (Node.isConditionalExpression(chosen)) {
|
|
3836
|
+
const condValue = this.resolveExpressionValue(chosen.getCondition(), propsContext);
|
|
3837
|
+
if (condValue !== undefined) {
|
|
3838
|
+
const nested = this.isExpressionTruthy(condValue)
|
|
3839
|
+
? chosen.getWhenTrue()
|
|
3840
|
+
: chosen.getWhenFalse();
|
|
3841
|
+
this.pushChosenJsxBranch(nested, children, localComponents, relativeImports, propsContext);
|
|
3842
|
+
}
|
|
3843
|
+
return;
|
|
3844
|
+
}
|
|
3845
|
+
// CallExpression `arr.map(...)` inside a ternary branch must expand the
|
|
3846
|
+
// same way it would as a direct JSX child. Without this case the
|
|
3847
|
+
// walker drops it silently because `resolveExpressionValue` doesn't
|
|
3848
|
+
// know how to iterate `.map`. Canonical repro: a table body shaped
|
|
3849
|
+
// {rows.length ? rows.map(row => <Row/>) : <EmptyRow/>}
|
|
3850
|
+
// — the truthy branch is the CallExpression, gets routed here, and
|
|
3851
|
+
// disappeared.
|
|
3852
|
+
if (Node.isCallExpression(chosen)) {
|
|
3853
|
+
const callee = chosen.getExpression();
|
|
3854
|
+
if (Node.isPropertyAccessExpression(callee) && callee.getName() === 'map') {
|
|
3855
|
+
const expanded = this.expandMapCall(
|
|
3856
|
+
chosen,
|
|
3857
|
+
chosen.getSourceFile(),
|
|
3858
|
+
localComponents,
|
|
3859
|
+
relativeImports,
|
|
3860
|
+
propsContext,
|
|
3861
|
+
);
|
|
3862
|
+
for (const c of expanded) children.push(c);
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3324
3866
|
const resolved = this.resolveExpressionValue(chosen, propsContext);
|
|
3325
3867
|
if (resolved !== undefined && resolved !== null) {
|
|
3326
3868
|
this.pushResolvedPropValue(children, resolved);
|
|
@@ -3861,7 +4403,33 @@ export class ComponentScanner {
|
|
|
3861
4403
|
continue;
|
|
3862
4404
|
}
|
|
3863
4405
|
if (!Node.isPropertyAssignment(prop)) continue;
|
|
3864
|
-
|
|
4406
|
+
// String-literal keys (`{ "So111...": value }`) — `prop.getName()`
|
|
4407
|
+
// returns the raw text INCLUDING the surrounding quotes, which would
|
|
4408
|
+
// store the entry under `"\"So111...\""` and break any
|
|
4409
|
+
// bracket-lookup with the unquoted string. Unwrap the literal so
|
|
4410
|
+
// the stored key matches what the consumer's `obj[mintString]`
|
|
4411
|
+
// access actually looks up.
|
|
4412
|
+
const nameNode = prop.getNameNode();
|
|
4413
|
+
let key: string | undefined;
|
|
4414
|
+
if (nameNode && (Node.isStringLiteral(nameNode) || Node.isNoSubstitutionTemplateLiteral(nameNode))) {
|
|
4415
|
+
key = nameNode.getLiteralValue();
|
|
4416
|
+
} else if (nameNode && Node.isNumericLiteral(nameNode)) {
|
|
4417
|
+
key = String(nameNode.getLiteralValue());
|
|
4418
|
+
} else if (nameNode && Node.isComputedPropertyName(nameNode)) {
|
|
4419
|
+
// Computed property names (`{ [USDC_MINT]: ... }`) — evaluate
|
|
4420
|
+
// the inner expression and use the resolved value as the key.
|
|
4421
|
+
// Common in token tables that key off an exported constant
|
|
4422
|
+
// (greenhouse-app's TOKEN_METADATA uses `[USDC_MINT]` so the
|
|
4423
|
+
// map is keyed by the actual mint string, not the identifier
|
|
4424
|
+
// text "USDC_MINT").
|
|
4425
|
+
const inner = nameNode.getExpression();
|
|
4426
|
+
const resolvedKey = this.resolveExpressionValue(inner, propsContext);
|
|
4427
|
+
if (resolvedKey != null) {
|
|
4428
|
+
key = String(resolvedKey);
|
|
4429
|
+
}
|
|
4430
|
+
} else {
|
|
4431
|
+
key = prop.getName();
|
|
4432
|
+
}
|
|
3865
4433
|
if (!key) continue;
|
|
3866
4434
|
const val = prop.getInitializer();
|
|
3867
4435
|
if (!val) continue;
|
|
@@ -3884,6 +4452,14 @@ export class ComponentScanner {
|
|
|
3884
4452
|
const text = val.getText();
|
|
3885
4453
|
if (text === 'true') return true;
|
|
3886
4454
|
if (text === 'false') return false;
|
|
4455
|
+
// `null` / `undefined` keywords resolve to their JS values, not the
|
|
4456
|
+
// strings "null" / "undefined". Previously they fell through to the
|
|
4457
|
+
// `return text` branch and (since "null" is a non-empty truthy string)
|
|
4458
|
+
// caused conditionals like `{error && <Banner>{error}</Banner>}` to
|
|
4459
|
+
// render the banner with literal "null" text when stories passed
|
|
4460
|
+
// `error: null`.
|
|
4461
|
+
if (text === 'null') return null;
|
|
4462
|
+
if (text === 'undefined') return undefined;
|
|
3887
4463
|
if (Node.isArrayLiteralExpression(val)) {
|
|
3888
4464
|
return val.getElements().map((el) => this.parseLiteralValue(el));
|
|
3889
4465
|
}
|
|
@@ -3893,7 +4469,7 @@ export class ComponentScanner {
|
|
|
3893
4469
|
return text;
|
|
3894
4470
|
}
|
|
3895
4471
|
|
|
3896
|
-
private coerceBoolean(value: unknown): boolean
|
|
4472
|
+
private coerceBoolean(value: unknown): boolean {
|
|
3897
4473
|
if (typeof value === 'boolean') return value;
|
|
3898
4474
|
if (typeof value === 'number') return value !== 0;
|
|
3899
4475
|
if (typeof value === 'string') {
|
|
@@ -3905,7 +4481,15 @@ export class ComponentScanner {
|
|
|
3905
4481
|
if (normalized.length > 0) return true;
|
|
3906
4482
|
return false;
|
|
3907
4483
|
}
|
|
3908
|
-
|
|
4484
|
+
// null / undefined match JS truthiness — falsy. Treating them as
|
|
4485
|
+
// "can't determine" (previous behaviour, returning null) made
|
|
4486
|
+
// optional-prop guards like `activeGroups && X` and
|
|
4487
|
+
// `activeGroups?.length ? a : b` evaluate to undefined and the
|
|
4488
|
+
// entire conditional branch dropped from the JSX tree — instead of
|
|
4489
|
+
// picking the "else" branch the way runtime React would. Match JS.
|
|
4490
|
+
if (value === null || value === undefined) return false;
|
|
4491
|
+
// Objects, arrays, functions, etc. are truthy in JS.
|
|
4492
|
+
return true;
|
|
3909
4493
|
}
|
|
3910
4494
|
|
|
3911
4495
|
/**
|
|
@@ -3965,15 +4549,36 @@ export class ComponentScanner {
|
|
|
3965
4549
|
if (Node.isParenthesizedExpression(expr)) {
|
|
3966
4550
|
return this.resolveExpressionValue(expr.getExpression(), propsContext);
|
|
3967
4551
|
}
|
|
4552
|
+
// TypeScript type-only constructs that don't affect runtime value —
|
|
4553
|
+
// unwrap to the inner expression. Without this, common consumer
|
|
4554
|
+
// idioms like `(position as any).field` and `position!.field` cause
|
|
4555
|
+
// the entire chain to bail (the cast wrapper isn't one of the
|
|
4556
|
+
// recognised expression shapes).
|
|
4557
|
+
if (Node.isAsExpression(expr) || Node.isSatisfiesExpression(expr) || Node.isNonNullExpression(expr) || Node.isTypeAssertion(expr)) {
|
|
4558
|
+
return this.resolveExpressionValue(expr.getExpression(), propsContext);
|
|
4559
|
+
}
|
|
3968
4560
|
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
|
|
3969
4561
|
return expr.getLiteralValue();
|
|
3970
4562
|
}
|
|
3971
4563
|
if (Node.isNumericLiteral(expr)) {
|
|
3972
4564
|
return expr.getLiteralValue();
|
|
3973
4565
|
}
|
|
4566
|
+
if (Node.isArrayLiteralExpression(expr)) {
|
|
4567
|
+
return this.parseArrayLiteral(expr);
|
|
4568
|
+
}
|
|
4569
|
+
if (Node.isObjectLiteralExpression(expr)) {
|
|
4570
|
+
// Inline object literals appearing as call/new arguments (e.g. the
|
|
4571
|
+
// options bag in `new Intl.NumberFormat("en-US", { style: "currency",
|
|
4572
|
+
// currency: "USD" })`). Without this case the evaluator returned
|
|
4573
|
+
// undefined for the options arg, so the Intl instance fell back to
|
|
4574
|
+
// its default formatting (no currency, no fraction digits).
|
|
4575
|
+
return this.parseObjectLiteralExpression(expr, propsContext);
|
|
4576
|
+
}
|
|
3974
4577
|
const exprText = expr.getText();
|
|
3975
4578
|
if (exprText === 'true') return true;
|
|
3976
4579
|
if (exprText === 'false') return false;
|
|
4580
|
+
if (exprText === 'null') return null;
|
|
4581
|
+
if (exprText === 'undefined') return undefined;
|
|
3977
4582
|
if (Node.isIdentifier(expr)) {
|
|
3978
4583
|
if (propsContext.has(exprText)) return propsContext.get(exprText);
|
|
3979
4584
|
// Resolve function-body-local consts FIRST so they shadow module-level
|
|
@@ -4002,7 +4607,51 @@ export class ComponentScanner {
|
|
|
4002
4607
|
if (Node.isObjectLiteralExpression(unwrapped)) {
|
|
4003
4608
|
return this.parseObjectLiteralExpression(unwrapped, propsContext);
|
|
4004
4609
|
}
|
|
4610
|
+
// Module-level pure-constructor instances (e.g.
|
|
4611
|
+
// `const usdFormatter = new Intl.NumberFormat(...)`). Evaluating
|
|
4612
|
+
// the NewExpression returns the live JS object so subsequent
|
|
4613
|
+
// `usdFormatter.format(N)` calls dispatch against its prototype.
|
|
4614
|
+
if (Node.isNewExpression(unwrapped)) {
|
|
4615
|
+
return this.evaluateNewExpression(unwrapped, propsContext);
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
// Cross-module identifier: follow a named import to the source file
|
|
4620
|
+
// and try the same module-level resolution there. Enables
|
|
4621
|
+
// `import { usdFormatter } from './utils'` patterns to find the
|
|
4622
|
+
// remote Intl.NumberFormat instance.
|
|
4623
|
+
for (const importDecl of expr.getSourceFile().getImportDeclarations()) {
|
|
4624
|
+
const match = importDecl.getNamedImports().find(
|
|
4625
|
+
ni => (ni.getAliasNode()?.getText() ?? ni.getName()) === exprText
|
|
4626
|
+
);
|
|
4627
|
+
if (!match) continue;
|
|
4628
|
+
const sourceModule = importDecl.getModuleSpecifierSourceFile();
|
|
4629
|
+
if (!sourceModule) break;
|
|
4630
|
+
const importedName = match.getName();
|
|
4631
|
+
const importedVar = sourceModule.getVariableDeclaration(importedName);
|
|
4632
|
+
if (!importedVar) break;
|
|
4633
|
+
const init = importedVar.getInitializer();
|
|
4634
|
+
if (!init) break;
|
|
4635
|
+
const unwrapped = this.unwrapStaticValueExpression(init) || init;
|
|
4636
|
+
if (Node.isStringLiteral(unwrapped) || Node.isNoSubstitutionTemplateLiteral(unwrapped)) {
|
|
4637
|
+
return unwrapped.getLiteralValue();
|
|
4638
|
+
}
|
|
4639
|
+
if (Node.isNumericLiteral(unwrapped)) return unwrapped.getLiteralValue();
|
|
4640
|
+
if (Node.isNewExpression(unwrapped)) {
|
|
4641
|
+
return this.evaluateNewExpression(unwrapped, propsContext);
|
|
4005
4642
|
}
|
|
4643
|
+
// Imported object / array literals — TOKEN_METADATA / DEFAULT_ACTIVE
|
|
4644
|
+
// / constant tables that consumers commonly import for lookups
|
|
4645
|
+
// (`TOKEN_METADATA[mint]?.symbol`). Without this, the element-access
|
|
4646
|
+
// returns undefined and falls through to fallbacks (e.g. `mint.slice(
|
|
4647
|
+
// 0, 4)` → "So11" instead of "SOL").
|
|
4648
|
+
if (Node.isArrayLiteralExpression(unwrapped)) {
|
|
4649
|
+
return this.parseArrayLiteral(unwrapped);
|
|
4650
|
+
}
|
|
4651
|
+
if (Node.isObjectLiteralExpression(unwrapped)) {
|
|
4652
|
+
return this.parseObjectLiteralExpression(unwrapped, propsContext);
|
|
4653
|
+
}
|
|
4654
|
+
break;
|
|
4006
4655
|
}
|
|
4007
4656
|
return undefined;
|
|
4008
4657
|
}
|
|
@@ -4048,17 +4697,31 @@ export class ComponentScanner {
|
|
|
4048
4697
|
}
|
|
4049
4698
|
if (Node.isBinaryExpression(expr)) {
|
|
4050
4699
|
const op = expr.getOperatorToken().getText();
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4700
|
+
// Equality with `undefined` / `null` literals must compare even when
|
|
4701
|
+
// the resolved operand IS undefined. Otherwise common nullish guards
|
|
4702
|
+
// (`if (value === undefined) return null;`) always evaluate to
|
|
4703
|
+
// undefined and the interpreter incorrectly falls through, which
|
|
4704
|
+
// breaks every formatter that does an early-return on missing input.
|
|
4705
|
+
if (op === '===' || op === '==' || op === '!==' || op === '!=') {
|
|
4706
|
+
const leftExpr = expr.getLeft();
|
|
4707
|
+
const rightExpr = expr.getRight();
|
|
4708
|
+
const leftText = leftExpr.getText();
|
|
4709
|
+
const rightText = rightExpr.getText();
|
|
4710
|
+
const leftLitUndef = leftText === 'undefined';
|
|
4711
|
+
const leftLitNull = leftText === 'null';
|
|
4712
|
+
const rightLitUndef = rightText === 'undefined';
|
|
4713
|
+
const rightLitNull = rightText === 'null';
|
|
4714
|
+
const left = this.resolveExpressionValue(leftExpr, propsContext);
|
|
4715
|
+
const right = this.resolveExpressionValue(rightExpr, propsContext);
|
|
4716
|
+
const strict = op === '===' || op === '!==';
|
|
4717
|
+
const negate = op === '!==' || op === '!=';
|
|
4718
|
+
const apply = (eq: boolean) => negate ? !eq : eq;
|
|
4719
|
+
if (rightLitUndef) return apply(strict ? left === undefined : left == null);
|
|
4720
|
+
if (leftLitUndef) return apply(strict ? right === undefined : right == null);
|
|
4721
|
+
if (rightLitNull) return apply(strict ? left === null : left == null);
|
|
4722
|
+
if (leftLitNull) return apply(strict ? right === null : right == null);
|
|
4054
4723
|
if (left === undefined || right === undefined) return undefined;
|
|
4055
|
-
return left === right;
|
|
4056
|
-
}
|
|
4057
|
-
if (op === '!==' || op === '!=') {
|
|
4058
|
-
const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
4059
|
-
const right = this.resolveExpressionValue(expr.getRight(), propsContext);
|
|
4060
|
-
if (left === undefined || right === undefined) return undefined;
|
|
4061
|
-
return left !== right;
|
|
4724
|
+
return apply(strict ? left === right : left == right);
|
|
4062
4725
|
}
|
|
4063
4726
|
if (op === '&&') {
|
|
4064
4727
|
const left = this.coerceBoolean(this.resolveExpressionValue(expr.getLeft(), propsContext));
|
|
@@ -4085,15 +4748,25 @@ export class ComponentScanner {
|
|
|
4085
4748
|
if (typeof left === 'number' && typeof right === 'number') return left + right;
|
|
4086
4749
|
return String(left) + String(right);
|
|
4087
4750
|
}
|
|
4088
|
-
if (op === '-' || op === '*' || op === '/' || op === '%') {
|
|
4751
|
+
if (op === '-' || op === '*' || op === '/' || op === '%' || op === '**') {
|
|
4089
4752
|
const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
4090
4753
|
const right = this.resolveExpressionValue(expr.getRight(), propsContext);
|
|
4091
4754
|
if (typeof left !== 'number' || typeof right !== 'number') return undefined;
|
|
4092
4755
|
if (op === '-') return left - right;
|
|
4093
4756
|
if (op === '*') return left * right;
|
|
4757
|
+
if (op === '**') return left ** right;
|
|
4094
4758
|
if (op === '/') return right === 0 ? undefined : left / right;
|
|
4095
4759
|
return right === 0 ? undefined : left % right;
|
|
4096
4760
|
}
|
|
4761
|
+
if (op === '<' || op === '<=' || op === '>' || op === '>=') {
|
|
4762
|
+
const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
|
|
4763
|
+
const right = this.resolveExpressionValue(expr.getRight(), propsContext);
|
|
4764
|
+
if (typeof left !== 'number' || typeof right !== 'number') return undefined;
|
|
4765
|
+
if (op === '<') return left < right;
|
|
4766
|
+
if (op === '<=') return left <= right;
|
|
4767
|
+
if (op === '>') return left > right;
|
|
4768
|
+
return left >= right;
|
|
4769
|
+
}
|
|
4097
4770
|
}
|
|
4098
4771
|
// Method-call expressions on resolvable bases — covers the common
|
|
4099
4772
|
// story-args pattern `{rewardSol.toFixed(4)} SOL`. Without this the
|
|
@@ -4103,51 +4776,622 @@ export class ComponentScanner {
|
|
|
4103
4776
|
// String built-in methods that have an obvious string output — we
|
|
4104
4777
|
// are NOT a JS evaluator, just covering the high-traffic cases.
|
|
4105
4778
|
if (Node.isCallExpression(expr)) {
|
|
4779
|
+
const builtinResult = this.evaluateBuiltinCall(expr, propsContext);
|
|
4780
|
+
if (builtinResult !== undefined) return builtinResult;
|
|
4781
|
+
// User-function evaluator handles both Identifier callees
|
|
4782
|
+
// (`formatUsd(price)`) and IIFE callees (`(() => { ... })()`).
|
|
4783
|
+
// Returns undefined for shapes it can't dispatch on.
|
|
4784
|
+
const interpreted = this.evaluateUserFunctionCall(expr, propsContext);
|
|
4785
|
+
if (interpreted !== undefined) return interpreted;
|
|
4106
4786
|
const calleeExpr = expr.getExpression();
|
|
4107
|
-
if (Node.
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4787
|
+
if (Node.isIdentifier(calleeExpr)) {
|
|
4788
|
+
// Raw-arg fallback ONLY applies to identifier-named calls — when
|
|
4789
|
+
// the user-fn interpreter couldn't follow the function (unknown
|
|
4790
|
+
// helper, unsupported body construct, etc.), echo the first
|
|
4791
|
+
// argument so the cell renders the raw data instead of going
|
|
4792
|
+
// blank. Skip known side-effect helpers.
|
|
4793
|
+
const fnName = calleeExpr.getText();
|
|
4794
|
+
if (fnName === 'console' || fnName.startsWith('use')) return undefined;
|
|
4795
|
+
const firstArg = expr.getArguments()[0];
|
|
4796
|
+
if (firstArg) {
|
|
4797
|
+
const resolved = this.resolveExpressionValue(firstArg, propsContext);
|
|
4798
|
+
if (resolved !== undefined && resolved !== null) return resolved;
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
if (Node.isNewExpression(expr)) {
|
|
4803
|
+
return this.evaluateNewExpression(expr, propsContext);
|
|
4804
|
+
}
|
|
4805
|
+
if (Node.isPrefixUnaryExpression(expr)) {
|
|
4806
|
+
const op = expr.getOperatorToken();
|
|
4807
|
+
const operandValue = this.resolveExpressionValue(expr.getOperand(), propsContext);
|
|
4808
|
+
if (operandValue === undefined) return undefined;
|
|
4809
|
+
if (op === SyntaxKind.ExclamationToken) return !this.coerceBoolean(operandValue);
|
|
4810
|
+
if (op === SyntaxKind.MinusToken && typeof operandValue === 'number') return -operandValue;
|
|
4811
|
+
if (op === SyntaxKind.PlusToken && typeof operandValue === 'number') return +operandValue;
|
|
4812
|
+
if (op === SyntaxKind.TildeToken && typeof operandValue === 'number') return ~operandValue;
|
|
4813
|
+
return undefined;
|
|
4814
|
+
}
|
|
4815
|
+
if (Node.isTypeOfExpression(expr)) {
|
|
4816
|
+
const operandValue = this.resolveExpressionValue(expr.getExpression(), propsContext);
|
|
4817
|
+
if (operandValue === undefined) return undefined;
|
|
4818
|
+
if (operandValue === null) return 'object';
|
|
4819
|
+
return typeof operandValue;
|
|
4820
|
+
}
|
|
4821
|
+
if (Node.isTemplateExpression(expr)) {
|
|
4822
|
+
const parts: string[] = [expr.getHead().getLiteralText() || ''];
|
|
4823
|
+
for (const span of expr.getTemplateSpans()) {
|
|
4824
|
+
const resolved = this.resolveExpressionValue(span.getExpression(), propsContext);
|
|
4825
|
+
if (resolved === undefined) return undefined;
|
|
4826
|
+
parts.push(String(resolved));
|
|
4827
|
+
parts.push(span.getLiteral().getLiteralText() || '');
|
|
4828
|
+
}
|
|
4829
|
+
return parts.join('');
|
|
4830
|
+
}
|
|
4831
|
+
return undefined;
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4834
|
+
/**
|
|
4835
|
+
* Built-in JS evaluation for CallExpression — string / number / Date /
|
|
4836
|
+
* Intl.NumberFormat / Boolean methods, plus global conversion functions
|
|
4837
|
+
* (`Number`, `String`, `Boolean`, `parseInt`, `parseFloat`, `isNaN`,
|
|
4838
|
+
* `isFinite`) and `Number.*` / `Math.*` namespace functions.
|
|
4839
|
+
*
|
|
4840
|
+
* Returns `undefined` when the call shape isn't recognised — that's the
|
|
4841
|
+
* signal for the caller to keep trying (user-function evaluator, raw-arg
|
|
4842
|
+
* fallback).
|
|
4843
|
+
*
|
|
4844
|
+
* Restricted to pure, side-effect-free operations so we can run them at
|
|
4845
|
+
* scan time without surprising the consumer.
|
|
4846
|
+
*/
|
|
4847
|
+
private evaluateBuiltinCall(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): ResolvedExpressionValue {
|
|
4848
|
+
if (!Node.isCallExpression(expr)) return undefined;
|
|
4849
|
+
const calleeExpr = expr.getExpression();
|
|
4850
|
+
if (Node.isPropertyAccessExpression(calleeExpr)) {
|
|
4851
|
+
const methodName = calleeExpr.getName();
|
|
4852
|
+
const baseExpr = calleeExpr.getExpression();
|
|
4853
|
+
// `Number.isFinite(x)`, `Number.isInteger(x)`, `Number.isNaN(x)`,
|
|
4854
|
+
// `Math.abs(x)`, `Math.floor(x)`, etc. — namespace dispatch, not
|
|
4855
|
+
// instance method dispatch.
|
|
4856
|
+
if (Node.isIdentifier(baseExpr)) {
|
|
4857
|
+
const baseText = baseExpr.getText();
|
|
4111
4858
|
const argValues = expr.getArguments().map(a => this.resolveExpressionValue(a, propsContext));
|
|
4112
|
-
|
|
4113
|
-
const STRING_METHODS: Record<string, boolean> = { toString: true, toUpperCase: true, toLowerCase: true, trim: true };
|
|
4114
|
-
if (typeof baseValue === 'number' && NUMBER_METHODS[methodName]) {
|
|
4859
|
+
if (baseText === 'Number') {
|
|
4115
4860
|
const arg = argValues[0];
|
|
4861
|
+
if (methodName === 'isFinite') return typeof arg === 'number' && Number.isFinite(arg);
|
|
4862
|
+
if (methodName === 'isInteger') return typeof arg === 'number' && Number.isInteger(arg);
|
|
4863
|
+
if (methodName === 'isNaN') return typeof arg === 'number' && Number.isNaN(arg);
|
|
4864
|
+
if (methodName === 'isSafeInteger') return typeof arg === 'number' && Number.isSafeInteger(arg);
|
|
4865
|
+
}
|
|
4866
|
+
if (baseText === 'Math') {
|
|
4867
|
+
if (argValues.some(v => typeof v !== 'number')) return undefined;
|
|
4868
|
+
const nums = argValues as number[];
|
|
4116
4869
|
try {
|
|
4117
|
-
if (methodName === '
|
|
4118
|
-
if (methodName === '
|
|
4119
|
-
if (methodName === '
|
|
4870
|
+
if (methodName === 'abs') return Math.abs(nums[0]);
|
|
4871
|
+
if (methodName === 'floor') return Math.floor(nums[0]);
|
|
4872
|
+
if (methodName === 'ceil') return Math.ceil(nums[0]);
|
|
4873
|
+
if (methodName === 'round') return Math.round(nums[0]);
|
|
4874
|
+
if (methodName === 'trunc') return Math.trunc(nums[0]);
|
|
4875
|
+
if (methodName === 'sign') return Math.sign(nums[0]);
|
|
4876
|
+
if (methodName === 'sqrt') return Math.sqrt(nums[0]);
|
|
4877
|
+
if (methodName === 'pow') return Math.pow(nums[0], nums[1]);
|
|
4878
|
+
if (methodName === 'min') return Math.min(...nums);
|
|
4879
|
+
if (methodName === 'max') return Math.max(...nums);
|
|
4120
4880
|
} catch (_e) { return undefined; }
|
|
4121
4881
|
}
|
|
4122
|
-
|
|
4882
|
+
// `Object.entries(x)` / `Object.keys(x)` / `Object.values(x)` /
|
|
4883
|
+
// `Object.fromEntries(pairs)` — pure, predictable shape transforms.
|
|
4884
|
+
// Real-world trigger: shadcn Select dropdowns built via
|
|
4885
|
+
// `Object.entries(TOKEN_METADATA).map(([k, v]) => <SelectItem ...>)`
|
|
4886
|
+
// — without this the scanner can't expand the .map() into actual
|
|
4887
|
+
// SelectItem nodes, so `findSelectedSelectLabel` can't resolve the
|
|
4888
|
+
// selected value's label and the Figma render shows the raw value
|
|
4889
|
+
// (mint address) instead of the label ("USDC").
|
|
4890
|
+
if (baseText === 'Object') {
|
|
4891
|
+
const arg = argValues[0];
|
|
4892
|
+
try {
|
|
4893
|
+
if (methodName === 'keys') {
|
|
4894
|
+
if (arg && typeof arg === 'object') return Object.keys(arg as object);
|
|
4895
|
+
return undefined;
|
|
4896
|
+
}
|
|
4897
|
+
if (methodName === 'values') {
|
|
4898
|
+
if (arg && typeof arg === 'object') return Object.values(arg as object);
|
|
4899
|
+
return undefined;
|
|
4900
|
+
}
|
|
4901
|
+
if (methodName === 'entries') {
|
|
4902
|
+
if (arg && typeof arg === 'object') return Object.entries(arg as object);
|
|
4903
|
+
return undefined;
|
|
4904
|
+
}
|
|
4905
|
+
if (methodName === 'fromEntries') {
|
|
4906
|
+
if (Array.isArray(arg)) return Object.fromEntries(arg as Iterable<[PropertyKey, unknown]>);
|
|
4907
|
+
return undefined;
|
|
4908
|
+
}
|
|
4909
|
+
} catch (_e) { return undefined; }
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4912
|
+
const baseValue = this.resolveExpressionValue(baseExpr, propsContext);
|
|
4913
|
+
if (baseValue == null) return undefined;
|
|
4914
|
+
const argValues = expr.getArguments().map(a => this.resolveExpressionValue(a, propsContext));
|
|
4915
|
+
// Intl.NumberFormat / Intl.DateTimeFormat / Date / Set / Map instances.
|
|
4916
|
+
// The resolver returns the live JS object for `new Intl.NumberFormat(...)`
|
|
4917
|
+
// / `new Date(...)` / `new Set(...)` / `new Map(...)` expressions so we
|
|
4918
|
+
// can dispatch on the prototype here.
|
|
4919
|
+
if (typeof baseValue === 'object') {
|
|
4920
|
+
// `.size` is a getter, not a method — handled by the
|
|
4921
|
+
// PropertyAccessExpression branch above. Only the method calls
|
|
4922
|
+
// (`.has`, `.get`) live here.
|
|
4923
|
+
if (baseValue instanceof Set && methodName === 'has') {
|
|
4924
|
+
try { return baseValue.has(argValues[0]); } catch (_e) { return undefined; }
|
|
4925
|
+
}
|
|
4926
|
+
if (baseValue instanceof Map) {
|
|
4927
|
+
try {
|
|
4928
|
+
if (methodName === 'has') return baseValue.has(argValues[0]);
|
|
4929
|
+
if (methodName === 'get') return baseValue.get(argValues[0]);
|
|
4930
|
+
} catch (_e) { return undefined; }
|
|
4931
|
+
}
|
|
4932
|
+
if (baseValue instanceof Intl.NumberFormat && methodName === 'format' && typeof argValues[0] === 'number') {
|
|
4933
|
+
try { return baseValue.format(argValues[0]); } catch (_e) { return undefined; }
|
|
4934
|
+
}
|
|
4935
|
+
if (baseValue instanceof Intl.DateTimeFormat && methodName === 'format' && argValues[0] instanceof Date) {
|
|
4936
|
+
try { return baseValue.format(argValues[0]); } catch (_e) { return undefined; }
|
|
4937
|
+
}
|
|
4938
|
+
if (baseValue instanceof Date) {
|
|
4939
|
+
try {
|
|
4940
|
+
if (methodName === 'toISOString') return baseValue.toISOString();
|
|
4941
|
+
if (methodName === 'toUTCString') return baseValue.toUTCString();
|
|
4942
|
+
if (methodName === 'toDateString') return baseValue.toDateString();
|
|
4943
|
+
if (methodName === 'toTimeString') return baseValue.toTimeString();
|
|
4944
|
+
if (methodName === 'toString') return baseValue.toString();
|
|
4945
|
+
if (methodName === 'toLocaleString') return baseValue.toLocaleString();
|
|
4946
|
+
if (methodName === 'toLocaleDateString') return baseValue.toLocaleDateString();
|
|
4947
|
+
if (methodName === 'toLocaleTimeString') return baseValue.toLocaleTimeString();
|
|
4948
|
+
if (methodName === 'getTime') return baseValue.getTime();
|
|
4949
|
+
if (methodName === 'valueOf') return baseValue.valueOf();
|
|
4950
|
+
if (methodName === 'getFullYear') return baseValue.getFullYear();
|
|
4951
|
+
if (methodName === 'getMonth') return baseValue.getMonth();
|
|
4952
|
+
if (methodName === 'getDate') return baseValue.getDate();
|
|
4953
|
+
} catch (_e) { return undefined; }
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
// String instance methods.
|
|
4957
|
+
if (typeof baseValue === 'string') {
|
|
4958
|
+
try {
|
|
4959
|
+
if (methodName === 'toString' || methodName === 'valueOf') return baseValue;
|
|
4123
4960
|
if (methodName === 'toUpperCase') return baseValue.toUpperCase();
|
|
4124
4961
|
if (methodName === 'toLowerCase') return baseValue.toLowerCase();
|
|
4125
4962
|
if (methodName === 'trim') return baseValue.trim();
|
|
4126
|
-
if (methodName === '
|
|
4963
|
+
if (methodName === 'trimStart' || methodName === 'trimLeft') return baseValue.trimStart();
|
|
4964
|
+
if (methodName === 'trimEnd' || methodName === 'trimRight') return baseValue.trimEnd();
|
|
4965
|
+
if (methodName === 'slice' && typeof argValues[0] === 'number') {
|
|
4966
|
+
return argValues.length >= 2 && typeof argValues[1] === 'number'
|
|
4967
|
+
? baseValue.slice(argValues[0] as number, argValues[1] as number)
|
|
4968
|
+
: baseValue.slice(argValues[0] as number);
|
|
4969
|
+
}
|
|
4970
|
+
if (methodName === 'substring' && typeof argValues[0] === 'number') {
|
|
4971
|
+
return argValues.length >= 2 && typeof argValues[1] === 'number'
|
|
4972
|
+
? baseValue.substring(argValues[0] as number, argValues[1] as number)
|
|
4973
|
+
: baseValue.substring(argValues[0] as number);
|
|
4974
|
+
}
|
|
4975
|
+
if (methodName === 'substr' && typeof argValues[0] === 'number') {
|
|
4976
|
+
// `String.prototype.substr` is deprecated but still used in the
|
|
4977
|
+
// wild; map it onto `substring`-equivalent semantics (start +
|
|
4978
|
+
// length) so consumer code that uses it still resolves.
|
|
4979
|
+
const start = argValues[0] as number;
|
|
4980
|
+
const len = typeof argValues[1] === 'number' ? (argValues[1] as number) : baseValue.length;
|
|
4981
|
+
return baseValue.substring(start, start + len);
|
|
4982
|
+
}
|
|
4983
|
+
if (methodName === 'replace' && typeof argValues[0] === 'string' && typeof argValues[1] === 'string') {
|
|
4984
|
+
return baseValue.replace(argValues[0] as string, argValues[1] as string);
|
|
4985
|
+
}
|
|
4986
|
+
if (methodName === 'replaceAll' && typeof argValues[0] === 'string' && typeof argValues[1] === 'string') {
|
|
4987
|
+
// String.prototype.replaceAll requires es2021 in the lib target;
|
|
4988
|
+
// emulate via split/join so we keep the current tsconfig target.
|
|
4989
|
+
return baseValue.split(argValues[0] as string).join(argValues[1] as string);
|
|
4990
|
+
}
|
|
4991
|
+
if (methodName === 'concat') {
|
|
4992
|
+
const allStrings = argValues.every(v => typeof v === 'string');
|
|
4993
|
+
if (allStrings) return baseValue.concat(...(argValues as string[]));
|
|
4994
|
+
}
|
|
4995
|
+
if (methodName === 'repeat' && typeof argValues[0] === 'number') {
|
|
4996
|
+
return baseValue.repeat(argValues[0] as number);
|
|
4997
|
+
}
|
|
4998
|
+
if (methodName === 'padStart' && typeof argValues[0] === 'number') {
|
|
4999
|
+
const pad = typeof argValues[1] === 'string' ? (argValues[1] as string) : ' ';
|
|
5000
|
+
return baseValue.padStart(argValues[0] as number, pad);
|
|
5001
|
+
}
|
|
5002
|
+
if (methodName === 'padEnd' && typeof argValues[0] === 'number') {
|
|
5003
|
+
const pad = typeof argValues[1] === 'string' ? (argValues[1] as string) : ' ';
|
|
5004
|
+
return baseValue.padEnd(argValues[0] as number, pad);
|
|
5005
|
+
}
|
|
5006
|
+
if (methodName === 'startsWith' && typeof argValues[0] === 'string') {
|
|
5007
|
+
return baseValue.startsWith(argValues[0] as string);
|
|
5008
|
+
}
|
|
5009
|
+
if (methodName === 'endsWith' && typeof argValues[0] === 'string') {
|
|
5010
|
+
return baseValue.endsWith(argValues[0] as string);
|
|
5011
|
+
}
|
|
5012
|
+
if (methodName === 'includes' && typeof argValues[0] === 'string') {
|
|
5013
|
+
return baseValue.includes(argValues[0] as string);
|
|
5014
|
+
}
|
|
5015
|
+
if (methodName === 'indexOf' && typeof argValues[0] === 'string') {
|
|
5016
|
+
return baseValue.indexOf(argValues[0] as string);
|
|
5017
|
+
}
|
|
5018
|
+
if (methodName === 'lastIndexOf' && typeof argValues[0] === 'string') {
|
|
5019
|
+
return baseValue.lastIndexOf(argValues[0] as string);
|
|
5020
|
+
}
|
|
5021
|
+
if (methodName === 'charAt' && typeof argValues[0] === 'number') {
|
|
5022
|
+
return baseValue.charAt(argValues[0] as number);
|
|
5023
|
+
}
|
|
5024
|
+
if (methodName === 'at' && typeof argValues[0] === 'number') {
|
|
5025
|
+
return baseValue.at(argValues[0] as number);
|
|
5026
|
+
}
|
|
5027
|
+
if (methodName === 'split' && typeof argValues[0] === 'string') {
|
|
5028
|
+
return baseValue.split(argValues[0] as string);
|
|
5029
|
+
}
|
|
5030
|
+
if (methodName === 'normalize') return baseValue.normalize();
|
|
5031
|
+
} catch (_e) { return undefined; }
|
|
5032
|
+
}
|
|
5033
|
+
// Number instance methods.
|
|
5034
|
+
if (typeof baseValue === 'number') {
|
|
5035
|
+
try {
|
|
5036
|
+
if (methodName === 'toString') {
|
|
5037
|
+
const radix = argValues[0];
|
|
5038
|
+
return typeof radix === 'number' ? baseValue.toString(radix) : String(baseValue);
|
|
5039
|
+
}
|
|
5040
|
+
if (methodName === 'valueOf') return baseValue;
|
|
5041
|
+
if (methodName === 'toFixed' && typeof argValues[0] === 'number') return baseValue.toFixed(argValues[0] as number);
|
|
5042
|
+
if (methodName === 'toPrecision' && typeof argValues[0] === 'number') return baseValue.toPrecision(argValues[0] as number);
|
|
5043
|
+
if (methodName === 'toExponential' && typeof argValues[0] === 'number') return baseValue.toExponential(argValues[0] as number);
|
|
5044
|
+
if (methodName === 'toLocaleString') return baseValue.toLocaleString();
|
|
5045
|
+
} catch (_e) { return undefined; }
|
|
5046
|
+
}
|
|
5047
|
+
// Boolean instance methods.
|
|
5048
|
+
if (typeof baseValue === 'boolean') {
|
|
5049
|
+
if (methodName === 'toString') return String(baseValue);
|
|
5050
|
+
if (methodName === 'valueOf') return baseValue;
|
|
5051
|
+
}
|
|
5052
|
+
// Array instance methods (limited subset — read-only / pure transforms).
|
|
5053
|
+
if (Array.isArray(baseValue)) {
|
|
5054
|
+
try {
|
|
5055
|
+
if (methodName === 'join') {
|
|
5056
|
+
const sep = typeof argValues[0] === 'string' ? (argValues[0] as string) : ',';
|
|
5057
|
+
return baseValue.join(sep);
|
|
5058
|
+
}
|
|
5059
|
+
if (methodName === 'slice') {
|
|
5060
|
+
const start = typeof argValues[0] === 'number' ? (argValues[0] as number) : undefined;
|
|
5061
|
+
const end = typeof argValues[1] === 'number' ? (argValues[1] as number) : undefined;
|
|
5062
|
+
return baseValue.slice(start, end);
|
|
5063
|
+
}
|
|
5064
|
+
if (methodName === 'concat') {
|
|
5065
|
+
return baseValue.concat(...(argValues.filter(v => v !== undefined) as ResolvedExpressionValue[]));
|
|
5066
|
+
}
|
|
5067
|
+
if (methodName === 'includes') return baseValue.includes(argValues[0]);
|
|
5068
|
+
if (methodName === 'indexOf') return baseValue.indexOf(argValues[0]);
|
|
5069
|
+
if (methodName === 'lastIndexOf') return baseValue.lastIndexOf(argValues[0]);
|
|
5070
|
+
if (methodName === 'at' && typeof argValues[0] === 'number') return baseValue.at(argValues[0] as number);
|
|
5071
|
+
} catch (_e) { return undefined; }
|
|
5072
|
+
}
|
|
5073
|
+
return undefined;
|
|
5074
|
+
}
|
|
5075
|
+
// Global identifier calls: `Number(x)`, `String(x)`, `Boolean(x)`,
|
|
5076
|
+
// `parseInt(x, 10)`, `parseFloat(x)`, `isNaN(x)`, `isFinite(x)`.
|
|
5077
|
+
if (Node.isIdentifier(calleeExpr)) {
|
|
5078
|
+
const fnName = calleeExpr.getText();
|
|
5079
|
+
const argValues = expr.getArguments().map(a => this.resolveExpressionValue(a, propsContext));
|
|
5080
|
+
const first = argValues[0];
|
|
5081
|
+
try {
|
|
5082
|
+
if (fnName === 'Number') {
|
|
5083
|
+
if (typeof first === 'number') return first;
|
|
5084
|
+
if (typeof first === 'string' || typeof first === 'boolean') return Number(first);
|
|
5085
|
+
if (first === null) return 0;
|
|
5086
|
+
return undefined;
|
|
4127
5087
|
}
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
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;
|
|
5088
|
+
if (fnName === 'String') return String(first);
|
|
5089
|
+
if (fnName === 'Boolean') return Boolean(first);
|
|
5090
|
+
if (fnName === 'parseInt') {
|
|
5091
|
+
if (typeof first !== 'string' && typeof first !== 'number') return undefined;
|
|
5092
|
+
const radix = typeof argValues[1] === 'number' ? (argValues[1] as number) : 10;
|
|
5093
|
+
return parseInt(String(first), radix);
|
|
5094
|
+
}
|
|
5095
|
+
if (fnName === 'parseFloat') {
|
|
5096
|
+
if (typeof first !== 'string' && typeof first !== 'number') return undefined;
|
|
5097
|
+
return parseFloat(String(first));
|
|
4145
5098
|
}
|
|
5099
|
+
if (fnName === 'isNaN') return typeof first === 'number' ? isNaN(first) : undefined;
|
|
5100
|
+
if (fnName === 'isFinite') return typeof first === 'number' ? isFinite(first) : undefined;
|
|
5101
|
+
} catch (_e) { return undefined; }
|
|
5102
|
+
}
|
|
5103
|
+
return undefined;
|
|
5104
|
+
}
|
|
5105
|
+
|
|
5106
|
+
/**
|
|
5107
|
+
* P2: handle `new Intl.NumberFormat(...)`, `new Intl.DateTimeFormat(...)`,
|
|
5108
|
+
* `new Date(...)`. Returns the live JS instance so subsequent method calls
|
|
5109
|
+
* (`fmt.format(123)`, `date.toLocaleString()`) can dispatch on its
|
|
5110
|
+
* prototype via `evaluateBuiltinCall`.
|
|
5111
|
+
*
|
|
5112
|
+
* All three constructors are pure (no IO, no DOM). Safe to run at scan
|
|
5113
|
+
* time. We swallow construction errors and return undefined to keep the
|
|
5114
|
+
* scanner robust to oddly-shaped args.
|
|
5115
|
+
*/
|
|
5116
|
+
private evaluateNewExpression(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): ResolvedExpressionValue {
|
|
5117
|
+
if (!Node.isNewExpression(expr)) return undefined;
|
|
5118
|
+
const calleeExpr = expr.getExpression();
|
|
5119
|
+
const args = (expr.getArguments() || []).map(a => this.resolveExpressionValue(a, propsContext));
|
|
5120
|
+
// `new Intl.NumberFormat(...)` / `new Intl.DateTimeFormat(...)`
|
|
5121
|
+
if (Node.isPropertyAccessExpression(calleeExpr)) {
|
|
5122
|
+
const ns = calleeExpr.getExpression().getText();
|
|
5123
|
+
const ctor = calleeExpr.getName();
|
|
5124
|
+
if (ns === 'Intl') {
|
|
5125
|
+
try {
|
|
5126
|
+
if (ctor === 'NumberFormat') {
|
|
5127
|
+
const locale = typeof args[0] === 'string' ? (args[0] as string) : (Array.isArray(args[0]) ? (args[0] as string[]) : undefined);
|
|
5128
|
+
const opts = args[1] && typeof args[1] === 'object' ? (args[1] as Intl.NumberFormatOptions) : undefined;
|
|
5129
|
+
return new Intl.NumberFormat(locale, opts);
|
|
5130
|
+
}
|
|
5131
|
+
if (ctor === 'DateTimeFormat') {
|
|
5132
|
+
const locale = typeof args[0] === 'string' ? (args[0] as string) : undefined;
|
|
5133
|
+
const opts = args[1] && typeof args[1] === 'object' ? (args[1] as Intl.DateTimeFormatOptions) : undefined;
|
|
5134
|
+
return new Intl.DateTimeFormat(locale, opts);
|
|
5135
|
+
}
|
|
5136
|
+
} catch (_e) { return undefined; }
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
5139
|
+
if (Node.isIdentifier(calleeExpr) && calleeExpr.getText() === 'Date') {
|
|
5140
|
+
try {
|
|
5141
|
+
if (args.length === 0) return new Date();
|
|
5142
|
+
const a = args[0];
|
|
5143
|
+
if (typeof a === 'string' || typeof a === 'number') return new Date(a);
|
|
5144
|
+
} catch (_e) { return undefined; }
|
|
5145
|
+
}
|
|
5146
|
+
// `new Set([...])` / `new Set()` and `new Map([[k, v], ...])` / `new Map()`.
|
|
5147
|
+
// Pure constructors with no IO, safe at scan time. Enables the common
|
|
5148
|
+
// visibility-gate idiom `const active = new Set(activeGroups ?? DEFAULTS);
|
|
5149
|
+
// active.has("price") ? <Section/> : null` — Set/Map methods then dispatch
|
|
5150
|
+
// via `evaluateBuiltinCall`.
|
|
5151
|
+
if (Node.isIdentifier(calleeExpr)) {
|
|
5152
|
+
const ctorName = calleeExpr.getText();
|
|
5153
|
+
if (ctorName === 'Set') {
|
|
5154
|
+
try {
|
|
5155
|
+
if (args.length === 0) return new Set();
|
|
5156
|
+
if (Array.isArray(args[0])) return new Set(args[0] as Iterable<unknown>);
|
|
5157
|
+
return undefined;
|
|
5158
|
+
} catch (_e) { return undefined; }
|
|
5159
|
+
}
|
|
5160
|
+
if (ctorName === 'Map') {
|
|
5161
|
+
try {
|
|
5162
|
+
if (args.length === 0) return new Map();
|
|
5163
|
+
if (Array.isArray(args[0])) {
|
|
5164
|
+
// Validate every entry is a 2-tuple — silently drop malformed
|
|
5165
|
+
// inputs rather than throwing, so the scanner remains robust.
|
|
5166
|
+
const entries = args[0] as unknown[];
|
|
5167
|
+
const valid: Array<[unknown, unknown]> = [];
|
|
5168
|
+
for (const e of entries) {
|
|
5169
|
+
if (Array.isArray(e) && e.length === 2) valid.push([e[0], e[1]]);
|
|
5170
|
+
}
|
|
5171
|
+
return new Map(valid);
|
|
5172
|
+
}
|
|
5173
|
+
return undefined;
|
|
5174
|
+
} catch (_e) { return undefined; }
|
|
4146
5175
|
}
|
|
4147
5176
|
}
|
|
4148
5177
|
return undefined;
|
|
4149
5178
|
}
|
|
4150
5179
|
|
|
5180
|
+
/**
|
|
5181
|
+
* P3: evaluate a user-defined function call by walking the function body
|
|
5182
|
+
* AST. Supports the common shapes used by formatter helpers in consumer
|
|
5183
|
+
* code (`formatUsd`, `formatUsdFromBase`, `resolveUsdNumber`, etc.):
|
|
5184
|
+
*
|
|
5185
|
+
* - Arrow functions and `function` declarations
|
|
5186
|
+
* - Single-expression bodies (`(x) => x.toFixed(2)`)
|
|
5187
|
+
* - Block bodies with `if` / `return` / `const` statements
|
|
5188
|
+
* - Default parameter values
|
|
5189
|
+
* - Recursive calls into other user functions (handled implicitly
|
|
5190
|
+
* because the interpreter calls back into `resolveExpressionValue`)
|
|
5191
|
+
*
|
|
5192
|
+
* Falls back to `undefined` (caller will use the raw-arg fallback) if
|
|
5193
|
+
* the function isn't found, the body uses unsupported constructs, or
|
|
5194
|
+
* any sub-expression resolves to undefined.
|
|
5195
|
+
*
|
|
5196
|
+
* Recursion is depth-limited to guard against pathological code or
|
|
5197
|
+
* accidental infinite chains.
|
|
5198
|
+
*/
|
|
5199
|
+
private evaluateUserFunctionCall(
|
|
5200
|
+
callExpr: Node,
|
|
5201
|
+
propsContext: Map<string, ResolvedExpressionValue>
|
|
5202
|
+
): ResolvedExpressionValue {
|
|
5203
|
+
if (!Node.isCallExpression(callExpr)) return undefined;
|
|
5204
|
+
const calleeExpr = callExpr.getExpression();
|
|
5205
|
+
|
|
5206
|
+
// IIFE form: `(() => { ... })()` or `(function() { ... })()`. The
|
|
5207
|
+
// callee is an inline arrow / function expression wrapped in
|
|
5208
|
+
// parentheses, with the call's args supplied (often zero — the
|
|
5209
|
+
// common pattern is a no-arg IIFE used to scope local state inside
|
|
5210
|
+
// a single JSX expression). We can interpret it the same way as a
|
|
5211
|
+
// named user function — bind params, walk body — without needing
|
|
5212
|
+
// the import / declaration lookup.
|
|
5213
|
+
let inlineFn: UserFunctionNode | undefined;
|
|
5214
|
+
if (Node.isParenthesizedExpression(calleeExpr)) {
|
|
5215
|
+
const inner = calleeExpr.getExpression();
|
|
5216
|
+
if (Node.isArrowFunction(inner) || Node.isFunctionExpression(inner)) inlineFn = inner;
|
|
5217
|
+
} else if (Node.isArrowFunction(calleeExpr) || Node.isFunctionExpression(calleeExpr)) {
|
|
5218
|
+
inlineFn = calleeExpr;
|
|
5219
|
+
}
|
|
5220
|
+
if (inlineFn) {
|
|
5221
|
+
return this.evaluateFunctionLike(inlineFn, callExpr.getArguments(), propsContext, '__iife__');
|
|
5222
|
+
}
|
|
5223
|
+
|
|
5224
|
+
if (!Node.isIdentifier(calleeExpr)) return undefined;
|
|
5225
|
+
const fnName = calleeExpr.getText();
|
|
5226
|
+
const fnNode = this.resolveFunctionDeclaration(fnName, callExpr);
|
|
5227
|
+
if (!fnNode) return undefined;
|
|
5228
|
+
return this.evaluateFunctionLike(fnNode, callExpr.getArguments(), propsContext, fnName);
|
|
5229
|
+
}
|
|
5230
|
+
|
|
5231
|
+
/**
|
|
5232
|
+
* Shared param-binding + body-interpretation entry point for both named
|
|
5233
|
+
* user functions (resolved via import / declaration lookup) and inline
|
|
5234
|
+
* IIFE-style invocations `(() => { ... })()`. The recursion guard keys
|
|
5235
|
+
* on the function's source-file path so each call site participates in
|
|
5236
|
+
* the same depth limit.
|
|
5237
|
+
*/
|
|
5238
|
+
private evaluateFunctionLike(
|
|
5239
|
+
fnNode: UserFunctionNode,
|
|
5240
|
+
argExprs: Node[],
|
|
5241
|
+
propsContext: Map<string, ResolvedExpressionValue>,
|
|
5242
|
+
debugKey: string,
|
|
5243
|
+
): ResolvedExpressionValue {
|
|
5244
|
+
if (!this.userFunctionStack) this.userFunctionStack = new Set();
|
|
5245
|
+
const stackKey = fnNode.getSourceFile().getFilePath() + ':' + debugKey;
|
|
5246
|
+
if (this.userFunctionStack.has(stackKey)) return undefined;
|
|
5247
|
+
if (this.userFunctionStack.size > 16) return undefined;
|
|
5248
|
+
this.userFunctionStack.add(stackKey);
|
|
5249
|
+
try {
|
|
5250
|
+
const params = fnNode.getParameters();
|
|
5251
|
+
const localContext = new Map(propsContext);
|
|
5252
|
+
for (let i = 0; i < params.length; i++) {
|
|
5253
|
+
const paramName = params[i].getName();
|
|
5254
|
+
let value: ResolvedExpressionValue = undefined;
|
|
5255
|
+
if (i < argExprs.length) {
|
|
5256
|
+
value = this.resolveExpressionValue(argExprs[i], propsContext);
|
|
5257
|
+
}
|
|
5258
|
+
if (value === undefined) {
|
|
5259
|
+
const defaultInit = params[i].getInitializer();
|
|
5260
|
+
if (defaultInit) value = this.resolveExpressionValue(defaultInit, propsContext);
|
|
5261
|
+
}
|
|
5262
|
+
localContext.set(paramName, value);
|
|
5263
|
+
}
|
|
5264
|
+
const body = fnNode.getBody();
|
|
5265
|
+
if (!body) return undefined;
|
|
5266
|
+
if (Node.isBlock(body)) {
|
|
5267
|
+
return this.evaluateFunctionBlock(body, localContext);
|
|
5268
|
+
}
|
|
5269
|
+
// Arrow function with expression body.
|
|
5270
|
+
return this.resolveExpressionValue(body, localContext);
|
|
5271
|
+
} finally {
|
|
5272
|
+
this.userFunctionStack.delete(stackKey);
|
|
5273
|
+
}
|
|
5274
|
+
}
|
|
5275
|
+
|
|
5276
|
+
/**
|
|
5277
|
+
* Find the function declaration for a given identifier, following one
|
|
5278
|
+
* level of `import { name } from '...'` indirection.
|
|
5279
|
+
*
|
|
5280
|
+
* Resolution order:
|
|
5281
|
+
* 1. Variable declaration in the same source file whose initializer is
|
|
5282
|
+
* an arrow function or function expression.
|
|
5283
|
+
* 2. Top-level `function` declaration in the same file.
|
|
5284
|
+
* 3. Single-named import — follow the module specifier to the source
|
|
5285
|
+
* file and repeat the search there.
|
|
5286
|
+
*
|
|
5287
|
+
* Returns the function-or-arrow node, or undefined when not resolvable.
|
|
5288
|
+
*/
|
|
5289
|
+
private resolveFunctionDeclaration(name: string, originExpr: Node): UserFunctionNode | undefined {
|
|
5290
|
+
const sourceFile = originExpr.getSourceFile();
|
|
5291
|
+
const localVar = sourceFile.getVariableDeclaration(name);
|
|
5292
|
+
if (localVar) {
|
|
5293
|
+
const init = localVar.getInitializer();
|
|
5294
|
+
if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
|
|
5295
|
+
return init;
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
const localFn = sourceFile.getFunction(name);
|
|
5299
|
+
if (localFn) return localFn;
|
|
5300
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
5301
|
+
const namedImports = importDecl.getNamedImports();
|
|
5302
|
+
const match = namedImports.find(ni => (ni.getAliasNode()?.getText() ?? ni.getName()) === name);
|
|
5303
|
+
if (!match) continue;
|
|
5304
|
+
const sourceModule = importDecl.getModuleSpecifierSourceFile();
|
|
5305
|
+
if (!sourceModule) return undefined;
|
|
5306
|
+
const importedName = match.getName();
|
|
5307
|
+
const importedVar = sourceModule.getVariableDeclaration(importedName);
|
|
5308
|
+
if (importedVar) {
|
|
5309
|
+
const init = importedVar.getInitializer();
|
|
5310
|
+
if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
|
|
5311
|
+
return init;
|
|
5312
|
+
}
|
|
5313
|
+
}
|
|
5314
|
+
const importedFn = sourceModule.getFunction(importedName);
|
|
5315
|
+
if (importedFn) return importedFn;
|
|
5316
|
+
return undefined;
|
|
5317
|
+
}
|
|
5318
|
+
return undefined;
|
|
5319
|
+
}
|
|
5320
|
+
|
|
5321
|
+
/**
|
|
5322
|
+
* Mini-interpreter for the body of a user function. Supports the
|
|
5323
|
+
* narrow set of statements that appear in pure formatter helpers:
|
|
5324
|
+
*
|
|
5325
|
+
* - `if (cond) { ... } else { ... }` — early returns work because we
|
|
5326
|
+
* surface the inner block's return value.
|
|
5327
|
+
* - `if (cond) return ...;` — single-statement consequent without block.
|
|
5328
|
+
* - `const x = expr;` / `let x = expr;` — variable bindings shadow
|
|
5329
|
+
* the caller's context for subsequent statements.
|
|
5330
|
+
* - `return expr;` — terminates evaluation with the resolved value.
|
|
5331
|
+
*
|
|
5332
|
+
* Returns the value of the executed `return`, or `undefined` if the
|
|
5333
|
+
* body uses an unsupported construct (which the caller treats as
|
|
5334
|
+
* "couldn't interpret, fall through").
|
|
5335
|
+
*
|
|
5336
|
+
* Uses a sentinel symbol to distinguish "block fell through with no
|
|
5337
|
+
* return" (continue interpreting outer statements) from "explicit
|
|
5338
|
+
* return undefined" — only the latter is a successful evaluation.
|
|
5339
|
+
*/
|
|
5340
|
+
private evaluateFunctionBlock(
|
|
5341
|
+
block: Node,
|
|
5342
|
+
context: Map<string, ResolvedExpressionValue>
|
|
5343
|
+
): ResolvedExpressionValue {
|
|
5344
|
+
if (!Node.isBlock(block)) return undefined;
|
|
5345
|
+
for (const stmt of block.getStatements()) {
|
|
5346
|
+
const result = this.evaluateStatement(stmt, context);
|
|
5347
|
+
if (result === EVAL_NEXT) continue;
|
|
5348
|
+
return result;
|
|
5349
|
+
}
|
|
5350
|
+
return undefined;
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
private evaluateStatement(
|
|
5354
|
+
stmt: Node,
|
|
5355
|
+
context: Map<string, ResolvedExpressionValue>
|
|
5356
|
+
): EvalStatementResult {
|
|
5357
|
+
if (Node.isReturnStatement(stmt)) {
|
|
5358
|
+
const expr = stmt.getExpression();
|
|
5359
|
+
if (!expr) return undefined;
|
|
5360
|
+
return this.resolveExpressionValue(expr, context);
|
|
5361
|
+
}
|
|
5362
|
+
if (Node.isVariableStatement(stmt)) {
|
|
5363
|
+
for (const decl of stmt.getDeclarationList().getDeclarations()) {
|
|
5364
|
+
const init = decl.getInitializer();
|
|
5365
|
+
if (!init) return EVAL_NEXT;
|
|
5366
|
+
const value = this.resolveExpressionValue(init, context);
|
|
5367
|
+
context.set(decl.getName(), value);
|
|
5368
|
+
}
|
|
5369
|
+
return EVAL_NEXT;
|
|
5370
|
+
}
|
|
5371
|
+
if (Node.isIfStatement(stmt)) {
|
|
5372
|
+
const condValue = this.resolveExpressionValue(stmt.getExpression(), context);
|
|
5373
|
+
const condBool = this.coerceBoolean(condValue);
|
|
5374
|
+
if (condBool == null) return undefined;
|
|
5375
|
+
const branch = condBool ? stmt.getThenStatement() : stmt.getElseStatement();
|
|
5376
|
+
if (!branch) return EVAL_NEXT;
|
|
5377
|
+
if (Node.isBlock(branch)) {
|
|
5378
|
+
return this.evaluateFunctionBlock(branch, context);
|
|
5379
|
+
}
|
|
5380
|
+
return this.evaluateStatement(branch, context);
|
|
5381
|
+
}
|
|
5382
|
+
if (Node.isBlock(stmt)) {
|
|
5383
|
+
return this.evaluateFunctionBlock(stmt, context);
|
|
5384
|
+
}
|
|
5385
|
+
if (Node.isExpressionStatement(stmt)) {
|
|
5386
|
+
// No side effects to model; just evaluate for any assignments we
|
|
5387
|
+
// might add in the future. For now, treat as a fall-through.
|
|
5388
|
+
return EVAL_NEXT;
|
|
5389
|
+
}
|
|
5390
|
+
return undefined;
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
private userFunctionStack?: Set<string>;
|
|
5394
|
+
|
|
4151
5395
|
private resolveClassNameExpression(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): string {
|
|
4152
5396
|
if (Node.isParenthesizedExpression(expr)) {
|
|
4153
5397
|
return this.resolveClassNameExpression(expr.getExpression(), propsContext);
|
|
@@ -4327,6 +5571,18 @@ export class ComponentScanner {
|
|
|
4327
5571
|
}
|
|
4328
5572
|
}
|
|
4329
5573
|
}
|
|
5574
|
+
|
|
5575
|
+
// cva()'s special pass-through args: `className` and `class` are
|
|
5576
|
+
// merged into the output verbatim. Without this, shadcn's
|
|
5577
|
+
// `cn(buttonVariants({ variant, size, className }))` lost the
|
|
5578
|
+
// user's className override entirely — the `+` / `−` buttons in
|
|
5579
|
+
// greenhouse-app's LeverageSlider rendered as rounded-md ghost
|
|
5580
|
+
// buttons instead of the rounded-full bordered circles the
|
|
5581
|
+
// consumer asked for. cva treats className/class as the final
|
|
5582
|
+
// merge step on top of base + variant classes, so append them
|
|
5583
|
+
// at the end of our resolved class list.
|
|
5584
|
+
const passThrough = requestedVariants.className || requestedVariants.class;
|
|
5585
|
+
if (passThrough) classes.push(passThrough);
|
|
4330
5586
|
}
|
|
4331
5587
|
|
|
4332
5588
|
return resolveClassConflicts(classes.filter(Boolean).join(' '));
|