inkbridge 0.1.0-beta.21 → 0.1.0-beta.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inkbridge",
3
- "version": "0.1.0-beta.21",
3
+ "version": "0.1.0-beta.22",
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": {
@@ -95,8 +95,11 @@
95
95
  "test:input-range": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/input-range-regression.ts",
96
96
  "test:font-family-extract": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/font-family-extract-regression.ts",
97
97
  "test:local-const-className": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/local-const-className-regression.ts",
98
+ "test:imported-array-map": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/imported-array-map-regression.ts",
99
+ "test:story-args-resolution": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/story-args-resolution-regression.ts",
100
+ "test:intrinsic-sizing": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin/scanner/intrinsic-sizing-regression.ts",
98
101
  "docs:audit": "node ./scripts/docs-audit.mjs",
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",
102
+ "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 test:imported-array-map && pnpm run test:story-args-resolution && pnpm run test:intrinsic-sizing && pnpm run docs:audit && pnpm run build && pnpm run test:bundle-size",
100
103
  "release:beta": "pnpm publish --tag beta && npm dist-tag add inkbridge@$npm_package_version latest && npm view inkbridge dist-tags"
101
104
  }
102
105
  }
@@ -934,13 +934,38 @@ export class ComponentScanner {
934
934
  // this an args-based BlockTable story would see
935
935
  // `blocks = "mockBlocks"` and the .map call inside the
936
936
  // component couldn't resolve the iteration source.
937
+ //
938
+ // Handles both literal property assignments AND spread
939
+ // assignments (`...BASE_ARGS`). Without spread handling,
940
+ // shared-args patterns like
941
+ // const BASE_ARGS = { marketSymbol: "SOL", ... };
942
+ // export const WithLegend = { args: { ...BASE_ARGS, markers: [...] } };
943
+ // would silently drop every base arg, so the component
944
+ // body sees `marketSymbol = undefined` and renders blank.
937
945
  for (const prop of argsInit.getProperties()) {
946
+ if (Node.isSpreadAssignment(prop)) {
947
+ const spreadResolved = this.resolveExpressionValue(prop.getExpression(), new Map());
948
+ if (spreadResolved && typeof spreadResolved === 'object' && !Array.isArray(spreadResolved)) {
949
+ for (const [k, v] of Object.entries(spreadResolved as Record<string, ResolvedExpressionValue>)) {
950
+ if (v !== undefined && v !== null) argsContext.set(k, v);
951
+ }
952
+ }
953
+ continue;
954
+ }
938
955
  if (!Node.isPropertyAssignment(prop)) continue;
939
956
  const propInit = prop.getInitializer();
940
957
  if (!propInit) continue;
941
958
  const resolved = this.resolveExpressionValue(propInit, new Map());
942
- if (resolved !== undefined && resolved !== null) {
943
- argsContext.set(prop.getName(), resolved);
959
+ // Fallback to parseLiteralValue for shapes resolveExpressionValue
960
+ // doesn't cover (array literals, in particular — needed for
961
+ // `markers: [...]` and similar inline-array story args).
962
+ // `null` / `undefined` are deliberately skipped — they have
963
+ // no propsContext-bearing value to thread through.
964
+ const finalValue = resolved !== undefined
965
+ ? resolved
966
+ : this.parseLiteralValue(propInit);
967
+ if (finalValue !== undefined && finalValue !== null) {
968
+ argsContext.set(prop.getName(), finalValue);
944
969
  }
945
970
  }
946
971
  const localCompBody = localComponents.get(metaComponentName);
@@ -2568,6 +2593,17 @@ export class ComponentScanner {
2568
2593
  return jsonImported;
2569
2594
  }
2570
2595
 
2596
+ // Named import from a TypeScript / JavaScript file. Covers the common
2597
+ // pattern `import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants"`
2598
+ // followed by `PRICE_CHART_TIMEFRAMES.map(...)` in the JSX body. Without
2599
+ // this branch, `.map()` falls through to the placeholder-iteration path
2600
+ // (one synthetic item) and the rendered tree shows zero buttons.
2601
+ const fromNamedImport = this.findArrayInNamedImports(arrayName, sourceFile);
2602
+ if (fromNamedImport) {
2603
+ this.arrayValueCache.set(cacheKey, fromNamedImport);
2604
+ return fromNamedImport;
2605
+ }
2606
+
2571
2607
  // Look for default parameter value in function: function Comp({ features = defaultFeatures })
2572
2608
  for (const func of sourceFile.getFunctions()) {
2573
2609
  const params = func.getParameters();
@@ -2630,6 +2666,92 @@ export class ComponentScanner {
2630
2666
  return null;
2631
2667
  }
2632
2668
 
2669
+ /**
2670
+ * Resolve a named-imported array constant by following the import
2671
+ * declaration to its source file and recursing into `findArrayValue`
2672
+ * against that file. Covers patterns like:
2673
+ *
2674
+ * import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants";
2675
+ * ...
2676
+ * {PRICE_CHART_TIMEFRAMES.map((option) => <Button>{option.label}</Button>)}
2677
+ *
2678
+ * Without this, `findArrayValue` only inspects the current source file's
2679
+ * declarations, returns `null` for cross-file named imports, and the
2680
+ * `.map()` falls through to the placeholder-iteration path (one synthetic
2681
+ * item) — so consumer JSX renders zero buttons instead of N.
2682
+ *
2683
+ * Returns `null` for default imports (the JSON branch handles `import x from
2684
+ * "./foo.json"` separately) and for any import whose target file can't be
2685
+ * resolved to disk via `resolveImportedComponentPath`.
2686
+ */
2687
+ private findArrayInNamedImports(name: string, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
2688
+ const fileDir = path.dirname(sourceFile.getFilePath());
2689
+ for (const imp of sourceFile.getImportDeclarations()) {
2690
+ const named = imp.getNamedImports().find((n) => {
2691
+ const localName = n.getAliasNode()?.getText() || n.getName();
2692
+ return localName === name;
2693
+ });
2694
+ if (!named) continue;
2695
+ const moduleSpec = imp.getModuleSpecifierValue();
2696
+ const importedFile = this.openImportedSourceFile(fileDir, moduleSpec);
2697
+ if (!importedFile) return null;
2698
+ // Resolve under the IMPORTED source's name (the export may rename
2699
+ // via `import { Foo as Bar }` — we follow the exported name).
2700
+ const exportedName = named.getName();
2701
+ return this.findArrayValue(exportedName, importedFile);
2702
+ }
2703
+ return null;
2704
+ }
2705
+
2706
+ /**
2707
+ * Try to load the source file the import declaration points at, against
2708
+ * both the disk (production scan) and the in-memory ts-morph project
2709
+ * (regression fixtures that don't touch the filesystem). Returns `null`
2710
+ * for imports whose target can't be located in either place.
2711
+ *
2712
+ * Built on top of `resolveImportedComponentPath`'s extension-trying logic
2713
+ * — we re-implement the same candidate list and probe the project's
2714
+ * tracked files when `fs.existsSync` says "not on disk". Keeps the
2715
+ * regression fixtures self-contained (no temp-dir writes) while staying
2716
+ * correct for real consumer scans.
2717
+ */
2718
+ private openImportedSourceFile(baseDir: string, moduleSpec: string): SourceFile | null {
2719
+ // Fast path: real files on disk.
2720
+ const onDiskPath = this.resolveImportedComponentPath(baseDir, moduleSpec);
2721
+ if (onDiskPath) {
2722
+ try {
2723
+ const fromDisk = this.project.addSourceFileAtPathIfExists(onDiskPath);
2724
+ if (fromDisk) return fromDisk;
2725
+ } catch (_e) {
2726
+ /* fall through to in-memory probe */
2727
+ }
2728
+ }
2729
+
2730
+ // In-memory probe: build the same candidate list (with extension
2731
+ // variants) and check the project's tracked files. Lets fixtures call
2732
+ // `project.createSourceFile(path, content)` and have those files be
2733
+ // findable through normal import resolution.
2734
+ let basePath: string | null = null;
2735
+ if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
2736
+ basePath = path.resolve(baseDir, moduleSpec);
2737
+ } else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
2738
+ basePath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
2739
+ }
2740
+ if (!basePath) return null;
2741
+ const candidates = [
2742
+ basePath,
2743
+ `${basePath}.tsx`,
2744
+ `${basePath}.ts`,
2745
+ path.join(basePath, 'index.tsx'),
2746
+ path.join(basePath, 'index.ts'),
2747
+ ];
2748
+ for (const candidate of candidates) {
2749
+ const inProject = this.project.getSourceFile(candidate);
2750
+ if (inProject) return inProject;
2751
+ }
2752
+ return null;
2753
+ }
2754
+
2633
2755
  /**
2634
2756
  * Resolve an expression to an array of items. Handles:
2635
2757
  *
@@ -3884,6 +4006,14 @@ export class ComponentScanner {
3884
4006
  const text = val.getText();
3885
4007
  if (text === 'true') return true;
3886
4008
  if (text === 'false') return false;
4009
+ // `null` / `undefined` keywords resolve to their JS values, not the
4010
+ // strings "null" / "undefined". Previously they fell through to the
4011
+ // `return text` branch and (since "null" is a non-empty truthy string)
4012
+ // caused conditionals like `{error && <Banner>{error}</Banner>}` to
4013
+ // render the banner with literal "null" text when stories passed
4014
+ // `error: null`.
4015
+ if (text === 'null') return null;
4016
+ if (text === 'undefined') return undefined;
3887
4017
  if (Node.isArrayLiteralExpression(val)) {
3888
4018
  return val.getElements().map((el) => this.parseLiteralValue(el));
3889
4019
  }
@@ -0,0 +1,195 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: `.map()` over a NAMED imported array must expand into N
7
+ * JSX children, not fall through to the placeholder-iteration path.
8
+ *
9
+ * History: greenhouse-app's `MarketPriceCard` renders its timeframe row
10
+ * with
11
+ *
12
+ * import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants";
13
+ * ...
14
+ * {PRICE_CHART_TIMEFRAMES.map((option) => (
15
+ * <Button variant={timeframe === option.value ? "default" : "outline"}>
16
+ * {option.label}
17
+ * </Button>
18
+ * ))}
19
+ *
20
+ * `findArrayValue` only inspected the current source file's variable
21
+ * declarations + default JSON imports. Named imports from a TypeScript
22
+ * file (`export const PRICE_CHART_TIMEFRAMES = [...]` in
23
+ * `~/domains/perps/constants.ts`) were invisible. The `.map()` fell
24
+ * through to the "no array found" branch and emitted ONE synthetic
25
+ * placeholder iteration — so Figma rendered zero buttons.
26
+ *
27
+ * Fix: `findArrayInNamedImports` follows named imports to the source
28
+ * file via `resolveImportedComponentPath`, opens the file, and recurses
29
+ * into `findArrayValue` against the imported source. Wired into
30
+ * `findArrayValue` after the JSON-import branch.
31
+ *
32
+ * Generalizes to ANY named-imported const array, not just chart
33
+ * timeframes — covers role lists, theme enums, navigation items, etc.
34
+ */
35
+
36
+ interface JsxNodeLike {
37
+ type: 'element' | 'text';
38
+ tagName?: string;
39
+ content?: string;
40
+ props?: Record<string, unknown>;
41
+ children?: JsxNodeLike[];
42
+ }
43
+
44
+ interface TestScannerView {
45
+ project: import('ts-morph').Project;
46
+ extractComponentJsxTree: (
47
+ sourceFile: import('ts-morph').SourceFile,
48
+ componentName: string,
49
+ ) => JsxNodeLike | null;
50
+ }
51
+
52
+ function makeScanner(): TestScannerView {
53
+ return new ComponentScanner({
54
+ componentPaths: [],
55
+ filePattern: '*.tsx',
56
+ exclude: [],
57
+ }) as unknown as TestScannerView;
58
+ }
59
+
60
+ function fixturePath(relative: string): string {
61
+ return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
62
+ }
63
+
64
+ function findAllElements(node: JsxNodeLike | null, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
65
+ if (!node || node.type !== 'element') return out;
66
+ if (node.tagName === tag) out.push(node);
67
+ for (const child of node.children || []) findAllElements(child, tag, out);
68
+ return out;
69
+ }
70
+
71
+ const scanner = makeScanner();
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Case 1: relative-path named import (`./constants`). Asserts `.map()` over
75
+ // the imported array expands into N JSX elements with the right label text.
76
+ // ---------------------------------------------------------------------------
77
+ {
78
+ scanner.project.createSourceFile(
79
+ fixturePath('imported-array/constants.ts'),
80
+ `
81
+ export const PRICE_CHART_TIMEFRAMES: Array<{ label: string; value: string }> = [
82
+ { label: "Tick", value: "tick" },
83
+ { label: "1h", value: "1h" },
84
+ { label: "1d", value: "1d" },
85
+ ];
86
+ `,
87
+ { overwrite: true },
88
+ );
89
+
90
+ const file = scanner.project.createSourceFile(
91
+ fixturePath('imported-array/Card.tsx'),
92
+ `
93
+ import { PRICE_CHART_TIMEFRAMES } from "./constants";
94
+ export function Card() {
95
+ return (
96
+ <div>
97
+ {PRICE_CHART_TIMEFRAMES.map((option) => (
98
+ <button data-key={option.value}>{option.label}</button>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+ `,
104
+ { overwrite: true },
105
+ );
106
+
107
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
108
+ assert.ok(tree, 'Card must produce a tree');
109
+ const buttons = findAllElements(tree, 'button');
110
+ assert.equal(
111
+ buttons.length,
112
+ 3,
113
+ `expected 3 buttons from the named-imported array .map(); got ${buttons.length}`,
114
+ );
115
+ const labels = buttons
116
+ .map((b) => (b.children || []).map((c) => c.content ?? '').join(''))
117
+ .join('|');
118
+ assert.ok(
119
+ labels.includes('Tick') && labels.includes('1h') && labels.includes('1d'),
120
+ `expected each button's text child to come from option.label; got "${labels}"`,
121
+ );
122
+ const keys = buttons.map((b) => b.props?.['data-key']).join(',');
123
+ assert.equal(
124
+ keys,
125
+ 'tick,1h,1d',
126
+ `expected data-key props to come from option.value; got "${keys}"`,
127
+ );
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Case 2: aliased named import (`import { Foo as Bar } from "./x"`). The
132
+ // scanner must follow the imported name even when locally renamed.
133
+ // ---------------------------------------------------------------------------
134
+ {
135
+ scanner.project.createSourceFile(
136
+ fixturePath('imported-array/aliased-constants.ts'),
137
+ `
138
+ export const ROLES = [
139
+ { id: "admin", label: "Admin" },
140
+ { id: "viewer", label: "Viewer" },
141
+ ];
142
+ `,
143
+ { overwrite: true },
144
+ );
145
+
146
+ const file = scanner.project.createSourceFile(
147
+ fixturePath('imported-array/Aliased.tsx'),
148
+ `
149
+ import { ROLES as RolesList } from "./aliased-constants";
150
+ export function Aliased() {
151
+ return (
152
+ <ul>
153
+ {RolesList.map((r) => <li data-id={r.id}>{r.label}</li>)}
154
+ </ul>
155
+ );
156
+ }
157
+ `,
158
+ { overwrite: true },
159
+ );
160
+
161
+ const tree = scanner.extractComponentJsxTree(file, 'Aliased');
162
+ assert.ok(tree, 'Aliased must produce a tree');
163
+ const items = findAllElements(tree, 'li');
164
+ assert.equal(items.length, 2, `expected 2 <li> from aliased import .map(); got ${items.length}`);
165
+ const ids = items.map((b) => b.props?.['data-id']).join(',');
166
+ assert.equal(ids, 'admin,viewer', `expected ids resolved through the alias; got "${ids}"`);
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Case 3: unresolvable import (path doesn't exist on disk) MUST NOT crash.
171
+ // `.map()` falls through to the existing placeholder-iteration path. Lock the
172
+ // graceful degradation so a future change can't turn this into a hard error.
173
+ // ---------------------------------------------------------------------------
174
+ {
175
+ const file = scanner.project.createSourceFile(
176
+ fixturePath('imported-array/Unresolvable.tsx'),
177
+ `
178
+ import { NOPE } from "./does-not-exist";
179
+ export function Unresolvable() {
180
+ return <div>{NOPE.map((x) => <span>{x.label}</span>)}</div>;
181
+ }
182
+ `,
183
+ { overwrite: true },
184
+ );
185
+
186
+ const tree = scanner.extractComponentJsxTree(file, 'Unresolvable');
187
+ assert.ok(tree, 'must not throw on an unresolvable import');
188
+ // Should produce the placeholder iteration (one synthetic <span>) — the
189
+ // existing fallback path. Just assert we got *something* without
190
+ // checking the placeholder shape, which is implementation-detail.
191
+ const spans = findAllElements(tree, 'span');
192
+ assert.ok(spans.length >= 0, 'should not throw');
193
+ }
194
+
195
+ console.log('imported-array-map-regression: PASS (3 cases)');