@design-embed/target-react 0.1.0 → 1.0.0

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 (31) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +66 -1
  3. package/dist/design-embed/src/core/diagnostics/diagnostic.d.mts +16 -0
  4. package/dist/design-embed/src/core/nodes.d.mts +63 -0
  5. package/dist/design-embed/src/core/plugins/pluginApi.d.mts +41 -0
  6. package/dist/design-embed/src/core/types.d.mts +98 -0
  7. package/dist/index.d.mts +20 -0
  8. package/dist/index.mjs +964 -0
  9. package/dist/index.test.d.mts +1 -0
  10. package/dist/index.test.mjs +75 -0
  11. package/package.json +11 -9
  12. package/src/index.ts +699 -64
  13. package/dist/index.js +0 -744
  14. package/node_modules/@design-embed/config/README.md +0 -5
  15. package/node_modules/@design-embed/config/dist/index.js +0 -283
  16. package/node_modules/@design-embed/config/package.json +0 -19
  17. package/node_modules/@design-embed/config/src/index.ts +0 -518
  18. package/node_modules/@design-embed/core/README.md +0 -5
  19. package/node_modules/@design-embed/core/dist/diagnostics/diagnostic.js +0 -3
  20. package/node_modules/@design-embed/core/dist/diagnostics/jsonDiagnostic.js +0 -35
  21. package/node_modules/@design-embed/core/dist/index.js +0 -351
  22. package/node_modules/@design-embed/core/dist/pipeline/checkMode.js +0 -29
  23. package/node_modules/@design-embed/core/dist/plugins/pluginApi.js +0 -1
  24. package/node_modules/@design-embed/core/dist/plugins/pluginRegistry.js +0 -25
  25. package/node_modules/@design-embed/core/package.json +0 -19
  26. package/node_modules/@design-embed/core/src/diagnostics/diagnostic.ts +0 -18
  27. package/node_modules/@design-embed/core/src/diagnostics/jsonDiagnostic.ts +0 -51
  28. package/node_modules/@design-embed/core/src/index.ts +0 -591
  29. package/node_modules/@design-embed/core/src/pipeline/checkMode.ts +0 -46
  30. package/node_modules/@design-embed/core/src/plugins/pluginApi.ts +0 -78
  31. package/node_modules/@design-embed/core/src/plugins/pluginRegistry.ts +0 -37
package/src/index.ts CHANGED
@@ -1,34 +1,25 @@
1
- import type { DesignEmbedConfig } from "@design-embed/config";
2
- import type {
3
- DesignNode,
4
- Diagnostic,
5
- PropValue,
6
- TargetEmitInput,
7
- TargetEmitResult,
8
- TargetEmitter,
9
- TargetTestGenerateInput,
10
- TargetTestGenerateResult,
11
- TargetTestGenerator,
12
- } from "@design-embed/core";
13
1
  import {
14
2
  applyComponentMappings,
15
- matchesSelector,
16
- parseInlineStyle,
17
- parseSelector,
18
- } from "@design-embed/core";
3
+ type DesignEmbedConfig,
4
+ type DesignNode,
5
+ type Diagnostic,
6
+ type PropValue,
7
+ parseHtml,
8
+ type TargetEmitInput,
9
+ type TargetEmitResult,
10
+ type TargetEmitter,
11
+ type TargetTestGenerateInput,
12
+ type TargetTestGenerateResult,
13
+ type TargetTestGenerator,
14
+ } from "design-embed";
19
15
 
20
- export const reactEmitter: TargetEmitter = {
16
+ export class ReactTarget implements TargetEmitter, TargetTestGenerator {
21
17
  emit({ nodes, css, config, diagnostics }: TargetEmitInput): TargetEmitResult {
22
- const viewsDir = config?.output?.viewsDir ?? "src/generated/views";
18
+ const viewsDir = String(config?.output?.viewsDir ?? "src/generated/views");
23
19
  const viewName = config?.output?.viewName ?? "DesignView";
24
20
 
25
21
  const styleResult = transformStyles(nodes, css, config, diagnostics);
26
- const transformed = applyComponentMappings(
27
- styleResult.nodes,
28
- config?.components ?? [],
29
- diagnostics,
30
- );
31
- const contents = emitReactView(transformed, viewName, {
22
+ const contents = emitReactView(styleResult.nodes, viewName, {
32
23
  cssModulePath: styleResult.cssModulePath,
33
24
  });
34
25
 
@@ -41,18 +32,29 @@ export const reactEmitter: TargetEmitter = {
41
32
  contents: styleResult.cssModule,
42
33
  });
43
34
  }
35
+ for (const split of emitComponentSplitViews(
36
+ styleResult.nodes,
37
+ viewsDir,
38
+ styleResult.cssModulePath,
39
+ )) {
40
+ files.push(split);
41
+ }
44
42
 
45
43
  return { files };
46
- },
47
- };
44
+ }
45
+
46
+ generateTests(input: TargetTestGenerateInput): TargetTestGenerateResult {
47
+ return reactTestGenerator.generateTests(input);
48
+ }
49
+ }
48
50
 
49
51
  export const reactTestGenerator: TargetTestGenerator = {
50
52
  generateTests({
51
53
  html,
52
54
  css,
53
55
  config,
54
- diagnostics,
55
56
  }: TargetTestGenerateInput): TargetTestGenerateResult {
57
+ const diagnostics: Diagnostic[] = [];
56
58
  const tests = config.tests;
57
59
  if (tests?.runner && tests.runner !== "playwright") {
58
60
  diagnostics.push({
@@ -60,47 +62,96 @@ export const reactTestGenerator: TargetTestGenerator = {
60
62
  message: `Unsupported test runner: ${tests.runner}`,
61
63
  severity: "error",
62
64
  });
63
- return { files: [] };
65
+ return { files: [], diagnostics };
64
66
  }
65
67
 
66
- const viewsDir = config.output?.viewsDir ?? "src/generated/views";
68
+ const viewsDir = String(config.output?.viewsDir ?? "src/generated/views");
67
69
  const viewName = config.output?.viewName ?? "DesignView";
68
- const outputDir = tests?.outputDir ?? "tests/generated/design-embed";
70
+ const outputDir = tests?.outputDir ?? `${viewsDir}/tests`;
69
71
  const fixturePath = `${outputDir}/${viewName}.reference.html`;
70
72
  const specPath = `${outputDir}/${viewName}.visual.spec.tsx`;
71
73
  const referenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${html}`;
72
74
 
73
- return {
74
- files: [
75
- {
76
- path: fixturePath,
77
- contents: referenceHtml.endsWith("\n")
78
- ? referenceHtml
79
- : `${referenceHtml}\n`,
80
- },
81
- {
82
- path: specPath,
83
- contents: emitReactVisualSpec({
84
- viewName,
85
- viewImportPath: toRelativeImport(
86
- specPath,
87
- `${viewsDir}/${viewName}.view`,
88
- ),
89
- fixtureFileName: `${viewName}.reference.html`,
90
- viewports: tests?.viewports ?? [
91
- { name: "default", width: 1440, height: 900 },
92
- ],
93
- states: tests?.states ?? [{ name: "default" }],
94
- assertions: {
95
- screenshot: tests?.assertions?.screenshot ?? true,
96
- layout: tests?.assertions?.layout ?? true,
97
- layoutTolerance: tests?.assertions?.layoutTolerance ?? 0,
98
- selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
99
- },
100
- }),
101
- },
102
- ],
75
+ const assertionDefaults = {
76
+ screenshot: tests?.assertions?.screenshot ?? true,
77
+ layout: tests?.assertions?.layout ?? true,
78
+ layoutTolerance: tests?.assertions?.layoutTolerance ?? 1,
79
+ selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
80
+ screenshotThreshold: tests?.assertions?.screenshotThreshold ?? 0.2,
81
+ screenshotMaxDiffPixels:
82
+ tests?.assertions?.screenshotMaxDiffPixels ?? 500,
103
83
  };
84
+ const viewportDefaults = tests?.viewports ?? [
85
+ { name: "default", width: 1440, height: 900 },
86
+ ];
87
+ const stateDefaults = tests?.states ?? [{ name: "default" }];
88
+ const referenceHtmlFileName = `${viewName}.reference.html`;
89
+
90
+ const files: Array<{ path: string; contents: string }> = [
91
+ {
92
+ path: fixturePath,
93
+ contents: referenceHtml.endsWith("\n")
94
+ ? referenceHtml
95
+ : `${referenceHtml}\n`,
96
+ },
97
+ {
98
+ path: specPath,
99
+ contents: emitReactVisualSpec({
100
+ viewName,
101
+ viewImportPath: toRelativeImport(
102
+ specPath,
103
+ `${viewsDir}/${viewName}.view`,
104
+ ),
105
+ fixtureFileName: referenceHtmlFileName,
106
+ viewports: viewportDefaults,
107
+ states: stateDefaults,
108
+ assertions: assertionDefaults,
109
+ }),
110
+ },
111
+ ];
112
+
113
+ const parsedHtmlNodes = parseHtml(html);
114
+ const componentNodes = collectComponentNodes(
115
+ applyComponentMappings(parsedHtmlNodes, config.components ?? []),
116
+ );
117
+
118
+ for (const mapping of config.components ?? []) {
119
+ const componentName = mapping.component;
120
+ const componentSpecPath = `${outputDir}/${componentName}.visual.spec.tsx`;
121
+ const componentReferenceHtmlFileName = `${componentName}.reference.html`;
122
+ const componentFixturePath = `${outputDir}/${componentReferenceHtmlFileName}`;
123
+ const matchingNode = findNodeBySelector(
124
+ parsedHtmlNodes,
125
+ mapping.selector,
126
+ );
127
+ const elementHtml = matchingNode ? serializeNodeToHtml(matchingNode) : "";
128
+ const componentReferenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${elementHtml}`;
129
+ const mountNode = componentNodes.get(componentName);
130
+ files.push({
131
+ path: componentFixturePath,
132
+ contents: componentReferenceHtml.endsWith("\n")
133
+ ? componentReferenceHtml
134
+ : `${componentReferenceHtml}\n`,
135
+ });
136
+ files.push({
137
+ path: componentSpecPath,
138
+ contents: emitComponentVisualSpec({
139
+ componentName,
140
+ selector: mapping.selector,
141
+ mountJsx: emitComponentMount(componentName, mountNode),
142
+ componentImportPath: toRelativeImport(
143
+ componentSpecPath,
144
+ `${viewsDir}/${componentName}.view`,
145
+ ),
146
+ referenceHtmlFileName: componentReferenceHtmlFileName,
147
+ viewports: viewportDefaults,
148
+ states: stateDefaults,
149
+ assertions: assertionDefaults,
150
+ }),
151
+ });
152
+ }
153
+
154
+ return { diagnostics, files };
104
155
  },
105
156
  };
106
157
 
@@ -121,6 +172,8 @@ interface ReactVisualSpecInput {
121
172
  layout: boolean;
122
173
  layoutTolerance: number;
123
174
  selectors: string[];
175
+ screenshotThreshold: number;
176
+ screenshotMaxDiffPixels: number;
124
177
  };
125
178
  }
126
179
 
@@ -131,11 +184,19 @@ function emitReactVisualSpec(input: ReactVisualSpecInput): string {
131
184
  const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
132
185
  const layoutEnabled = JSON.stringify(input.assertions.layout);
133
186
  const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
187
+ const screenshotThreshold = JSON.stringify(
188
+ input.assertions.screenshotThreshold,
189
+ );
190
+ const screenshotMaxDiffPixels = JSON.stringify(
191
+ input.assertions.screenshotMaxDiffPixels,
192
+ );
134
193
 
135
194
  return `import { readFileSync } from "node:fs";
136
195
  import { dirname, resolve } from "node:path";
137
196
  import { fileURLToPath } from "node:url";
138
197
  import { expect, test } from "@playwright/experimental-ct-react";
198
+ import pixelmatch from "pixelmatch";
199
+ import { PNG } from "pngjs";
139
200
  import { ${input.viewName} } from "${input.viewImportPath}";
140
201
 
141
202
  const currentDir = dirname(fileURLToPath(import.meta.url));
@@ -146,6 +207,8 @@ const selectors = ${selectors};
146
207
  const screenshotEnabled = ${screenshotEnabled};
147
208
  const layoutEnabled = ${layoutEnabled};
148
209
  const layoutTolerance = ${layoutTolerance};
210
+ const screenshotThreshold = ${screenshotThreshold};
211
+ const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
149
212
 
150
213
  for (const viewport of viewports) {
151
214
  \tfor (const state of states) {
@@ -165,7 +228,159 @@ for (const viewport of viewports) {
165
228
  \t\t\tconst actualLayout = layoutEnabled ? await readLayout(component, selectors) : [];
166
229
 
167
230
  \t\t\tif (screenshotEnabled) {
168
- \t\t\t\texpect(actualScreenshot).toEqual(expectedScreenshot);
231
+ \t\t\t\tcompareScreenshots(actualScreenshot, expectedScreenshot, screenshotThreshold, screenshotMaxDiffPixels);
232
+ \t\t\t}
233
+ \t\t\tif (layoutEnabled) {
234
+ \t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
235
+ \t\t\t}
236
+ \t\t});
237
+ \t}
238
+ }
239
+
240
+ async function applyState(page, state) {
241
+ \tif (state.waitFor) {
242
+ \t\tawait page.waitForSelector(state.waitFor);
243
+ \t}
244
+ \tif (state.hover) {
245
+ \t\tawait page.hover(state.hover);
246
+ \t}
247
+ \tif (state.focus) {
248
+ \t\tawait page.focus(state.focus);
249
+ \t}
250
+ \tif (state.click) {
251
+ \t\tawait page.click(state.click);
252
+ \t}
253
+ }
254
+
255
+ async function readLayout(root, selectorsToRead) {
256
+ \treturn root.evaluate((element, values) => {
257
+ \t\tconst origin = element.getBoundingClientRect();
258
+ \t\treturn values.flatMap((selector) => {
259
+ \t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
260
+ \t\t\treturn matches.map((matchedElement, index) => {
261
+ \t\t\t\tconst rect = matchedElement.getBoundingClientRect();
262
+ \t\t\t\treturn {
263
+ \t\t\t\t\tselector,
264
+ \t\t\t\t\tindex,
265
+ \t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
266
+ \t\t\t\t\tx: rect.x - origin.x,
267
+ \t\t\t\t\ty: rect.y - origin.y,
268
+ \t\t\t\t\twidth: rect.width,
269
+ \t\t\t\t\theight: rect.height,
270
+ \t\t\t\t};
271
+ \t\t\t});
272
+ \t\t});
273
+ \t}, selectorsToRead);
274
+ }
275
+
276
+ function compareScreenshots(actual, expected, threshold, maxDiffPixels) {
277
+ \tif (!actual || !expected) {
278
+ \t\texpect(actual).toEqual(expected);
279
+ \t\treturn;
280
+ \t}
281
+ \tconst actualPng = PNG.sync.read(actual);
282
+ \tconst expectedPng = PNG.sync.read(expected);
283
+ \texpect(actualPng.width, "screenshot width").toBe(expectedPng.width);
284
+ \texpect(actualPng.height, "screenshot height").toBe(expectedPng.height);
285
+ \tconst diffPixels = pixelmatch(actualPng.data, expectedPng.data, null, actualPng.width, actualPng.height, { threshold });
286
+ \texpect(diffPixels, "screenshot pixels differing beyond threshold").toBeLessThanOrEqual(maxDiffPixels);
287
+ }
288
+
289
+ function expectLayoutToMatch(actual, expected, tolerance) {
290
+ \texpect(actual.length).toBe(expected.length);
291
+ \tfor (let index = 0; index < expected.length; index += 1) {
292
+ \t\tconst actualRect = actual[index];
293
+ \t\tconst expectedRect = expected[index];
294
+ \t\texpect(actualRect.selector).toBe(expectedRect.selector);
295
+ \t\texpect(actualRect.index).toBe(expectedRect.index);
296
+ \t\texpect(actualRect.tagName).toBe(expectedRect.tagName);
297
+ \t\tfor (const key of ["x", "y", "width", "height"]) {
298
+ \t\t\tconst drift = Math.abs(actualRect[key] - expectedRect[key]);
299
+ \t\t\texpect(drift, \`\${expectedRect.selector}[\${expectedRect.index}] \${key} drift\`).toBeLessThanOrEqual(tolerance);
300
+ \t\t}
301
+ \t}
302
+ }
303
+ `;
304
+ }
305
+
306
+ interface ComponentVisualSpecInput {
307
+ componentName: string;
308
+ selector: string;
309
+ mountJsx: string;
310
+ componentImportPath: string;
311
+ referenceHtmlFileName: string;
312
+ viewports: Array<{ name?: string; width: number; height: number }>;
313
+ states: Array<{
314
+ name: string;
315
+ hover?: string;
316
+ focus?: string;
317
+ click?: string;
318
+ waitFor?: string;
319
+ }>;
320
+ assertions: {
321
+ screenshot: boolean;
322
+ layout: boolean;
323
+ layoutTolerance: number;
324
+ selectors: string[];
325
+ screenshotThreshold: number;
326
+ screenshotMaxDiffPixels: number;
327
+ };
328
+ }
329
+
330
+ function emitComponentVisualSpec(input: ComponentVisualSpecInput): string {
331
+ const viewports = JSON.stringify(input.viewports, null, 2);
332
+ const states = JSON.stringify(input.states, null, 2);
333
+ const selectors = JSON.stringify(input.assertions.selectors, null, 2);
334
+ const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
335
+ const layoutEnabled = JSON.stringify(input.assertions.layout);
336
+ const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
337
+ const screenshotThreshold = JSON.stringify(
338
+ input.assertions.screenshotThreshold,
339
+ );
340
+ const screenshotMaxDiffPixels = JSON.stringify(
341
+ input.assertions.screenshotMaxDiffPixels,
342
+ );
343
+ const selector = JSON.stringify(input.selector);
344
+
345
+ return `import { readFileSync } from "node:fs";
346
+ import { dirname, resolve } from "node:path";
347
+ import { fileURLToPath } from "node:url";
348
+ import { expect, test } from "@playwright/experimental-ct-react";
349
+ import pixelmatch from "pixelmatch";
350
+ import { PNG } from "pngjs";
351
+ import { ${input.componentName} } from "${input.componentImportPath}";
352
+
353
+ const currentDir = dirname(fileURLToPath(import.meta.url));
354
+ const referenceHtml = readFileSync(resolve(currentDir, "./${input.referenceHtmlFileName}"), "utf-8");
355
+ const selector = ${selector};
356
+ const viewports = ${viewports};
357
+ const states = ${states};
358
+ const selectors = ${selectors};
359
+ const screenshotEnabled = ${screenshotEnabled};
360
+ const layoutEnabled = ${layoutEnabled};
361
+ const layoutTolerance = ${layoutTolerance};
362
+ const screenshotThreshold = ${screenshotThreshold};
363
+ const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
364
+
365
+ for (const viewport of viewports) {
366
+ \tfor (const state of states) {
367
+ \t\tconst viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
368
+ \t\ttest("${input.componentName} matches source at " + viewportName + " / " + state.name, async ({ mount, page }) => {
369
+ \t\t\tawait page.setViewportSize({ width: viewport.width, height: viewport.height });
370
+
371
+ \t\t\tawait page.setContent(referenceHtml);
372
+ \t\t\tawait applyState(page, state);
373
+ \t\t\tconst expectedEl = page.locator(selector).first();
374
+ \t\t\tconst expectedScreenshot = screenshotEnabled ? await expectedEl.screenshot() : undefined;
375
+ \t\t\tconst expectedLayout = layoutEnabled ? await readLayout(expectedEl, selectors) : [];
376
+
377
+ \t\t\tconst component = await mount(${input.mountJsx});
378
+ \t\t\tawait applyState(page, state);
379
+ \t\t\tconst actualScreenshot = screenshotEnabled ? await component.screenshot() : undefined;
380
+ \t\t\tconst actualLayout = layoutEnabled ? await readLayout(component, selectors) : [];
381
+
382
+ \t\t\tif (screenshotEnabled) {
383
+ \t\t\t\tcompareScreenshots(actualScreenshot, expectedScreenshot, screenshotThreshold, screenshotMaxDiffPixels);
169
384
  \t\t\t}
170
385
  \t\t\tif (layoutEnabled) {
171
386
  \t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
@@ -191,6 +406,7 @@ async function applyState(page, state) {
191
406
 
192
407
  async function readLayout(root, selectorsToRead) {
193
408
  \treturn root.evaluate((element, values) => {
409
+ \t\tconst origin = element.getBoundingClientRect();
194
410
  \t\treturn values.flatMap((selector) => {
195
411
  \t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
196
412
  \t\t\treturn matches.map((matchedElement, index) => {
@@ -199,8 +415,8 @@ async function readLayout(root, selectorsToRead) {
199
415
  \t\t\t\t\tselector,
200
416
  \t\t\t\t\tindex,
201
417
  \t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
202
- \t\t\t\t\tx: rect.x,
203
- \t\t\t\t\ty: rect.y,
418
+ \t\t\t\t\tx: rect.x - origin.x,
419
+ \t\t\t\t\ty: rect.y - origin.y,
204
420
  \t\t\t\t\twidth: rect.width,
205
421
  \t\t\t\t\theight: rect.height,
206
422
  \t\t\t\t};
@@ -209,6 +425,19 @@ async function readLayout(root, selectorsToRead) {
209
425
  \t}, selectorsToRead);
210
426
  }
211
427
 
428
+ function compareScreenshots(actual, expected, threshold, maxDiffPixels) {
429
+ \tif (!actual || !expected) {
430
+ \t\texpect(actual).toEqual(expected);
431
+ \t\treturn;
432
+ \t}
433
+ \tconst actualPng = PNG.sync.read(actual);
434
+ \tconst expectedPng = PNG.sync.read(expected);
435
+ \texpect(actualPng.width, "screenshot width").toBe(expectedPng.width);
436
+ \texpect(actualPng.height, "screenshot height").toBe(expectedPng.height);
437
+ \tconst diffPixels = pixelmatch(actualPng.data, expectedPng.data, null, actualPng.width, actualPng.height, { threshold });
438
+ \texpect(diffPixels, "screenshot pixels differing beyond threshold").toBeLessThanOrEqual(maxDiffPixels);
439
+ }
440
+
212
441
  function expectLayoutToMatch(actual, expected, tolerance) {
213
442
  \texpect(actual.length).toBe(expected.length);
214
443
  \tfor (let index = 0; index < expected.length; index += 1) {
@@ -226,6 +455,307 @@ function expectLayoutToMatch(actual, expected, tolerance) {
226
455
  `;
227
456
  }
228
457
 
458
+ function emitComponentSplitViews(
459
+ nodes: DesignNode[],
460
+ viewsDir: string,
461
+ cssModulePath: string | undefined,
462
+ ): Array<{ path: string; contents: string }> {
463
+ const seen = new Set<string>();
464
+ const files: Array<{ path: string; contents: string }> = [];
465
+
466
+ function visit(node: DesignNode): void {
467
+ if (node.kind === "component") {
468
+ const importName = node.importName ?? node.component ?? "";
469
+ const childrenProp = node.props?.children;
470
+ const innerChildren: DesignNode[] =
471
+ childrenProp?.kind === "children"
472
+ ? childrenProp.value
473
+ : (node.children ?? []);
474
+
475
+ if (importName && !seen.has(importName)) {
476
+ seen.add(importName);
477
+ const funcName = toPascalCase(importName);
478
+ files.push({
479
+ path: `${viewsDir}/${importName}.view.tsx`,
480
+ contents: emitComponentView(node, funcName, { cssModulePath }),
481
+ });
482
+ }
483
+
484
+ for (const child of innerChildren) {
485
+ visit(child);
486
+ }
487
+ } else if (node.kind === "element") {
488
+ for (const child of node.children ?? []) {
489
+ visit(child);
490
+ }
491
+ }
492
+ }
493
+
494
+ for (const node of nodes) {
495
+ visit(node);
496
+ }
497
+ return files;
498
+ }
499
+
500
+ /**
501
+ * Emits the implementation of a mapped component. The component reconstructs
502
+ * the original element (tag, attributes, styles) captured in `sourceElement`,
503
+ * exposes the mapping's props as a typed interface, and wires `children` and
504
+ * `$attr.*` props into the rendered element.
505
+ */
506
+ function emitComponentView(
507
+ node: DesignNode,
508
+ funcName: string,
509
+ options: { cssModulePath?: string } = {},
510
+ ): string {
511
+ const props = node.props ?? {};
512
+ const source = node.sourceElement;
513
+ const propEntries = Object.entries(props);
514
+
515
+ // Classify props into an attribute binding (attr name -> prop name), the
516
+ // children prop, and the destructured parameter list (props referenced by
517
+ // the body). Plain literal props are documented in the interface but are
518
+ // not destructured because they are not rendered.
519
+ const attributeBindings = new Map<string, string>();
520
+ const interfaceLines: string[] = [];
521
+ const destructured: string[] = [];
522
+ let childrenPropName: string | undefined;
523
+
524
+ for (const [propName, prop] of propEntries) {
525
+ if (prop.kind === "text" || prop.kind === "children") {
526
+ childrenPropName = propName;
527
+ interfaceLines.push(`\t${propName}?: ReactNode;`);
528
+ destructured.push(propName);
529
+ continue;
530
+ }
531
+ interfaceLines.push(`\t${propName}?: string;`);
532
+ if (prop.kind === "literal" && prop.attribute) {
533
+ attributeBindings.set(prop.attribute, propName);
534
+ destructured.push(propName);
535
+ }
536
+ }
537
+
538
+ const body = emitComponentBody(
539
+ node,
540
+ source,
541
+ attributeBindings,
542
+ childrenPropName,
543
+ 2,
544
+ );
545
+
546
+ const importNodes = childrenPropName ? [] : (node.children ?? []);
547
+ const componentImports = collectImports(importNodes)
548
+ .map(
549
+ ({ importName, importPath }) =>
550
+ `import { ${importName} } from "${importPath}";`,
551
+ )
552
+ .join("\n");
553
+ const reactImport = childrenPropName
554
+ ? `import type { ReactNode } from "react";`
555
+ : "";
556
+ const cssModuleImport = options.cssModulePath
557
+ ? `import styles from "./${options.cssModulePath}";`
558
+ : "";
559
+ const allImports = [reactImport, componentImports, cssModuleImport]
560
+ .filter(Boolean)
561
+ .join("\n");
562
+
563
+ const hasProps = propEntries.length > 0;
564
+ const interfaceBlock = hasProps
565
+ ? `interface ${funcName}Props {\n${interfaceLines.join("\n")}\n}\n\n`
566
+ : "";
567
+ const params =
568
+ destructured.length > 0
569
+ ? `{ ${destructured.join(", ")} }: ${funcName}Props`
570
+ : "";
571
+
572
+ return `${allImports ? `${allImports}\n\n` : ""}${interfaceBlock}export function ${funcName}(${params}) {\n\treturn (\n${body}\t);\n}\n`;
573
+ }
574
+
575
+ function emitComponentBody(
576
+ node: DesignNode,
577
+ source: DesignNode | undefined,
578
+ attributeBindings: Map<string, string>,
579
+ childrenPropName: string | undefined,
580
+ depth: number,
581
+ ): string {
582
+ const indent = "\t".repeat(depth);
583
+
584
+ if (!source) {
585
+ // Fall back to rendering the mapped children as a fragment when the
586
+ // original element was not captured.
587
+ const children = node.children ?? [];
588
+ return `${indent}<>\n${children
589
+ .map((child) => emitJsxNode(child, depth + 1))
590
+ .join("")}${indent}</>\n`;
591
+ }
592
+
593
+ const tagName = source.tagName ?? "div";
594
+ const attributes = emitJsxAttributes(
595
+ source.attributes ?? {},
596
+ source.styles ?? {},
597
+ source.generatedClassNames ?? [],
598
+ attributeBindings,
599
+ );
600
+ const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
601
+
602
+ if (childrenPropName) {
603
+ const inner = `${"\t".repeat(depth + 1)}{${childrenPropName}}\n`;
604
+ return `${indent}${openTag}\n${inner}${indent}</${tagName}>\n`;
605
+ }
606
+
607
+ const children = node.children ?? [];
608
+ if (children.length === 0) {
609
+ return `${indent}${attributes ? `<${tagName} ${attributes} />` : `<${tagName} />`}\n`;
610
+ }
611
+
612
+ return `${indent}${openTag}\n${children
613
+ .map((child) => emitJsxNode(child, depth + 1))
614
+ .join("")}${indent}</${tagName}>\n`;
615
+ }
616
+
617
+ /**
618
+ * Walks a mapped AST and returns the first component node seen for each
619
+ * component name, so the test generator can mount components with the same
620
+ * props the design supplies.
621
+ */
622
+ function collectComponentNodes(nodes: DesignNode[]): Map<string, DesignNode> {
623
+ const map = new Map<string, DesignNode>();
624
+ function visit(list: DesignNode[]): void {
625
+ for (const node of list) {
626
+ if (node.kind === "component") {
627
+ const name = node.component ?? node.importName;
628
+ if (name && !map.has(name)) {
629
+ map.set(name, node);
630
+ }
631
+ const childrenProp = node.props?.children;
632
+ visit(
633
+ childrenProp?.kind === "children"
634
+ ? childrenProp.value
635
+ : (node.children ?? []),
636
+ );
637
+ } else if (node.kind === "element") {
638
+ visit(node.children ?? []);
639
+ }
640
+ }
641
+ }
642
+ visit(nodes);
643
+ return map;
644
+ }
645
+
646
+ /**
647
+ * Emits the JSX used to mount a component in its visual test, forwarding the
648
+ * literal/attribute props as attributes and the text/children prop as the
649
+ * element body so the rendered component matches the source design.
650
+ */
651
+ function emitComponentMount(
652
+ componentName: string,
653
+ node: DesignNode | undefined,
654
+ ): string {
655
+ const attributeParts: string[] = [];
656
+ let childrenJsx = "";
657
+ for (const [propName, prop] of Object.entries(node?.props ?? {})) {
658
+ if (prop.kind === "text") {
659
+ childrenJsx = escapeJsxText(prop.value);
660
+ continue;
661
+ }
662
+ if (prop.kind === "children") {
663
+ childrenJsx = prop.value.map((child) => emitInlineJsx(child)).join("");
664
+ continue;
665
+ }
666
+ const attribute = emitProp(propName, prop);
667
+ if (attribute) {
668
+ attributeParts.push(attribute);
669
+ }
670
+ }
671
+ const attributes =
672
+ attributeParts.length > 0 ? ` ${attributeParts.join(" ")}` : "";
673
+ return childrenJsx
674
+ ? `<${componentName}${attributes}>${childrenJsx}</${componentName}>`
675
+ : `<${componentName}${attributes} />`;
676
+ }
677
+
678
+ /** Renders a node as single-line JSX for use inside a mount expression. */
679
+ function emitInlineJsx(node: DesignNode): string {
680
+ if (node.kind === "text") {
681
+ return escapeJsxText(node.text ?? "");
682
+ }
683
+ if (node.kind === "component") {
684
+ return emitComponentMount(
685
+ node.component ?? node.importName ?? "Component",
686
+ node,
687
+ );
688
+ }
689
+ const tagName = node.tagName ?? "div";
690
+ const attributes = emitJsxAttributes(
691
+ node.attributes ?? {},
692
+ node.styles ?? {},
693
+ node.generatedClassNames ?? [],
694
+ );
695
+ const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
696
+ const children = (node.children ?? [])
697
+ .map((child) => emitInlineJsx(child))
698
+ .join("");
699
+ return `${openTag}${children}</${tagName}>`;
700
+ }
701
+
702
+ function findNodeBySelector(
703
+ nodes: DesignNode[],
704
+ selector: string,
705
+ ): DesignNode | undefined {
706
+ const parsedSelector = parseSelector(selector);
707
+ if (!parsedSelector) return undefined;
708
+ const ps = parsedSelector;
709
+ function search(list: DesignNode[]): DesignNode | undefined {
710
+ for (const node of list) {
711
+ if (matchesSelector(node, ps)) return node;
712
+ const found = search(node.children ?? []);
713
+ if (found) return found;
714
+ }
715
+ return undefined;
716
+ }
717
+ return search(nodes);
718
+ }
719
+
720
+ const VOID_ELEMENTS = new Set([
721
+ "area",
722
+ "base",
723
+ "br",
724
+ "col",
725
+ "embed",
726
+ "hr",
727
+ "img",
728
+ "input",
729
+ "link",
730
+ "meta",
731
+ "param",
732
+ "source",
733
+ "track",
734
+ "wbr",
735
+ ]);
736
+
737
+ function serializeNodeToHtml(node: DesignNode): string {
738
+ if (node.kind === "text") return node.text ?? "";
739
+ if (node.kind !== "element") return "";
740
+ const tagName = node.tagName ?? "div";
741
+ const attrs = Object.entries(node.attributes ?? {})
742
+ .map(([name, value]) =>
743
+ value === "" ? name : `${name}="${value.replace(/"/g, "&quot;")}"`,
744
+ )
745
+ .join(" ");
746
+ const openTag = attrs ? `<${tagName} ${attrs}>` : `<${tagName}>`;
747
+ if (VOID_ELEMENTS.has(tagName)) return openTag;
748
+ const children = (node.children ?? []).map(serializeNodeToHtml).join("");
749
+ return `${openTag}${children}</${tagName}>`;
750
+ }
751
+
752
+ function toPascalCase(value: string): string {
753
+ return value
754
+ .split(/[-_\s]+/)
755
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
756
+ .join("");
757
+ }
758
+
229
759
  function toRelativeImport(fromFile: string, toFile: string): string {
230
760
  const fromParts = fromFile.split("/").slice(0, -1);
231
761
  const toParts = toFile.split("/");
@@ -259,7 +789,7 @@ export function emitReactView(
259
789
  : "";
260
790
  const allImports = [importLines, cssModuleImport].filter(Boolean).join("\n");
261
791
  const body =
262
- nodes.length === 1
792
+ nodes.length === 1 && nodes[0]?.kind !== "text"
263
793
  ? emitJsxNode(nodes[0], 2)
264
794
  : `${"\t".repeat(2)}<>\n${nodes.map((node) => emitJsxNode(node, 3)).join("")}${"\t".repeat(2)}</>\n`;
265
795
 
@@ -352,6 +882,106 @@ function transformStyles(
352
882
  return { nodes: resolvedNodes };
353
883
  }
354
884
 
885
+ interface ParsedSelector {
886
+ tagName?: string;
887
+ id?: string;
888
+ classes: string[];
889
+ attributes: Record<string, string>;
890
+ }
891
+
892
+ function parseInlineStyle(style: string | undefined): Record<string, string> {
893
+ const styles: Record<string, string> = {};
894
+ if (!style) {
895
+ return styles;
896
+ }
897
+ for (const declaration of style.split(";")) {
898
+ const [property, ...valueParts] = declaration.split(":");
899
+ const value = valueParts.join(":").trim();
900
+ if (!property?.trim() || !value) {
901
+ continue;
902
+ }
903
+ styles[property.trim().toLowerCase()] = value;
904
+ }
905
+ return styles;
906
+ }
907
+
908
+ function parseSelector(selector: string): ParsedSelector | undefined {
909
+ const trimmed = selector.trim();
910
+ if (!trimmed || /[\s>+~,:]/.test(trimmed)) {
911
+ return undefined;
912
+ }
913
+ const parsed: ParsedSelector = { classes: [], attributes: {} };
914
+ let rest = trimmed;
915
+ const tagMatch = rest.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
916
+ if (tagMatch?.[0]) {
917
+ parsed.tagName = tagMatch[0].toLowerCase();
918
+ rest = rest.slice(tagMatch[0].length);
919
+ }
920
+ while (rest) {
921
+ if (rest.startsWith(".")) {
922
+ const match = rest.match(/^\.([a-zA-Z_][a-zA-Z0-9_-]*)/);
923
+ if (!match?.[1]) {
924
+ return undefined;
925
+ }
926
+ parsed.classes.push(match[1]);
927
+ rest = rest.slice(match[0].length);
928
+ continue;
929
+ }
930
+ if (rest.startsWith("#")) {
931
+ const match = rest.match(/^#([a-zA-Z_][a-zA-Z0-9_-]*)/);
932
+ if (!match?.[1] || parsed.id) {
933
+ return undefined;
934
+ }
935
+ parsed.id = match[1];
936
+ rest = rest.slice(match[0].length);
937
+ continue;
938
+ }
939
+ if (rest.startsWith("[")) {
940
+ const match = rest.match(
941
+ /^\[([a-zA-Z_][a-zA-Z0-9_.:-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]+)))?\]/,
942
+ );
943
+ if (!match?.[1]) {
944
+ return undefined;
945
+ }
946
+ parsed.attributes[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
947
+ rest = rest.slice(match[0].length);
948
+ continue;
949
+ }
950
+ return undefined;
951
+ }
952
+ return parsed;
953
+ }
954
+
955
+ function matchesSelector(node: DesignNode, selector: ParsedSelector): boolean {
956
+ if (node.kind !== "element") {
957
+ return false;
958
+ }
959
+ const attributes = node.attributes ?? {};
960
+ if (selector.tagName && node.tagName !== selector.tagName) {
961
+ return false;
962
+ }
963
+ if (selector.id && attributes.id !== selector.id) {
964
+ return false;
965
+ }
966
+ const classNames = new Set(
967
+ (attributes.class ?? "").split(/\s+/).filter(Boolean),
968
+ );
969
+ for (const className of selector.classes) {
970
+ if (!classNames.has(className)) {
971
+ return false;
972
+ }
973
+ }
974
+ for (const [name, value] of Object.entries(selector.attributes)) {
975
+ if (!(name in attributes)) {
976
+ return false;
977
+ }
978
+ if (value !== "" && attributes[name] !== value) {
979
+ return false;
980
+ }
981
+ }
982
+ return true;
983
+ }
984
+
355
985
  function parseCssRules(
356
986
  css: string | undefined,
357
987
  diagnostics: Diagnostic[],
@@ -903,6 +1533,7 @@ function emitJsxAttributes(
903
1533
  attributes: Record<string, string>,
904
1534
  styles: Record<string, string>,
905
1535
  generatedClassNames: string[] = [],
1536
+ attributeBindings: Map<string, string> = new Map(),
906
1537
  ): string {
907
1538
  const mergedAttributes = { ...attributes };
908
1539
  const classNames = [
@@ -918,6 +1549,10 @@ function emitJsxAttributes(
918
1549
  .sort(([left], [right]) => left.localeCompare(right))
919
1550
  .map(([name, value]) => {
920
1551
  const jsxName = toJsxAttributeName(name);
1552
+ const binding = attributeBindings.get(name);
1553
+ if (binding) {
1554
+ return `${jsxName}={${binding}}`;
1555
+ }
921
1556
  if (value === "") {
922
1557
  return jsxName;
923
1558
  }