inkbridge 0.1.0-beta.20 → 0.1.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +2 -1
  2. package/bin/inkbridge.mjs +64 -9
  3. package/code.js +11 -11
  4. package/package.json +8 -2
  5. package/scanner/adapter-utils-regression.ts +159 -0
  6. package/scanner/component-scanner.ts +276 -19
  7. package/scanner/font-family-extract-regression.ts +113 -0
  8. package/scanner/framework-adapter-shadcn-regression.ts +96 -1
  9. package/scanner/grid-cols-extraction-regression.ts +110 -0
  10. package/scanner/input-range-regression.ts +217 -0
  11. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  12. package/scanner/local-const-className-regression.ts +331 -0
  13. package/scanner/ring-utility-regression.ts +25 -4
  14. package/scanner/state-classification-regression.ts +38 -0
  15. package/scanner/stretch-to-parent-width-regression.ts +35 -1
  16. package/scanner/tailwind-parser.ts +38 -2
  17. package/src/components/component-gen.ts +11 -151
  18. package/src/design-system/cva-master.ts +7 -3
  19. package/src/design-system/design-system.ts +8 -0
  20. package/src/design-system/node-helpers.ts +15 -1
  21. package/src/design-system/preview-builder.ts +14 -45
  22. package/src/design-system/state-master.ts +23 -1
  23. package/src/design-system/story-builder.ts +55 -5
  24. package/src/design-system/ui-builder.ts +116 -6
  25. package/src/framework-adapters/index.ts +15 -2
  26. package/src/framework-adapters/shadcn.ts +83 -67
  27. package/src/layout/deferred-layout.ts +187 -1
  28. package/src/layout/layout-utils.ts +2 -1
  29. package/src/layout/ring-utils.ts +31 -82
  30. package/src/render-engine-version.ts +1 -1
  31. package/src/tailwind/adapter-utils.ts +137 -0
  32. package/src/tailwind/jsx-utils.ts +9 -0
  33. package/src/tailwind/node-ir.ts +172 -0
  34. package/src/tailwind/tailwind.ts +23 -16
  35. package/src/tokens/tokens.ts +11 -3
  36. package/templates/scan-components-route.ts +11 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inkbridge",
3
- "version": "0.1.0-beta.20",
3
+ "version": "0.1.0-beta.21",
4
4
  "description": "Generate a Figma design system from your Storybook stories — Tailwind React components rendered as native frames, design tokens, component states, and round-trip code sync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -76,6 +76,8 @@
76
76
  "test:cva-jsx-child-fallback": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/cva-jsx-child-fallback-regression.ts",
77
77
  "test:cva-master-icon": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/cva-master-icon-regression.ts",
78
78
  "test:data-attr-prop-alias": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/data-attr-prop-alias-regression.ts",
79
+ "test:jsx-prop-unresolved": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/jsx-prop-unresolved-regression.ts",
80
+ "test:grid-cols-extraction": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/grid-cols-extraction-regression.ts",
79
81
  "test:explicit-size-root": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/explicit-size-root-regression.ts",
80
82
  "test:image-src-collector": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/image-src-collector-regression.ts",
81
83
  "test:size-full-normalization": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/size-full-normalization-regression.ts",
@@ -89,8 +91,12 @@
89
91
  "test:svg-marker-inline": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/svg-marker-inline-regression.ts",
90
92
  "test:bundle-size": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/bundle-size-regression.ts",
91
93
  "test:ring-utility": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/ring-utility-regression.ts",
94
+ "test:adapter-utils": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/adapter-utils-regression.ts",
95
+ "test:input-range": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/input-range-regression.ts",
96
+ "test:font-family-extract": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/font-family-extract-regression.ts",
97
+ "test:local-const-className": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/local-const-className-regression.ts",
92
98
  "docs:audit": "node ./scripts/docs-audit.mjs",
93
- "verify": "pnpm run scan && pnpm run test:blob && pnpm run test:tokens && pnpm run test:font && pnpm run test:radial && pnpm run test:transform && pnpm run test:csspatch && pnpm run test:state-classification && pnpm run test:component-sections && pnpm run test:block-cache && pnpm run test:story-dimensioning && pnpm run test:story-render-strategy && pnpm run test:render-prop-parser && pnpm run test:story-diagnostics && pnpm run test:instance-rendering && pnpm run test:layout-spacing && pnpm run test:layout-sizing && pnpm run test:layout-alignment && pnpm run test:layout-flex && pnpm run test:layout-mode && pnpm run test:svg-marker && pnpm run test:aspect-ratio && pnpm run test:percent-position && pnpm run test:svg-fill-parent && pnpm run test:inline-flex && pnpm run test:child-sizing-matrix && pnpm run test:full-width-matrix && pnpm run test:text-resize-matrix && pnpm run test:provider-flatten && pnpm run test:provider-cascade && pnpm run test:selection-pressed && pnpm run test:cva-jsx-child-fallback && pnpm run test:cva-master-icon && pnpm run test:data-attr-prop-alias && pnpm run test:explicit-size-root && pnpm run test:image-src-collector && pnpm run test:size-full-normalization && pnpm run test:stretch-to-parent-width && pnpm run test:framework-adapter-shadcn && pnpm run test:compound-classes-lookup && pnpm run test:sandbox-spread && pnpm run test:jsx-text && pnpm run test:aspect-percent && pnpm run test:svg-group-inherit && pnpm run test:svg-marker-inline && pnpm run test:ring-utility && pnpm run docs:audit && pnpm run build && pnpm run test:bundle-size",
99
+ "verify": "pnpm run scan && pnpm run test:blob && pnpm run test:tokens && pnpm run test:font && pnpm run test:radial && pnpm run test:transform && pnpm run test:csspatch && pnpm run test:state-classification && pnpm run test:component-sections && pnpm run test:block-cache && pnpm run test:story-dimensioning && pnpm run test:story-render-strategy && pnpm run test:render-prop-parser && pnpm run test:story-diagnostics && pnpm run test:instance-rendering && pnpm run test:layout-spacing && pnpm run test:layout-sizing && pnpm run test:layout-alignment && pnpm run test:layout-flex && pnpm run test:layout-mode && pnpm run test:svg-marker && pnpm run test:aspect-ratio && pnpm run test:percent-position && pnpm run test:svg-fill-parent && pnpm run test:inline-flex && pnpm run test:child-sizing-matrix && pnpm run test:full-width-matrix && pnpm run test:text-resize-matrix && pnpm run test:provider-flatten && pnpm run test:provider-cascade && pnpm run test:selection-pressed && pnpm run test:cva-jsx-child-fallback && pnpm run test:cva-master-icon && pnpm run test:data-attr-prop-alias && pnpm run test:jsx-prop-unresolved && pnpm run test:grid-cols-extraction && pnpm run test:explicit-size-root && pnpm run test:image-src-collector && pnpm run test:size-full-normalization && pnpm run test:stretch-to-parent-width && pnpm run test:framework-adapter-shadcn && pnpm run test:compound-classes-lookup && pnpm run test:sandbox-spread && pnpm run test:jsx-text && pnpm run test:aspect-percent && pnpm run test:svg-group-inherit && pnpm run test:svg-marker-inline && pnpm run test:ring-utility && pnpm run test:adapter-utils && pnpm run test:input-range && pnpm run test:font-family-extract && pnpm run test:local-const-className && pnpm run docs:audit && pnpm run build && pnpm run test:bundle-size",
94
100
  "release:beta": "pnpm publish --tag beta && npm dist-tag add inkbridge@$npm_package_version latest && npm view inkbridge dist-tags"
95
101
  }
96
102
  }
@@ -0,0 +1,159 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import {
4
+ isClassedElement,
5
+ mergeMissing,
6
+ resolveValuePercents,
7
+ } from '../src/tailwind/adapter-utils';
8
+ import type { NodeIR } from '../src/tailwind/node-ir';
9
+
10
+ /**
11
+ * Regression: `src/tailwind/adapter-utils.ts` is the shared toolbox both
12
+ * `framework-adapters/shadcn.ts` and `tailwind/node-ir.ts`'s native-HTML
13
+ * transforms call into. If these helpers drift, both adapters
14
+ * silently break — shadcn Slider thumbs end up at wrong positions and
15
+ * `<input type=range>` rewrites compute the wrong fill width.
16
+ */
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // isClassedElement
20
+ // ---------------------------------------------------------------------------
21
+
22
+ {
23
+ const el: NodeIR = {
24
+ kind: 'element',
25
+ tagName: 'div',
26
+ tagLower: 'div',
27
+ props: {},
28
+ classes: [],
29
+ children: [],
30
+ };
31
+ const comp: NodeIR = {
32
+ kind: 'component',
33
+ tagName: 'Button',
34
+ tagLower: 'button',
35
+ props: {},
36
+ classes: [],
37
+ children: [],
38
+ };
39
+ const text: NodeIR = { kind: 'text', text: 'x' };
40
+ const frag: NodeIR = { kind: 'fragment', children: [] };
41
+
42
+ assert.equal(isClassedElement(el), true, 'element is classed');
43
+ assert.equal(isClassedElement(comp), true, 'component is classed');
44
+ assert.equal(isClassedElement(text), false, 'text is not classed');
45
+ assert.equal(isClassedElement(frag), false, 'fragment is not classed');
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // mergeMissing
50
+ // ---------------------------------------------------------------------------
51
+
52
+ {
53
+ // Empty extras → return existing by reference (cache-friendly).
54
+ const existing = ['a', 'b'];
55
+ const out = mergeMissing(existing, []);
56
+ assert.equal(out, existing, 'empty extras returns existing by reference');
57
+ }
58
+
59
+ {
60
+ // All extras already present → return existing by reference.
61
+ const existing = ['a', 'b', 'c'];
62
+ const out = mergeMissing(existing, ['b', 'c']);
63
+ assert.equal(out, existing, 'all present returns existing by reference');
64
+ }
65
+
66
+ {
67
+ // Some extras new → return a new array with originals + new ones in order.
68
+ const existing = ['a', 'b'];
69
+ const out = mergeMissing(existing, ['b', 'c', 'd']);
70
+ assert.notEqual(out, existing, 'new extras return a new array');
71
+ assert.deepEqual(out, ['a', 'b', 'c', 'd'], 'preserves order, de-dupes b');
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // resolveValuePercents — basic numeric input
76
+ // ---------------------------------------------------------------------------
77
+
78
+ {
79
+ // Default min=0, max=100: value 25 → 25%.
80
+ assert.deepEqual(resolveValuePercents(25, undefined, undefined), [25]);
81
+ // Default behavior with explicit undefined.
82
+ assert.deepEqual(resolveValuePercents(50, 0, 100), [50]);
83
+ }
84
+
85
+ {
86
+ // Non-zero min: (value - min) / (max - min) * 100.
87
+ // value=5, min=1.1, max=100 → ((5 - 1.1) / (100 - 1.1)) * 100 ≈ 3.9434
88
+ const out = resolveValuePercents(5, 1.1, 100);
89
+ assert.equal(out.length, 1);
90
+ assert.ok(Math.abs(out[0] - 3.9434) < 0.001, `got ${out[0]}`);
91
+ }
92
+
93
+ {
94
+ // Clamped to 0..100.
95
+ assert.deepEqual(resolveValuePercents(-50, 0, 100), [0], 'below-min clamps to 0');
96
+ assert.deepEqual(resolveValuePercents(200, 0, 100), [100], 'above-max clamps to 100');
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // resolveValuePercents — stringified props (scanner emits everything as
101
+ // string)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ {
105
+ assert.deepEqual(resolveValuePercents('25', '0', '100'), [25]);
106
+ // String min/max with non-zero baseline.
107
+ const out = resolveValuePercents('50', '0', '200');
108
+ assert.deepEqual(out, [25], 'string 50/200 → 25%');
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // resolveValuePercents — JSON array string (shadcn Slider tuple)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ {
116
+ const out = resolveValuePercents('[25, 75]', 0, 100);
117
+ assert.deepEqual(out, [25, 75], 'JSON tuple parses both values');
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // resolveValuePercents — actual array
122
+ // ---------------------------------------------------------------------------
123
+
124
+ {
125
+ const out = resolveValuePercents([10, 50, 90], 0, 100);
126
+ assert.deepEqual(out, [10, 50, 90]);
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // resolveValuePercents — degenerate ranges
131
+ // ---------------------------------------------------------------------------
132
+
133
+ {
134
+ // min === max → fallback range of 100.
135
+ const out = resolveValuePercents(5, 5, 5);
136
+ assert.equal(out.length, 1);
137
+ assert.equal(out[0], 0, 'min===max with value=min yields 0%');
138
+ }
139
+
140
+ {
141
+ // max < min → fallback. We don't care about specific number, just no crash
142
+ // and a finite result.
143
+ const out = resolveValuePercents(50, 100, 0);
144
+ assert.equal(out.length, 1);
145
+ assert.ok(Number.isFinite(out[0]), 'inverted min/max stays finite');
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // resolveValuePercents — unparseable value falls back to [0]
150
+ // ---------------------------------------------------------------------------
151
+
152
+ {
153
+ assert.deepEqual(resolveValuePercents(undefined, 0, 100), [0]);
154
+ assert.deepEqual(resolveValuePercents(null, 0, 100), [0]);
155
+ assert.deepEqual(resolveValuePercents('not-a-number', 0, 100), [0]);
156
+ assert.deepEqual(resolveValuePercents('', 0, 100), [0]);
157
+ }
158
+
159
+ console.log('adapter-utils-regression: ok');
@@ -27,7 +27,7 @@ import type {
27
27
  JsxText,
28
28
  IconImportSpec,
29
29
  } from './types';
30
- import { groupClassesByState, STATE_MODIFIERS } from './tailwind-parser';
30
+ import { groupClassesByState, OWN_STATE_MODIFIERS } from './tailwind-parser';
31
31
  import { twMerge } from 'tailwind-merge';
32
32
 
33
33
  /**
@@ -146,6 +146,11 @@ export class ComponentScanner {
146
146
  // "analyzed, no cascade detected" — still cached to skip the body walk
147
147
  // on subsequent invocations of the same component.
148
148
  private providerCascadeNamesCache: WeakMap<Node, Set<string>>;
149
+ // Re-entry guard for `resolveLocalIdentifier` — prevents infinite recursion
150
+ // when a local const's initializer references another local const that
151
+ // (directly or transitively) references the first one. Keyed by
152
+ // `${filePath}:${name}` so unrelated identifiers don't block each other.
153
+ private localResolutionStack: Set<string> = new Set();
149
154
 
150
155
  constructor(config: ScannerConfig) {
151
156
  this.config = config;
@@ -485,9 +490,11 @@ export class ComponentScanner {
485
490
  return null;
486
491
  }
487
492
 
488
- // Check if any classes have state modifiers
493
+ // Check if any classes have an OWN state modifier (excludes
494
+ // `group-*:` / `peer-*:` — those are passive reactions to a
495
+ // parent's state, not the component's own variant axis).
489
496
  const hasStateModifiers = allClasses.some(cls =>
490
- STATE_MODIFIERS.some(mod => cls.startsWith(mod))
497
+ OWN_STATE_MODIFIERS.some(mod => cls.startsWith(mod))
491
498
  );
492
499
 
493
500
  if (!hasStateModifiers) {
@@ -590,7 +597,7 @@ export class ComponentScanner {
590
597
 
591
598
  const classes = className.split(/\s+/).filter(Boolean);
592
599
  for (const cls of classes) {
593
- for (const modifier of STATE_MODIFIERS) {
600
+ for (const modifier of OWN_STATE_MODIFIERS) {
594
601
  if (cls.startsWith(modifier)) {
595
602
  return true;
596
603
  }
@@ -920,6 +927,22 @@ export class ComponentScanner {
920
927
  // `relativeImports`.
921
928
  if (!story.jsxTree) {
922
929
  const argsContext = this.buildArgsPropsContext(args);
930
+ // Walk the args initializer AST so prop values that are
931
+ // arrays, objects, or identifiers referencing such
932
+ // (e.g. `blocks: mockBlocks`) resolve to their real
933
+ // structure — not the string-coerced text. Without
934
+ // this an args-based BlockTable story would see
935
+ // `blocks = "mockBlocks"` and the .map call inside the
936
+ // component couldn't resolve the iteration source.
937
+ for (const prop of argsInit.getProperties()) {
938
+ if (!Node.isPropertyAssignment(prop)) continue;
939
+ const propInit = prop.getInitializer();
940
+ if (!propInit) continue;
941
+ const resolved = this.resolveExpressionValue(propInit, new Map());
942
+ if (resolved !== undefined && resolved !== null) {
943
+ argsContext.set(prop.getName(), resolved);
944
+ }
945
+ }
923
946
  const localCompBody = localComponents.get(metaComponentName);
924
947
  if (localCompBody) {
925
948
  story.jsxTree = this.extractJsxTreeFromFunctionBody(
@@ -933,6 +956,26 @@ export class ComponentScanner {
933
956
  ) || undefined;
934
957
  }
935
958
  }
959
+
960
+ // Inject `args.children` as a text node when the
961
+ // built tree has no element/text children of its own.
962
+ // Many shadcn primitives use `<Primitive {...props} />`
963
+ // to forward children, so the scanner sees a bare
964
+ // element — the children value lives in the args
965
+ // object but never makes it into the rendered tree.
966
+ // Without this an args-based `<Label>Email</Label>`
967
+ // story renders as an empty Label frame in Figma.
968
+ if (
969
+ story.jsxTree
970
+ && story.jsxTree.type === 'element'
971
+ && (!story.jsxTree.children || story.jsxTree.children.length === 0)
972
+ && typeof args.children === 'string'
973
+ && args.children.length > 0
974
+ ) {
975
+ story.jsxTree.children = [
976
+ { type: 'text', content: args.children },
977
+ ];
978
+ }
936
979
  }
937
980
  }
938
981
  }
@@ -1548,13 +1591,18 @@ export class ComponentScanner {
1548
1591
  } else if (expr && Node.isIdentifier(expr) && propsContext.has(expr.getText())) {
1549
1592
  this.pushResolvedPropValue(children, propsContext.get(expr.getText()));
1550
1593
  } else if (expr && Node.isIdentifier(expr)) {
1551
- // Try to resolve as a module-level const string (e.g. const LONG = "...")
1552
- const varDecl = node.getSourceFile().getVariableDeclaration(expr.getText());
1553
- if (varDecl) {
1554
- const init = varDecl.getInitializer();
1555
- if (init && Node.isStringLiteral(init)) {
1556
- children.push({ type: 'text', content: init.getLiteralValue() });
1557
- }
1594
+ // Delegate to resolveExpressionValue so function-body-local `const`
1595
+ // declarations (e.g. `const statusText = sourceLabel === "db" ? ...`)
1596
+ // resolve the same way they do for className identifiers. Without
1597
+ // this, a JSX text child like `<span>{statusText}</span>` falls
1598
+ // through to the "unresolved" branch and renders nothing — even
1599
+ // when the local const's initializer would resolve fine against
1600
+ // the current propsContext. Same scope-walk fix as the className
1601
+ // path; the bug only shows on JSX *children* because the children
1602
+ // pipeline used a custom (module-level-only) lookup here.
1603
+ const resolved = this.resolveExpressionValue(expr, propsContext);
1604
+ if (resolved !== undefined && resolved !== null) {
1605
+ this.pushResolvedPropValue(children, resolved);
1558
1606
  }
1559
1607
  } else if (expr && Node.isPropertyAccessExpression(expr)) {
1560
1608
  // Resolve {plan.name} style expressions when `plan` is an object in propsContext
@@ -2482,20 +2530,44 @@ export class ComponentScanner {
2482
2530
  return this.arrayValueCache.get(cacheKey)!;
2483
2531
  }
2484
2532
 
2485
- // Look for const declaration: const features = [...]
2486
- for (const varStmt of sourceFile.getVariableStatements()) {
2533
+ // Look for `const features = [...]` style declarations. Walks ALL
2534
+ // variable statements in the source file (top-level AND inside
2535
+ // function bodies) so locally-scoped arrays like
2536
+ // `function BlockTable() { const sortedBlocks = [...blocks].sort(...); ... }`
2537
+ // resolve too — the canonical case is greenhouse-app's block-table
2538
+ // where the .map source is a local sort over an imported JSON file.
2539
+ //
2540
+ // Initializer may be a direct array literal, a spread literal
2541
+ // (`[...other]`), a method chain on an existing array
2542
+ // (`[...blocks].sort(...)`, `data.filter(...).slice(0, N)`), or
2543
+ // simply an identifier that points at another array (re-export).
2544
+ // All of those routes funnel into `resolveArrayFromExpression`.
2545
+ const allVarStatements = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement);
2546
+ for (const varStmt of allVarStatements) {
2487
2547
  for (const decl of varStmt.getDeclarationList().getDeclarations()) {
2488
2548
  if (decl.getName() === arrayName) {
2489
- const init = this.unwrapStaticValueExpression(decl.getInitializer());
2490
- if (init && Node.isArrayLiteralExpression(init)) {
2491
- const value = this.parseArrayLiteral(init);
2492
- this.arrayValueCache.set(cacheKey, value);
2493
- return value;
2549
+ const init = this.unwrapStaticValueExpression(decl.getInitializer()) || decl.getInitializer();
2550
+ if (init) {
2551
+ const value = this.resolveArrayFromExpression(init, sourceFile);
2552
+ if (value) {
2553
+ this.arrayValueCache.set(cacheKey, value);
2554
+ return value;
2555
+ }
2494
2556
  }
2495
2557
  }
2496
2558
  }
2497
2559
  }
2498
2560
 
2561
+ // If the name is a default import of a `.json` file, load it from
2562
+ // disk. Covers patterns like `import data from '~/constants/blocks.json'`
2563
+ // followed by `.map(item => …)` over the imported array (block-table
2564
+ // is the canonical case in greenhouse-app).
2565
+ const jsonImported = this.resolveImportedJsonValue(arrayName, sourceFile);
2566
+ if (Array.isArray(jsonImported)) {
2567
+ this.arrayValueCache.set(cacheKey, jsonImported);
2568
+ return jsonImported;
2569
+ }
2570
+
2499
2571
  // Look for default parameter value in function: function Comp({ features = defaultFeatures })
2500
2572
  for (const func of sourceFile.getFunctions()) {
2501
2573
  const params = func.getParameters();
@@ -2558,6 +2630,101 @@ export class ComponentScanner {
2558
2630
  return null;
2559
2631
  }
2560
2632
 
2633
+ /**
2634
+ * Resolve an expression to an array of items. Handles:
2635
+ *
2636
+ * - `[a, b, c]` — direct array literal (parsed verbatim)
2637
+ * - `[...items]` — spread of another array (resolved
2638
+ * recursively against the source file)
2639
+ * - `items.sort(...)` — array-returning method chains
2640
+ * (`sort`, `filter`, `slice`, `reverse`,
2641
+ * `toSorted`, `toReversed`, `with`) are
2642
+ * unwrapped to the receiver. We do not
2643
+ * *apply* the operation — for design-
2644
+ * system rendering, any non-empty sample
2645
+ * of items is good enough.
2646
+ * - identifier — recurses through `findArrayValue`
2647
+ * (local var) and JSON-import resolution.
2648
+ *
2649
+ * Returns `null` when nothing resolvable is found; the caller then
2650
+ * falls back to its placeholder-iteration path.
2651
+ */
2652
+ private resolveArrayFromExpression(expr: Node, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
2653
+ const unwrapped = this.unwrapStaticValueExpression(expr) || expr;
2654
+
2655
+ if (Node.isArrayLiteralExpression(unwrapped)) {
2656
+ const elements = unwrapped.getElements();
2657
+ if (elements.length === 1 && Node.isSpreadElement(elements[0])) {
2658
+ // `[...items]` — unwrap to `items` and resolve that.
2659
+ const spreadInner = (elements[0] as import('ts-morph').SpreadElement).getExpression();
2660
+ return this.resolveArrayFromExpression(spreadInner, sourceFile);
2661
+ }
2662
+ return this.parseArrayLiteral(unwrapped);
2663
+ }
2664
+
2665
+ if (Node.isCallExpression(unwrapped)) {
2666
+ const callee = unwrapped.getExpression();
2667
+ if (Node.isPropertyAccessExpression(callee)) {
2668
+ const methodName = callee.getName();
2669
+ const PASS_THROUGH_METHODS = new Set([
2670
+ 'sort', 'filter', 'slice', 'reverse',
2671
+ 'toSorted', 'toReversed', 'with', 'concat', 'flat', 'flatMap',
2672
+ ]);
2673
+ if (PASS_THROUGH_METHODS.has(methodName)) {
2674
+ return this.resolveArrayFromExpression(callee.getExpression(), sourceFile);
2675
+ }
2676
+ }
2677
+ }
2678
+
2679
+ if (Node.isIdentifier(unwrapped)) {
2680
+ const name = unwrapped.getText();
2681
+ const fromCache = this.findArrayValue(name, sourceFile);
2682
+ if (fromCache) return fromCache;
2683
+ const fromJson = this.resolveImportedJsonValue(name, sourceFile);
2684
+ if (Array.isArray(fromJson)) return fromJson;
2685
+ }
2686
+
2687
+ return null;
2688
+ }
2689
+
2690
+ /**
2691
+ * If `name` is a default-imported JSON file in `sourceFile` (e.g.
2692
+ * `import blocks from '~/constants/blocks.json'`), load + parse the
2693
+ * file from disk and return the value. Returns `null` for any other
2694
+ * import shape (named imports, non-JSON files, unresolvable paths).
2695
+ *
2696
+ * Path-alias resolution piggybacks on the existing
2697
+ * `resolveImportedComponentPath`, plus a JSON-only branch that probes
2698
+ * the literal path (without `.tsx`/`.ts` candidates).
2699
+ */
2700
+ private resolveImportedJsonValue(name: string, sourceFile: SourceFile): unknown {
2701
+ const fileDir = path.dirname(sourceFile.getFilePath());
2702
+ for (const imp of sourceFile.getImportDeclarations()) {
2703
+ const defaultImport = imp.getDefaultImport();
2704
+ if (!defaultImport || defaultImport.getText() !== name) continue;
2705
+ const moduleSpec = imp.getModuleSpecifierValue();
2706
+ if (!moduleSpec.endsWith('.json')) return null;
2707
+
2708
+ let absPath: string | null = null;
2709
+ if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
2710
+ absPath = path.resolve(fileDir, moduleSpec);
2711
+ } else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
2712
+ absPath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
2713
+ } else {
2714
+ return null;
2715
+ }
2716
+
2717
+ if (!fs.existsSync(absPath)) return null;
2718
+ try {
2719
+ const raw = fs.readFileSync(absPath, 'utf-8');
2720
+ return JSON.parse(raw);
2721
+ } catch (_e) {
2722
+ return null;
2723
+ }
2724
+ }
2725
+ return null;
2726
+ }
2727
+
2561
2728
  /**
2562
2729
  * Unwrap syntax wrappers around static values so declarations like
2563
2730
  * `const FAQS = [...] as const` still resolve to the underlying literal.
@@ -3741,6 +3908,59 @@ export class ComponentScanner {
3741
3908
  return null;
3742
3909
  }
3743
3910
 
3911
+ /**
3912
+ * Resolve an identifier against function-body / block-scoped `const`
3913
+ * declarations, walking ancestor scopes from the reference site outward.
3914
+ * Returns `undefined` if no scope-local declaration is found so the caller
3915
+ * can fall back to module-level lookup.
3916
+ *
3917
+ * Necessary because `SourceFile.getVariableDeclaration` only sees top-level
3918
+ * statements — anything declared inside a function body (the common
3919
+ * `const dotClass = sourceLabel === "db" ? "..." : "..."` pattern used to
3920
+ * derive Tailwind classes from a prop value) is invisible to it. Without
3921
+ * this helper such derived classes silently collapse to "no class" in the
3922
+ * scanner output even though the runtime evaluates them fine.
3923
+ *
3924
+ * Forward references (a `const` declared AFTER the reference site in the
3925
+ * same scope) are skipped to match real JavaScript TDZ semantics.
3926
+ *
3927
+ * Cycle protection via `localResolutionStack` — re-entering the same
3928
+ * `${filePath}:${name}` returns `undefined` instead of recursing forever.
3929
+ */
3930
+ private resolveLocalIdentifier(
3931
+ name: string,
3932
+ fromNode: Node,
3933
+ propsContext: Map<string, ResolvedExpressionValue>,
3934
+ ): ResolvedExpressionValue {
3935
+ const filePath = fromNode.getSourceFile().getFilePath();
3936
+ const stackKey = `${filePath}:${name}`;
3937
+ if (this.localResolutionStack.has(stackKey)) return undefined;
3938
+ const fromStart = fromNode.getStart();
3939
+ let cursor: Node | undefined = fromNode.getParent();
3940
+ while (cursor && !Node.isSourceFile(cursor)) {
3941
+ if (Node.isBlock(cursor) || Node.isCaseClause(cursor) || Node.isDefaultClause(cursor)) {
3942
+ for (const stmt of cursor.getStatements()) {
3943
+ if (!Node.isVariableStatement(stmt)) continue;
3944
+ // Skip forward references — match TDZ semantics.
3945
+ if (stmt.getEnd() > fromStart) continue;
3946
+ for (const decl of stmt.getDeclarationList().getDeclarations()) {
3947
+ if (decl.getName() !== name) continue;
3948
+ const init = decl.getInitializer();
3949
+ if (!init) return undefined;
3950
+ this.localResolutionStack.add(stackKey);
3951
+ try {
3952
+ return this.resolveExpressionValue(init, propsContext);
3953
+ } finally {
3954
+ this.localResolutionStack.delete(stackKey);
3955
+ }
3956
+ }
3957
+ }
3958
+ }
3959
+ cursor = cursor.getParent();
3960
+ }
3961
+ return undefined;
3962
+ }
3963
+
3744
3964
  private resolveExpressionValue(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): ResolvedExpressionValue {
3745
3965
  if (Node.isParenthesizedExpression(expr)) {
3746
3966
  return this.resolveExpressionValue(expr.getExpression(), propsContext);
@@ -3756,6 +3976,13 @@ export class ComponentScanner {
3756
3976
  if (exprText === 'false') return false;
3757
3977
  if (Node.isIdentifier(expr)) {
3758
3978
  if (propsContext.has(exprText)) return propsContext.get(exprText);
3979
+ // Resolve function-body-local consts FIRST so they shadow module-level
3980
+ // names — same rule JavaScript scoping enforces. Without this, a
3981
+ // pattern like `const dotClass = sourceLabel === "db" ? "bg-emerald-500"
3982
+ // : "bg-sky-400"` followed by `<span className={cn(base, dotClass)}/>`
3983
+ // collapses to just `base` and the conditional color is dropped.
3984
+ const localValue = this.resolveLocalIdentifier(exprText, expr, propsContext);
3985
+ if (localValue !== undefined) return localValue;
3759
3986
  // Resolve module-level consts: strings, object literals (e.g.
3760
3987
  // `const WIDTH_CLASSNAMES = {...} as const;`), and array literals
3761
3988
  // (e.g. `const DEFAULT_WIDTHS = [...]`). Returning the parsed value
@@ -3898,6 +4125,24 @@ export class ComponentScanner {
3898
4125
  if (methodName === 'trim') return baseValue.trim();
3899
4126
  if (methodName === 'toString') return baseValue;
3900
4127
  }
4128
+ } else if (Node.isIdentifier(calleeExpr)) {
4129
+ // User-defined function call (e.g. `truncateHash(block.blockHash)`,
4130
+ // `formatTimestamp(block.timestamp)`). We can't execute the
4131
+ // function — that'd require evaluating arbitrary JS — but we
4132
+ // CAN resolve the first argument and return it as a graceful
4133
+ // fallback. Rendering "DKjW9hX6dqBE6aDgqdX7Ytqa…" as-is is
4134
+ // better than rendering an empty cell. If the user's function
4135
+ // does important formatting (e.g. truncation) the value will
4136
+ // look longer than the runtime, but it's visible and obviously
4137
+ // points at the right field. Skip well-known *side-effect*
4138
+ // helpers like `console.*` so we don't surface noise.
4139
+ const fnName = calleeExpr.getText();
4140
+ if (fnName === 'console' || fnName.startsWith('use')) return undefined;
4141
+ const firstArg = expr.getArguments()[0];
4142
+ if (firstArg) {
4143
+ const resolved = this.resolveExpressionValue(firstArg, propsContext);
4144
+ if (resolved !== undefined && resolved !== null) return resolved;
4145
+ }
3901
4146
  }
3902
4147
  }
3903
4148
  return undefined;
@@ -4163,7 +4408,19 @@ export class ComponentScanner {
4163
4408
  props[name] = this.parseLiteralValue(expr);
4164
4409
  } else if (expr) {
4165
4410
  const resolved = this.resolveExpressionValue(expr, propsContext);
4166
- props[name] = resolved !== undefined ? resolved : expr.getText();
4411
+ if (resolved !== undefined) {
4412
+ props[name] = resolved;
4413
+ }
4414
+ // Unresolved expression (typically an unresolved identifier like a
4415
+ // function parameter with no default — e.g. `data-inset={inset}`
4416
+ // where `inset` is undefined in the story). Falling back to
4417
+ // `expr.getText()` here used to serialise the identifier *name*
4418
+ // as the attribute's value (`"inset"`), which the variant engine
4419
+ // mistook for "data-inset is present" and activated every
4420
+ // `data-[inset]:` utility (e.g. DropdownMenuItem's
4421
+ // `data-[inset]:pl-8`, leaving every item indented 32px when no
4422
+ // story used `inset`). Omitting the prop matches React's runtime
4423
+ // behaviour: `data-x={undefined}` renders no attribute.
4167
4424
  }
4168
4425
  }
4169
4426
  }