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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { ComponentScanner } from './component-scanner';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression: story `args` resolution must handle three shapes that
|
|
8
|
+
* commonly appear in real consumer stories but were silently dropped or
|
|
9
|
+
* mis-parsed before:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Spread of a shared base** (`...BASE_ARGS`) — the canonical pattern
|
|
12
|
+
* for stories that mostly share args and only override one field:
|
|
13
|
+
*
|
|
14
|
+
* const BASE_ARGS = { marketSymbol: "SOL", activeSide: "long", ... };
|
|
15
|
+
* export const WithLegend = { args: { ...BASE_ARGS, markers: [...] } };
|
|
16
|
+
*
|
|
17
|
+
* Previously the scanner only walked `Node.isPropertyAssignment`,
|
|
18
|
+
* silently skipping `Node.isSpreadAssignment`. Every BASE_ARGS field
|
|
19
|
+
* went missing from `argsContext`, so the component body saw
|
|
20
|
+
* `marketSymbol = undefined` and rendered blank.
|
|
21
|
+
*
|
|
22
|
+
* 2. **`null` / `undefined` keyword literals** as story arg values —
|
|
23
|
+
* e.g. `error: null` in BASE_ARGS. `parseLiteralValue` previously
|
|
24
|
+
* fell through to `val.getText()` and returned the STRING `"null"`,
|
|
25
|
+
* which is truthy. Conditionals like `{error && <Banner>{error}</Banner>}`
|
|
26
|
+
* then rendered the banner with literal "null" text.
|
|
27
|
+
*
|
|
28
|
+
* 3. **Array literal property values** (`markers: [{...}, {...}]`) —
|
|
29
|
+
* `resolveExpressionValue` doesn't handle ArrayLiteralExpression, so
|
|
30
|
+
* the story-args loop set the arg to `undefined`. Inside the component
|
|
31
|
+
* `{markers.length ? <Legend/> : null}` then evaluated `undefined.length`
|
|
32
|
+
* → undefined → both branches skipped → legend missing. Fix: fall back
|
|
33
|
+
* to `parseLiteralValue` (already used by `extractPropsFromNode` for
|
|
34
|
+
* the JSX-prop equivalent).
|
|
35
|
+
*
|
|
36
|
+
* These fixes are exercised against an on-disk fixture project so the
|
|
37
|
+
* test models the production scan path (`pnpm inkbridge:scan`), not a
|
|
38
|
+
* synthetic in-memory variant.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
interface JsxNodeLike {
|
|
42
|
+
type: 'element' | 'text';
|
|
43
|
+
tagName?: string;
|
|
44
|
+
content?: string;
|
|
45
|
+
props?: Record<string, unknown>;
|
|
46
|
+
children?: JsxNodeLike[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface StoryShape {
|
|
50
|
+
name: string;
|
|
51
|
+
jsxTree?: JsxNodeLike;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ScannedShape {
|
|
55
|
+
name: string;
|
|
56
|
+
stories?: StoryShape[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findElement(node: JsxNodeLike | undefined | null, tag: string): JsxNodeLike | null {
|
|
60
|
+
if (!node || node.type !== 'element') return null;
|
|
61
|
+
if (node.tagName === tag) return node;
|
|
62
|
+
for (const child of node.children || []) {
|
|
63
|
+
const found = findElement(child, tag);
|
|
64
|
+
if (found) return found;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findAllElements(node: JsxNodeLike | undefined | null, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
|
|
70
|
+
if (!node || node.type !== 'element') return out;
|
|
71
|
+
if (node.tagName === tag) out.push(node);
|
|
72
|
+
for (const child of node.children || []) findAllElements(child, tag, out);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectText(node: JsxNodeLike | undefined | null, out: string[] = []): string[] {
|
|
77
|
+
if (!node) return out;
|
|
78
|
+
if (node.type === 'text') {
|
|
79
|
+
if (typeof node.content === 'string') out.push(node.content);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
for (const child of node.children || []) collectText(child, out);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const FIXTURE_DIR = path.resolve(
|
|
87
|
+
process.cwd(),
|
|
88
|
+
'tools/figma-plugin/scanner/__fixtures__/story-args-resolution'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Write fixture files to disk. The scanner discovers components via glob,
|
|
92
|
+
// so in-memory ts-morph files aren't enough — we need real files. Cleaned
|
|
93
|
+
// up at end of run so re-runs are idempotent.
|
|
94
|
+
function writeFixtures(): void {
|
|
95
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
96
|
+
fs.writeFileSync(
|
|
97
|
+
path.join(FIXTURE_DIR, 'Card.tsx'),
|
|
98
|
+
`
|
|
99
|
+
export interface CardProps {
|
|
100
|
+
title: string;
|
|
101
|
+
subtitle: string;
|
|
102
|
+
error: string | null;
|
|
103
|
+
activeSide: "long" | "short";
|
|
104
|
+
items: Array<{ id: string; label: string }>;
|
|
105
|
+
}
|
|
106
|
+
export function Card({ title, subtitle, error, activeSide, items }: CardProps) {
|
|
107
|
+
return (
|
|
108
|
+
<div>
|
|
109
|
+
<h1>{title}</h1>
|
|
110
|
+
<p>{subtitle}</p>
|
|
111
|
+
{error && <div data-role="error-banner">{error}</div>}
|
|
112
|
+
<button data-active={activeSide === "long" ? "true" : "false"}>{activeSide}</button>
|
|
113
|
+
{items.length ? (
|
|
114
|
+
<ul>
|
|
115
|
+
{items.map((it) => <li data-id={it.id}>{it.label}</li>)}
|
|
116
|
+
</ul>
|
|
117
|
+
) : null}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
'utf-8'
|
|
123
|
+
);
|
|
124
|
+
fs.writeFileSync(
|
|
125
|
+
path.join(FIXTURE_DIR, 'Card.stories.tsx'),
|
|
126
|
+
`
|
|
127
|
+
import { Card } from "./Card";
|
|
128
|
+
const meta = { component: Card };
|
|
129
|
+
export default meta;
|
|
130
|
+
|
|
131
|
+
const BASE_ARGS = {
|
|
132
|
+
title: "Hello",
|
|
133
|
+
subtitle: "from base",
|
|
134
|
+
error: null,
|
|
135
|
+
activeSide: "long" as const,
|
|
136
|
+
items: [],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Case 1: spread of base + override with array literal. Exercises
|
|
140
|
+
// the SpreadAssignment branch AND the ArrayLiteralExpression fallback
|
|
141
|
+
// in the PropertyAssignment branch.
|
|
142
|
+
export const WithItems = {
|
|
143
|
+
args: {
|
|
144
|
+
...BASE_ARGS,
|
|
145
|
+
items: [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Case 2: spread that includes a \`null\` keyword literal. The base's
|
|
150
|
+
// \`error: null\` MUST NOT become the string "null" (which is truthy
|
|
151
|
+
// and would render the error banner).
|
|
152
|
+
export const NoError = {
|
|
153
|
+
args: { ...BASE_ARGS },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Case 3: explicit override that flips a value. Verifies args
|
|
157
|
+
// ordering — properties after the spread win.
|
|
158
|
+
export const ShortSide = {
|
|
159
|
+
args: { ...BASE_ARGS, activeSide: "short" as const },
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Case 4: direct \`null\` PropertyAssignment (no spread). Mirrors
|
|
163
|
+
// the real PerpsHeader story shape: \`viewingWalletLabel: null\`
|
|
164
|
+
// written inline. The args-extraction path uses
|
|
165
|
+
// \`extractStringValue\`, which stringifies the null keyword to
|
|
166
|
+
// "null" — \`buildArgsPropsContext\` must drop that so the
|
|
167
|
+
// conditional \`{viewingWalletLabel ? ... : null}\` resolves
|
|
168
|
+
// falsy and the wallet div doesn't render.
|
|
169
|
+
export const DirectNoError = {
|
|
170
|
+
args: {
|
|
171
|
+
title: "Direct",
|
|
172
|
+
subtitle: "no spread",
|
|
173
|
+
error: null,
|
|
174
|
+
activeSide: "long" as const,
|
|
175
|
+
items: [],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
`,
|
|
179
|
+
'utf-8'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function cleanupFixtures(): void {
|
|
184
|
+
try {
|
|
185
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
186
|
+
} catch {
|
|
187
|
+
/* ignore */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function run(): Promise<void> {
|
|
192
|
+
writeFixtures();
|
|
193
|
+
|
|
194
|
+
const scanner = new ComponentScanner({
|
|
195
|
+
componentPaths: [FIXTURE_DIR],
|
|
196
|
+
filePattern: '*.tsx',
|
|
197
|
+
exclude: [],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const results = (await scanner.scanAll()) as unknown as ScannedShape[];
|
|
201
|
+
const card = results.find((r) => r.name === 'Card');
|
|
202
|
+
assert.ok(card, `must find the Card component; got names: ${results.map((r) => r.name).join(', ')}`);
|
|
203
|
+
const stories = card.stories || [];
|
|
204
|
+
const byName = (n: string) => stories.find((s) => s.name === n || s.name.replace(/\s+/g, '') === n);
|
|
205
|
+
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
// Case 1: WithItems — spread + array literal override
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
{
|
|
210
|
+
const story = byName('WithItems');
|
|
211
|
+
assert.ok(story?.jsxTree, 'WithItems story must produce a jsxTree');
|
|
212
|
+
const h1 = findElement(story.jsxTree, 'h1');
|
|
213
|
+
const h1Text = collectText(h1).join('');
|
|
214
|
+
assert.ok(
|
|
215
|
+
h1Text.includes('Hello'),
|
|
216
|
+
`WithItems should inherit title="Hello" from BASE_ARGS via spread; got "${h1Text}"`,
|
|
217
|
+
);
|
|
218
|
+
const items = findAllElements(story.jsxTree, 'li');
|
|
219
|
+
assert.equal(
|
|
220
|
+
items.length,
|
|
221
|
+
2,
|
|
222
|
+
`WithItems should render 2 <li> from the array-literal override; got ${items.length}`,
|
|
223
|
+
);
|
|
224
|
+
const ids = items.map((it) => it.props?.['data-id']).join(',');
|
|
225
|
+
assert.equal(
|
|
226
|
+
ids,
|
|
227
|
+
'a,b',
|
|
228
|
+
`array-literal story-arg must preserve object keys; got "${ids}"`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// -------------------------------------------------------------------------
|
|
233
|
+
// Case 2: NoError — spread including `error: null`
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
{
|
|
236
|
+
const story = byName('NoError');
|
|
237
|
+
assert.ok(story?.jsxTree, 'NoError story must produce a jsxTree');
|
|
238
|
+
// The error banner is the only nested <div> with data-role="error-banner".
|
|
239
|
+
function findByRole(n: JsxNodeLike | undefined, role: string): JsxNodeLike | null {
|
|
240
|
+
if (!n || n.type !== 'element') return null;
|
|
241
|
+
if (n.props?.['data-role'] === role) return n;
|
|
242
|
+
for (const c of n.children || []) {
|
|
243
|
+
const f = findByRole(c, role);
|
|
244
|
+
if (f) return f;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const errBanner = findByRole(story.jsxTree, 'error-banner');
|
|
249
|
+
assert.equal(
|
|
250
|
+
errBanner,
|
|
251
|
+
null,
|
|
252
|
+
`null literal must not be coerced to truthy string "null" — error banner should be absent`,
|
|
253
|
+
);
|
|
254
|
+
// h1 still resolves the title from the base.
|
|
255
|
+
const h1 = findElement(story.jsxTree, 'h1');
|
|
256
|
+
const h1Text = collectText(h1).join('');
|
|
257
|
+
assert.ok(h1Text.includes('Hello'), `NoError should inherit title from BASE_ARGS; got "${h1Text}"`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
// Case 3: ShortSide — explicit override after spread wins
|
|
262
|
+
// -------------------------------------------------------------------------
|
|
263
|
+
{
|
|
264
|
+
const story = byName('ShortSide');
|
|
265
|
+
assert.ok(story?.jsxTree, 'ShortSide story must produce a jsxTree');
|
|
266
|
+
const btn = findElement(story.jsxTree, 'button');
|
|
267
|
+
const btnText = collectText(btn).join('');
|
|
268
|
+
assert.ok(
|
|
269
|
+
btnText.includes('short'),
|
|
270
|
+
`explicit override should win over base; got button text "${btnText}"`,
|
|
271
|
+
);
|
|
272
|
+
assert.equal(
|
|
273
|
+
btn?.props?.['data-active'],
|
|
274
|
+
'false',
|
|
275
|
+
`activeSide override should flip the ternary; expected data-active="false" got "${btn?.props?.['data-active']}"`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
// Case 4: DirectNoError — direct PropertyAssignment of a `null` literal
|
|
281
|
+
// (no spread). Mirrors PerpsHeader's `viewingWalletLabel: null`.
|
|
282
|
+
// -------------------------------------------------------------------------
|
|
283
|
+
{
|
|
284
|
+
const story = byName('DirectNoError');
|
|
285
|
+
assert.ok(story?.jsxTree, 'DirectNoError story must produce a jsxTree');
|
|
286
|
+
function findByRole2(n: JsxNodeLike | undefined, role: string): JsxNodeLike | null {
|
|
287
|
+
if (!n || n.type !== 'element') return null;
|
|
288
|
+
if (n.props?.['data-role'] === role) return n;
|
|
289
|
+
for (const c of n.children || []) {
|
|
290
|
+
const f = findByRole2(c, role);
|
|
291
|
+
if (f) return f;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const errBanner = findByRole2(story.jsxTree, 'error-banner');
|
|
296
|
+
assert.equal(
|
|
297
|
+
errBanner,
|
|
298
|
+
null,
|
|
299
|
+
`direct PropertyAssignment of \`null\` must drop the prop — error banner should be absent (was the PerpsHeader "null" bug)`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
console.log('story-args-resolution-regression: PASS (4 cases)');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
run()
|
|
307
|
+
.catch((err) => {
|
|
308
|
+
cleanupFixtures();
|
|
309
|
+
throw err;
|
|
310
|
+
})
|
|
311
|
+
.then(cleanupFixtures);
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
treeHasDescendantClass,
|
|
9
9
|
treeHasPortalWithFullHeight,
|
|
10
10
|
} from '../src/design-system/story-dimensioning';
|
|
11
|
-
import type
|
|
11
|
+
import { findAnyMaxWidthInTree, type JsxNode } from '../src/tailwind';
|
|
12
12
|
import type { ComponentStory } from '../src/components';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -295,4 +295,79 @@ assert.equal(
|
|
|
295
295
|
'null tree falls back to layoutClasses scan',
|
|
296
296
|
);
|
|
297
297
|
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// findAnyMaxWidthInTree — looser fallback for portal-mounted dialogs
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
//
|
|
302
|
+
// The strict `extractLeadingContainerMaxWidthFromTree` walks only the
|
|
303
|
+
// single-child spine AND requires `w-full` on the same node. shadcn
|
|
304
|
+
// DialogContent has `sm:max-w-lg` but no `w-full`, and its parent Dialog
|
|
305
|
+
// usually has multi-child fan-out (overlay + content). So the strict helper
|
|
306
|
+
// returns null and the story falls back to the generic 900px width, making
|
|
307
|
+
// the dialog preview look much wider than it would in the browser.
|
|
308
|
+
// `findAnyMaxWidthInTree` is the looser fallback wired in
|
|
309
|
+
// `resolveStoryLayoutWidth` for that case.
|
|
310
|
+
|
|
311
|
+
// Single-child spine with max-w → picks it up (parity with the strict helper).
|
|
312
|
+
assert.equal(
|
|
313
|
+
findAnyMaxWidthInTree(el({ className: 'max-w-lg' })),
|
|
314
|
+
512,
|
|
315
|
+
'max-w-lg on root returns 512 (lg = 32rem)',
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Variant-prefixed (sm:max-w-lg) is honoured — extractMaxWidth already
|
|
319
|
+
// handles responsive variants after the Pass-2 extractor fix.
|
|
320
|
+
assert.equal(
|
|
321
|
+
findAnyMaxWidthInTree(el({ className: 'sm:max-w-lg grid' })),
|
|
322
|
+
512,
|
|
323
|
+
'variant-prefixed sm:max-w-lg resolves to 512 — covers shadcn DialogContent',
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Multi-child parent doesn't block the walk — the helper descends every branch.
|
|
327
|
+
{
|
|
328
|
+
const tree = el({}, [
|
|
329
|
+
el({ className: 'overlay fixed inset-0' }),
|
|
330
|
+
el({ className: 'sm:max-w-lg grid' }, [el({ className: 'p-6' })]),
|
|
331
|
+
]);
|
|
332
|
+
assert.equal(
|
|
333
|
+
findAnyMaxWidthInTree(tree),
|
|
334
|
+
512,
|
|
335
|
+
'multi-child parent (Dialog overlay + content) does not block the walk',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Without `w-full` the strict helper bails — this looser one does not.
|
|
340
|
+
assert.equal(
|
|
341
|
+
findAnyMaxWidthInTree(el({ className: 'max-w-sm' })),
|
|
342
|
+
384,
|
|
343
|
+
'lack of w-full does not prevent picking up max-w-* — sm = 24rem = 384',
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Picks the SMALLEST max-w-* when multiple appear in the tree (most
|
|
347
|
+
// restrictive constraint wins as the story-width hint).
|
|
348
|
+
{
|
|
349
|
+
const tree = el({ className: 'max-w-4xl' }, [
|
|
350
|
+
el({ className: 'max-w-md' }),
|
|
351
|
+
]);
|
|
352
|
+
assert.equal(
|
|
353
|
+
findAnyMaxWidthInTree(tree),
|
|
354
|
+
448,
|
|
355
|
+
'when multiple max-w-* appear, the smallest wins (md = 28rem = 448)',
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// No max-w anywhere → null (so callers can fall through to 900px default).
|
|
360
|
+
assert.equal(
|
|
361
|
+
findAnyMaxWidthInTree(el({ className: 'flex flex-col p-4' })),
|
|
362
|
+
null,
|
|
363
|
+
'tree with no max-w-* returns null — caller decides the fallback',
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Null tree is tolerated.
|
|
367
|
+
assert.equal(
|
|
368
|
+
findAnyMaxWidthInTree(undefined),
|
|
369
|
+
null,
|
|
370
|
+
'undefined tree returns null without throwing',
|
|
371
|
+
);
|
|
372
|
+
|
|
298
373
|
console.log('story-dimensioning-regression: PASS');
|
package/scanner/style-map.ts
CHANGED
|
@@ -35,6 +35,57 @@ async function loadStylesheet(id: string, base: string): Promise<{ path: string;
|
|
|
35
35
|
return { path: filePath, base: path.dirname(filePath), content };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a Tailwind v4 `@plugin "..."` directive to a JS module.
|
|
40
|
+
*
|
|
41
|
+
* Tailwind v4's `compile()` invokes this whenever it encounters
|
|
42
|
+
* `@plugin "foo"` in CSS. The directive points at a node_modules
|
|
43
|
+
* package whose default export is a Tailwind plugin (e.g.
|
|
44
|
+
* `@tailwindcss/typography`). Without this option, `compile()` throws
|
|
45
|
+
* "No `loadModule` function provided" the moment any `@plugin` line
|
|
46
|
+
* exists in the consumer's CSS.
|
|
47
|
+
*
|
|
48
|
+
* We don't actually USE the plugin's generated classes for the style
|
|
49
|
+
* map (typography utilities like `prose` are matched separately if at
|
|
50
|
+
* all), but the compile call has to succeed for the rest of the
|
|
51
|
+
* scan-time CSS lookup to work.
|
|
52
|
+
*/
|
|
53
|
+
async function loadModule(
|
|
54
|
+
id: string,
|
|
55
|
+
base: string,
|
|
56
|
+
_resourceHint: 'plugin' | 'config'
|
|
57
|
+
): Promise<{ path: string; base: string; module: unknown }> {
|
|
58
|
+
const root = process.cwd();
|
|
59
|
+
// Resolve the bare package id against the consumer's node_modules.
|
|
60
|
+
// Use Node's createRequire so we honour the consumer's resolution
|
|
61
|
+
// (pnpm hoisting, peer deps, etc.) without forcing a path shape.
|
|
62
|
+
const { createRequire } = await import('node:module');
|
|
63
|
+
const require = createRequire(path.join(root, 'package.json'));
|
|
64
|
+
let resolved: string;
|
|
65
|
+
try {
|
|
66
|
+
resolved = require.resolve(id, { paths: [root, base] });
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
// Plugin not installed — return a no-op plugin so compile doesn't
|
|
69
|
+
// crash. The scan won't expand that plugin's utilities, but the
|
|
70
|
+
// rest of the CSS still resolves.
|
|
71
|
+
return { path: id, base, module: () => ({}) };
|
|
72
|
+
}
|
|
73
|
+
const imported = await import(resolved);
|
|
74
|
+
// Tailwind v4's loadModule contract wants the Plugin or Config OBJECT,
|
|
75
|
+
// not the ESM namespace. ESM modules (`export default plugin(...)`)
|
|
76
|
+
// surface the plugin under `.default`; CJS interop sometimes
|
|
77
|
+
// double-wraps (`{ default: { default: pluginFn } }`). Unwrap once or
|
|
78
|
+
// twice until we hit something that isn't a plain namespace object.
|
|
79
|
+
let mod: unknown = imported;
|
|
80
|
+
if (mod && typeof mod === 'object' && 'default' in (mod as object)) {
|
|
81
|
+
mod = (mod as { default: unknown }).default;
|
|
82
|
+
}
|
|
83
|
+
if (mod && typeof mod === 'object' && 'default' in (mod as object)) {
|
|
84
|
+
mod = (mod as { default: unknown }).default;
|
|
85
|
+
}
|
|
86
|
+
return { path: resolved, base: path.dirname(resolved), module: mod };
|
|
87
|
+
}
|
|
88
|
+
|
|
38
89
|
function extractClassName(selector: string): string | null {
|
|
39
90
|
const dot = selector.indexOf('.');
|
|
40
91
|
if (dot === -1) return null;
|
|
@@ -134,9 +185,15 @@ export async function buildStyleMap(classes: string[]): Promise<StyleMap> {
|
|
|
134
185
|
if (unique.length === 0) return {};
|
|
135
186
|
|
|
136
187
|
const css = fs.readFileSync(path.resolve(process.cwd(), 'src/app/globals.css'), 'utf-8');
|
|
188
|
+
// Cast `loadModule` to the compile() type. Our internal return type uses
|
|
189
|
+
// `module: unknown` because we accept any user-provided plugin / config
|
|
190
|
+
// shape; Tailwind's exported `Plugin | UserConfig` is narrower than what
|
|
191
|
+
// we can statically guarantee, so a structural cast here is safer than
|
|
192
|
+
// forcing the loader to assert against the narrow union.
|
|
137
193
|
const compiled = await compile(css, {
|
|
138
194
|
base: process.cwd(),
|
|
139
195
|
loadStylesheet,
|
|
196
|
+
loadModule: loadModule as never,
|
|
140
197
|
});
|
|
141
198
|
|
|
142
199
|
const cssOutput = compiled.build(unique);
|