@design-embed/target-react 0.1.0 → 1.0.1
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/LICENSE +1 -1
- package/README.md +66 -1
- package/dist/design-embed/src/core/diagnostics/diagnostic.d.mts +16 -0
- package/dist/design-embed/src/core/nodes.d.mts +63 -0
- package/dist/design-embed/src/core/plugins/pluginApi.d.mts +41 -0
- package/dist/design-embed/src/core/types.d.mts +98 -0
- package/dist/index.d.mts +20 -0
- package/dist/index.mjs +964 -0
- package/dist/index.test.d.mts +1 -0
- package/dist/index.test.mjs +75 -0
- package/package.json +12 -10
- package/src/index.ts +699 -64
- package/dist/index.js +0 -744
- package/node_modules/@design-embed/config/README.md +0 -5
- package/node_modules/@design-embed/config/dist/index.js +0 -283
- package/node_modules/@design-embed/config/package.json +0 -19
- package/node_modules/@design-embed/config/src/index.ts +0 -518
- package/node_modules/@design-embed/core/README.md +0 -5
- package/node_modules/@design-embed/core/dist/diagnostics/diagnostic.js +0 -3
- package/node_modules/@design-embed/core/dist/diagnostics/jsonDiagnostic.js +0 -35
- package/node_modules/@design-embed/core/dist/index.js +0 -351
- package/node_modules/@design-embed/core/dist/pipeline/checkMode.js +0 -29
- package/node_modules/@design-embed/core/dist/plugins/pluginApi.js +0 -1
- package/node_modules/@design-embed/core/dist/plugins/pluginRegistry.js +0 -25
- package/node_modules/@design-embed/core/package.json +0 -19
- package/node_modules/@design-embed/core/src/diagnostics/diagnostic.ts +0 -18
- package/node_modules/@design-embed/core/src/diagnostics/jsonDiagnostic.ts +0 -51
- package/node_modules/@design-embed/core/src/index.ts +0 -591
- package/node_modules/@design-embed/core/src/pipeline/checkMode.ts +0 -46
- package/node_modules/@design-embed/core/src/plugins/pluginApi.ts +0 -78
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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\
|
|
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, """)}"`,
|
|
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
|
}
|