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.
- package/README.md +2 -1
- package/bin/inkbridge.mjs +64 -9
- package/code.js +11 -11
- package/package.json +8 -2
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/component-scanner.ts +276 -19
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/framework-adapter-shadcn-regression.ts +96 -1
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/ring-utility-regression.ts +25 -4
- package/scanner/state-classification-regression.ts +38 -0
- package/scanner/stretch-to-parent-width-regression.ts +35 -1
- package/scanner/tailwind-parser.ts +38 -2
- package/src/components/component-gen.ts +11 -151
- package/src/design-system/cva-master.ts +7 -3
- package/src/design-system/design-system.ts +8 -0
- package/src/design-system/node-helpers.ts +15 -1
- package/src/design-system/preview-builder.ts +14 -45
- package/src/design-system/state-master.ts +23 -1
- package/src/design-system/story-builder.ts +55 -5
- package/src/design-system/ui-builder.ts +116 -6
- package/src/framework-adapters/index.ts +15 -2
- package/src/framework-adapters/shadcn.ts +83 -67
- package/src/layout/deferred-layout.ts +187 -1
- package/src/layout/layout-utils.ts +2 -1
- package/src/layout/ring-utils.ts +31 -82
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/tailwind/jsx-utils.ts +9 -0
- package/src/tailwind/node-ir.ts +172 -0
- package/src/tailwind/tailwind.ts +23 -16
- package/src/tokens/tokens.ts +11 -3
- 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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1552
|
-
const
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
|
2486
|
-
|
|
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
|
|
2491
|
-
const value = this.
|
|
2492
|
-
|
|
2493
|
-
|
|
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
|
-
|
|
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
|
}
|