@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/dist/index.mjs
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
import { applyComponentMappings, parseHtml } from "design-embed";
|
|
2
|
+
//#region packages/target-react/src/index.ts
|
|
3
|
+
var ReactTarget = class {
|
|
4
|
+
emit({ nodes, css, config, diagnostics }) {
|
|
5
|
+
const viewsDir = String(config?.output?.viewsDir ?? "src/generated/views");
|
|
6
|
+
const viewName = config?.output?.viewName ?? "DesignView";
|
|
7
|
+
const styleResult = transformStyles(nodes, css, config, diagnostics);
|
|
8
|
+
const contents = emitReactView(styleResult.nodes, viewName, { cssModulePath: styleResult.cssModulePath });
|
|
9
|
+
const files = [{
|
|
10
|
+
path: `${viewsDir}/${viewName}.view.tsx`,
|
|
11
|
+
contents
|
|
12
|
+
}];
|
|
13
|
+
if (styleResult.cssModule && styleResult.cssModulePath) files.push({
|
|
14
|
+
path: `${viewsDir}/${styleResult.cssModulePath}`,
|
|
15
|
+
contents: styleResult.cssModule
|
|
16
|
+
});
|
|
17
|
+
for (const split of emitComponentSplitViews(styleResult.nodes, viewsDir, styleResult.cssModulePath)) files.push(split);
|
|
18
|
+
return { files };
|
|
19
|
+
}
|
|
20
|
+
generateTests(input) {
|
|
21
|
+
return reactTestGenerator.generateTests(input);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const reactTestGenerator = { generateTests({ html, css, config }) {
|
|
25
|
+
const diagnostics = [];
|
|
26
|
+
const tests = config.tests;
|
|
27
|
+
if (tests?.runner && tests.runner !== "playwright") {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
code: "TEST_RUNNER_UNSUPPORTED",
|
|
30
|
+
message: `Unsupported test runner: ${tests.runner}`,
|
|
31
|
+
severity: "error"
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
files: [],
|
|
35
|
+
diagnostics
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const viewsDir = String(config.output?.viewsDir ?? "src/generated/views");
|
|
39
|
+
const viewName = config.output?.viewName ?? "DesignView";
|
|
40
|
+
const outputDir = tests?.outputDir ?? `${viewsDir}/tests`;
|
|
41
|
+
const fixturePath = `${outputDir}/${viewName}.reference.html`;
|
|
42
|
+
const specPath = `${outputDir}/${viewName}.visual.spec.tsx`;
|
|
43
|
+
const referenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${html}`;
|
|
44
|
+
const assertionDefaults = {
|
|
45
|
+
screenshot: tests?.assertions?.screenshot ?? true,
|
|
46
|
+
layout: tests?.assertions?.layout ?? true,
|
|
47
|
+
layoutTolerance: tests?.assertions?.layoutTolerance ?? 1,
|
|
48
|
+
selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
|
|
49
|
+
screenshotThreshold: tests?.assertions?.screenshotThreshold ?? .2,
|
|
50
|
+
screenshotMaxDiffPixels: tests?.assertions?.screenshotMaxDiffPixels ?? 500
|
|
51
|
+
};
|
|
52
|
+
const viewportDefaults = tests?.viewports ?? [{
|
|
53
|
+
name: "default",
|
|
54
|
+
width: 1440,
|
|
55
|
+
height: 900
|
|
56
|
+
}];
|
|
57
|
+
const stateDefaults = tests?.states ?? [{ name: "default" }];
|
|
58
|
+
const referenceHtmlFileName = `${viewName}.reference.html`;
|
|
59
|
+
const files = [{
|
|
60
|
+
path: fixturePath,
|
|
61
|
+
contents: referenceHtml.endsWith("\n") ? referenceHtml : `${referenceHtml}\n`
|
|
62
|
+
}, {
|
|
63
|
+
path: specPath,
|
|
64
|
+
contents: emitReactVisualSpec({
|
|
65
|
+
viewName,
|
|
66
|
+
viewImportPath: toRelativeImport(specPath, `${viewsDir}/${viewName}.view`),
|
|
67
|
+
fixtureFileName: referenceHtmlFileName,
|
|
68
|
+
viewports: viewportDefaults,
|
|
69
|
+
states: stateDefaults,
|
|
70
|
+
assertions: assertionDefaults
|
|
71
|
+
})
|
|
72
|
+
}];
|
|
73
|
+
const parsedHtmlNodes = parseHtml(html);
|
|
74
|
+
const componentNodes = collectComponentNodes(applyComponentMappings(parsedHtmlNodes, config.components ?? []));
|
|
75
|
+
for (const mapping of config.components ?? []) {
|
|
76
|
+
const componentName = mapping.component;
|
|
77
|
+
const componentSpecPath = `${outputDir}/${componentName}.visual.spec.tsx`;
|
|
78
|
+
const componentReferenceHtmlFileName = `${componentName}.reference.html`;
|
|
79
|
+
const componentFixturePath = `${outputDir}/${componentReferenceHtmlFileName}`;
|
|
80
|
+
const matchingNode = findNodeBySelector(parsedHtmlNodes, mapping.selector);
|
|
81
|
+
const elementHtml = matchingNode ? serializeNodeToHtml(matchingNode) : "";
|
|
82
|
+
const componentReferenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${elementHtml}`;
|
|
83
|
+
const mountNode = componentNodes.get(componentName);
|
|
84
|
+
files.push({
|
|
85
|
+
path: componentFixturePath,
|
|
86
|
+
contents: componentReferenceHtml.endsWith("\n") ? componentReferenceHtml : `${componentReferenceHtml}\n`
|
|
87
|
+
});
|
|
88
|
+
files.push({
|
|
89
|
+
path: componentSpecPath,
|
|
90
|
+
contents: emitComponentVisualSpec({
|
|
91
|
+
componentName,
|
|
92
|
+
selector: mapping.selector,
|
|
93
|
+
mountJsx: emitComponentMount(componentName, mountNode),
|
|
94
|
+
componentImportPath: toRelativeImport(componentSpecPath, `${viewsDir}/${componentName}.view`),
|
|
95
|
+
referenceHtmlFileName: componentReferenceHtmlFileName,
|
|
96
|
+
viewports: viewportDefaults,
|
|
97
|
+
states: stateDefaults,
|
|
98
|
+
assertions: assertionDefaults
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
diagnostics,
|
|
104
|
+
files
|
|
105
|
+
};
|
|
106
|
+
} };
|
|
107
|
+
function emitReactVisualSpec(input) {
|
|
108
|
+
const viewports = JSON.stringify(input.viewports, null, 2);
|
|
109
|
+
const states = JSON.stringify(input.states, null, 2);
|
|
110
|
+
const selectors = JSON.stringify(input.assertions.selectors, null, 2);
|
|
111
|
+
const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
|
|
112
|
+
const layoutEnabled = JSON.stringify(input.assertions.layout);
|
|
113
|
+
const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
|
|
114
|
+
const screenshotThreshold = JSON.stringify(input.assertions.screenshotThreshold);
|
|
115
|
+
const screenshotMaxDiffPixels = JSON.stringify(input.assertions.screenshotMaxDiffPixels);
|
|
116
|
+
return `import { readFileSync } from "node:fs";
|
|
117
|
+
import { dirname, resolve } from "node:path";
|
|
118
|
+
import { fileURLToPath } from "node:url";
|
|
119
|
+
import { expect, test } from "@playwright/experimental-ct-react";
|
|
120
|
+
import pixelmatch from "pixelmatch";
|
|
121
|
+
import { PNG } from "pngjs";
|
|
122
|
+
import { ${input.viewName} } from "${input.viewImportPath}";
|
|
123
|
+
|
|
124
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
125
|
+
const referenceHtml = readFileSync(resolve(currentDir, "./${input.fixtureFileName}"), "utf-8");
|
|
126
|
+
const viewports = ${viewports};
|
|
127
|
+
const states = ${states};
|
|
128
|
+
const selectors = ${selectors};
|
|
129
|
+
const screenshotEnabled = ${screenshotEnabled};
|
|
130
|
+
const layoutEnabled = ${layoutEnabled};
|
|
131
|
+
const layoutTolerance = ${layoutTolerance};
|
|
132
|
+
const screenshotThreshold = ${screenshotThreshold};
|
|
133
|
+
const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
|
|
134
|
+
|
|
135
|
+
for (const viewport of viewports) {
|
|
136
|
+
\tfor (const state of states) {
|
|
137
|
+
\t\tconst viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
|
|
138
|
+
\t\ttest("${input.viewName} matches source at " + viewportName + " / " + state.name, async ({ mount, page }) => {
|
|
139
|
+
\t\t\tawait page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
140
|
+
|
|
141
|
+
\t\t\tawait page.setContent(referenceHtml);
|
|
142
|
+
\t\t\tawait applyState(page, state);
|
|
143
|
+
\t\t\tconst expectedScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
|
|
144
|
+
\t\t\tconst expectedLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
|
|
145
|
+
|
|
146
|
+
\t\t\tawait page.setContent("");
|
|
147
|
+
\t\t\tconst component = await mount(<${input.viewName} />);
|
|
148
|
+
\t\t\tawait applyState(page, state);
|
|
149
|
+
\t\t\tconst actualScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
|
|
150
|
+
\t\t\tconst actualLayout = layoutEnabled ? await readLayout(component, selectors) : [];
|
|
151
|
+
|
|
152
|
+
\t\t\tif (screenshotEnabled) {
|
|
153
|
+
\t\t\t\tcompareScreenshots(actualScreenshot, expectedScreenshot, screenshotThreshold, screenshotMaxDiffPixels);
|
|
154
|
+
\t\t\t}
|
|
155
|
+
\t\t\tif (layoutEnabled) {
|
|
156
|
+
\t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
|
|
157
|
+
\t\t\t}
|
|
158
|
+
\t\t});
|
|
159
|
+
\t}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function applyState(page, state) {
|
|
163
|
+
\tif (state.waitFor) {
|
|
164
|
+
\t\tawait page.waitForSelector(state.waitFor);
|
|
165
|
+
\t}
|
|
166
|
+
\tif (state.hover) {
|
|
167
|
+
\t\tawait page.hover(state.hover);
|
|
168
|
+
\t}
|
|
169
|
+
\tif (state.focus) {
|
|
170
|
+
\t\tawait page.focus(state.focus);
|
|
171
|
+
\t}
|
|
172
|
+
\tif (state.click) {
|
|
173
|
+
\t\tawait page.click(state.click);
|
|
174
|
+
\t}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readLayout(root, selectorsToRead) {
|
|
178
|
+
\treturn root.evaluate((element, values) => {
|
|
179
|
+
\t\tconst origin = element.getBoundingClientRect();
|
|
180
|
+
\t\treturn values.flatMap((selector) => {
|
|
181
|
+
\t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
|
|
182
|
+
\t\t\treturn matches.map((matchedElement, index) => {
|
|
183
|
+
\t\t\t\tconst rect = matchedElement.getBoundingClientRect();
|
|
184
|
+
\t\t\t\treturn {
|
|
185
|
+
\t\t\t\t\tselector,
|
|
186
|
+
\t\t\t\t\tindex,
|
|
187
|
+
\t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
|
|
188
|
+
\t\t\t\t\tx: rect.x - origin.x,
|
|
189
|
+
\t\t\t\t\ty: rect.y - origin.y,
|
|
190
|
+
\t\t\t\t\twidth: rect.width,
|
|
191
|
+
\t\t\t\t\theight: rect.height,
|
|
192
|
+
\t\t\t\t};
|
|
193
|
+
\t\t\t});
|
|
194
|
+
\t\t});
|
|
195
|
+
\t}, selectorsToRead);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function compareScreenshots(actual, expected, threshold, maxDiffPixels) {
|
|
199
|
+
\tif (!actual || !expected) {
|
|
200
|
+
\t\texpect(actual).toEqual(expected);
|
|
201
|
+
\t\treturn;
|
|
202
|
+
\t}
|
|
203
|
+
\tconst actualPng = PNG.sync.read(actual);
|
|
204
|
+
\tconst expectedPng = PNG.sync.read(expected);
|
|
205
|
+
\texpect(actualPng.width, "screenshot width").toBe(expectedPng.width);
|
|
206
|
+
\texpect(actualPng.height, "screenshot height").toBe(expectedPng.height);
|
|
207
|
+
\tconst diffPixels = pixelmatch(actualPng.data, expectedPng.data, null, actualPng.width, actualPng.height, { threshold });
|
|
208
|
+
\texpect(diffPixels, "screenshot pixels differing beyond threshold").toBeLessThanOrEqual(maxDiffPixels);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function expectLayoutToMatch(actual, expected, tolerance) {
|
|
212
|
+
\texpect(actual.length).toBe(expected.length);
|
|
213
|
+
\tfor (let index = 0; index < expected.length; index += 1) {
|
|
214
|
+
\t\tconst actualRect = actual[index];
|
|
215
|
+
\t\tconst expectedRect = expected[index];
|
|
216
|
+
\t\texpect(actualRect.selector).toBe(expectedRect.selector);
|
|
217
|
+
\t\texpect(actualRect.index).toBe(expectedRect.index);
|
|
218
|
+
\t\texpect(actualRect.tagName).toBe(expectedRect.tagName);
|
|
219
|
+
\t\tfor (const key of ["x", "y", "width", "height"]) {
|
|
220
|
+
\t\t\tconst drift = Math.abs(actualRect[key] - expectedRect[key]);
|
|
221
|
+
\t\t\texpect(drift, \`\${expectedRect.selector}[\${expectedRect.index}] \${key} drift\`).toBeLessThanOrEqual(tolerance);
|
|
222
|
+
\t\t}
|
|
223
|
+
\t}
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
function emitComponentVisualSpec(input) {
|
|
228
|
+
const viewports = JSON.stringify(input.viewports, null, 2);
|
|
229
|
+
const states = JSON.stringify(input.states, null, 2);
|
|
230
|
+
const selectors = JSON.stringify(input.assertions.selectors, null, 2);
|
|
231
|
+
const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
|
|
232
|
+
const layoutEnabled = JSON.stringify(input.assertions.layout);
|
|
233
|
+
const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
|
|
234
|
+
const screenshotThreshold = JSON.stringify(input.assertions.screenshotThreshold);
|
|
235
|
+
const screenshotMaxDiffPixels = JSON.stringify(input.assertions.screenshotMaxDiffPixels);
|
|
236
|
+
const selector = JSON.stringify(input.selector);
|
|
237
|
+
return `import { readFileSync } from "node:fs";
|
|
238
|
+
import { dirname, resolve } from "node:path";
|
|
239
|
+
import { fileURLToPath } from "node:url";
|
|
240
|
+
import { expect, test } from "@playwright/experimental-ct-react";
|
|
241
|
+
import pixelmatch from "pixelmatch";
|
|
242
|
+
import { PNG } from "pngjs";
|
|
243
|
+
import { ${input.componentName} } from "${input.componentImportPath}";
|
|
244
|
+
|
|
245
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
246
|
+
const referenceHtml = readFileSync(resolve(currentDir, "./${input.referenceHtmlFileName}"), "utf-8");
|
|
247
|
+
const selector = ${selector};
|
|
248
|
+
const viewports = ${viewports};
|
|
249
|
+
const states = ${states};
|
|
250
|
+
const selectors = ${selectors};
|
|
251
|
+
const screenshotEnabled = ${screenshotEnabled};
|
|
252
|
+
const layoutEnabled = ${layoutEnabled};
|
|
253
|
+
const layoutTolerance = ${layoutTolerance};
|
|
254
|
+
const screenshotThreshold = ${screenshotThreshold};
|
|
255
|
+
const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
|
|
256
|
+
|
|
257
|
+
for (const viewport of viewports) {
|
|
258
|
+
\tfor (const state of states) {
|
|
259
|
+
\t\tconst viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
|
|
260
|
+
\t\ttest("${input.componentName} matches source at " + viewportName + " / " + state.name, async ({ mount, page }) => {
|
|
261
|
+
\t\t\tawait page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
262
|
+
|
|
263
|
+
\t\t\tawait page.setContent(referenceHtml);
|
|
264
|
+
\t\t\tawait applyState(page, state);
|
|
265
|
+
\t\t\tconst expectedEl = page.locator(selector).first();
|
|
266
|
+
\t\t\tconst expectedScreenshot = screenshotEnabled ? await expectedEl.screenshot() : undefined;
|
|
267
|
+
\t\t\tconst expectedLayout = layoutEnabled ? await readLayout(expectedEl, selectors) : [];
|
|
268
|
+
|
|
269
|
+
\t\t\tconst component = await mount(${input.mountJsx});
|
|
270
|
+
\t\t\tawait applyState(page, state);
|
|
271
|
+
\t\t\tconst actualScreenshot = screenshotEnabled ? await component.screenshot() : undefined;
|
|
272
|
+
\t\t\tconst actualLayout = layoutEnabled ? await readLayout(component, selectors) : [];
|
|
273
|
+
|
|
274
|
+
\t\t\tif (screenshotEnabled) {
|
|
275
|
+
\t\t\t\tcompareScreenshots(actualScreenshot, expectedScreenshot, screenshotThreshold, screenshotMaxDiffPixels);
|
|
276
|
+
\t\t\t}
|
|
277
|
+
\t\t\tif (layoutEnabled) {
|
|
278
|
+
\t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
|
|
279
|
+
\t\t\t}
|
|
280
|
+
\t\t});
|
|
281
|
+
\t}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function applyState(page, state) {
|
|
285
|
+
\tif (state.waitFor) {
|
|
286
|
+
\t\tawait page.waitForSelector(state.waitFor);
|
|
287
|
+
\t}
|
|
288
|
+
\tif (state.hover) {
|
|
289
|
+
\t\tawait page.hover(state.hover);
|
|
290
|
+
\t}
|
|
291
|
+
\tif (state.focus) {
|
|
292
|
+
\t\tawait page.focus(state.focus);
|
|
293
|
+
\t}
|
|
294
|
+
\tif (state.click) {
|
|
295
|
+
\t\tawait page.click(state.click);
|
|
296
|
+
\t}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function readLayout(root, selectorsToRead) {
|
|
300
|
+
\treturn root.evaluate((element, values) => {
|
|
301
|
+
\t\tconst origin = element.getBoundingClientRect();
|
|
302
|
+
\t\treturn values.flatMap((selector) => {
|
|
303
|
+
\t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
|
|
304
|
+
\t\t\treturn matches.map((matchedElement, index) => {
|
|
305
|
+
\t\t\t\tconst rect = matchedElement.getBoundingClientRect();
|
|
306
|
+
\t\t\t\treturn {
|
|
307
|
+
\t\t\t\t\tselector,
|
|
308
|
+
\t\t\t\t\tindex,
|
|
309
|
+
\t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
|
|
310
|
+
\t\t\t\t\tx: rect.x - origin.x,
|
|
311
|
+
\t\t\t\t\ty: rect.y - origin.y,
|
|
312
|
+
\t\t\t\t\twidth: rect.width,
|
|
313
|
+
\t\t\t\t\theight: rect.height,
|
|
314
|
+
\t\t\t\t};
|
|
315
|
+
\t\t\t});
|
|
316
|
+
\t\t});
|
|
317
|
+
\t}, selectorsToRead);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function compareScreenshots(actual, expected, threshold, maxDiffPixels) {
|
|
321
|
+
\tif (!actual || !expected) {
|
|
322
|
+
\t\texpect(actual).toEqual(expected);
|
|
323
|
+
\t\treturn;
|
|
324
|
+
\t}
|
|
325
|
+
\tconst actualPng = PNG.sync.read(actual);
|
|
326
|
+
\tconst expectedPng = PNG.sync.read(expected);
|
|
327
|
+
\texpect(actualPng.width, "screenshot width").toBe(expectedPng.width);
|
|
328
|
+
\texpect(actualPng.height, "screenshot height").toBe(expectedPng.height);
|
|
329
|
+
\tconst diffPixels = pixelmatch(actualPng.data, expectedPng.data, null, actualPng.width, actualPng.height, { threshold });
|
|
330
|
+
\texpect(diffPixels, "screenshot pixels differing beyond threshold").toBeLessThanOrEqual(maxDiffPixels);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function expectLayoutToMatch(actual, expected, tolerance) {
|
|
334
|
+
\texpect(actual.length).toBe(expected.length);
|
|
335
|
+
\tfor (let index = 0; index < expected.length; index += 1) {
|
|
336
|
+
\t\tconst actualRect = actual[index];
|
|
337
|
+
\t\tconst expectedRect = expected[index];
|
|
338
|
+
\t\texpect(actualRect.selector).toBe(expectedRect.selector);
|
|
339
|
+
\t\texpect(actualRect.index).toBe(expectedRect.index);
|
|
340
|
+
\t\texpect(actualRect.tagName).toBe(expectedRect.tagName);
|
|
341
|
+
\t\tfor (const key of ["x", "y", "width", "height"]) {
|
|
342
|
+
\t\t\tconst drift = Math.abs(actualRect[key] - expectedRect[key]);
|
|
343
|
+
\t\t\texpect(drift, \`\${expectedRect.selector}[\${expectedRect.index}] \${key} drift\`).toBeLessThanOrEqual(tolerance);
|
|
344
|
+
\t\t}
|
|
345
|
+
\t}
|
|
346
|
+
}
|
|
347
|
+
`;
|
|
348
|
+
}
|
|
349
|
+
function emitComponentSplitViews(nodes, viewsDir, cssModulePath) {
|
|
350
|
+
const seen = /* @__PURE__ */ new Set();
|
|
351
|
+
const files = [];
|
|
352
|
+
function visit(node) {
|
|
353
|
+
if (node.kind === "component") {
|
|
354
|
+
const importName = node.importName ?? node.component ?? "";
|
|
355
|
+
const childrenProp = node.props?.children;
|
|
356
|
+
const innerChildren = childrenProp?.kind === "children" ? childrenProp.value : node.children ?? [];
|
|
357
|
+
if (importName && !seen.has(importName)) {
|
|
358
|
+
seen.add(importName);
|
|
359
|
+
const funcName = toPascalCase(importName);
|
|
360
|
+
files.push({
|
|
361
|
+
path: `${viewsDir}/${importName}.view.tsx`,
|
|
362
|
+
contents: emitComponentView(node, funcName, { cssModulePath })
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
for (const child of innerChildren) visit(child);
|
|
366
|
+
} else if (node.kind === "element") for (const child of node.children ?? []) visit(child);
|
|
367
|
+
}
|
|
368
|
+
for (const node of nodes) visit(node);
|
|
369
|
+
return files;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Emits the implementation of a mapped component. The component reconstructs
|
|
373
|
+
* the original element (tag, attributes, styles) captured in `sourceElement`,
|
|
374
|
+
* exposes the mapping's props as a typed interface, and wires `children` and
|
|
375
|
+
* `$attr.*` props into the rendered element.
|
|
376
|
+
*/
|
|
377
|
+
function emitComponentView(node, funcName, options = {}) {
|
|
378
|
+
const props = node.props ?? {};
|
|
379
|
+
const source = node.sourceElement;
|
|
380
|
+
const propEntries = Object.entries(props);
|
|
381
|
+
const attributeBindings = /* @__PURE__ */ new Map();
|
|
382
|
+
const interfaceLines = [];
|
|
383
|
+
const destructured = [];
|
|
384
|
+
let childrenPropName;
|
|
385
|
+
for (const [propName, prop] of propEntries) {
|
|
386
|
+
if (prop.kind === "text" || prop.kind === "children") {
|
|
387
|
+
childrenPropName = propName;
|
|
388
|
+
interfaceLines.push(`\t${propName}?: ReactNode;`);
|
|
389
|
+
destructured.push(propName);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
interfaceLines.push(`\t${propName}?: string;`);
|
|
393
|
+
if (prop.kind === "literal" && prop.attribute) {
|
|
394
|
+
attributeBindings.set(prop.attribute, propName);
|
|
395
|
+
destructured.push(propName);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const body = emitComponentBody(node, source, attributeBindings, childrenPropName, 2);
|
|
399
|
+
const componentImports = collectImports(childrenPropName ? [] : node.children ?? []).map(({ importName, importPath }) => `import { ${importName} } from "${importPath}";`).join("\n");
|
|
400
|
+
const allImports = [
|
|
401
|
+
childrenPropName ? `import type { ReactNode } from "react";` : "",
|
|
402
|
+
componentImports,
|
|
403
|
+
options.cssModulePath ? `import styles from "./${options.cssModulePath}";` : ""
|
|
404
|
+
].filter(Boolean).join("\n");
|
|
405
|
+
const interfaceBlock = propEntries.length > 0 ? `interface ${funcName}Props {\n${interfaceLines.join("\n")}\n}\n\n` : "";
|
|
406
|
+
const params = destructured.length > 0 ? `{ ${destructured.join(", ")} }: ${funcName}Props` : "";
|
|
407
|
+
return `${allImports ? `${allImports}\n\n` : ""}${interfaceBlock}export function ${funcName}(${params}) {\n\treturn (\n${body}\t);\n}\n`;
|
|
408
|
+
}
|
|
409
|
+
function emitComponentBody(node, source, attributeBindings, childrenPropName, depth) {
|
|
410
|
+
const indent = " ".repeat(depth);
|
|
411
|
+
if (!source) return `${indent}<>\n${(node.children ?? []).map((child) => emitJsxNode(child, depth + 1)).join("")}${indent}</>\n`;
|
|
412
|
+
const tagName = source.tagName ?? "div";
|
|
413
|
+
const attributes = emitJsxAttributes(source.attributes ?? {}, source.styles ?? {}, source.generatedClassNames ?? [], attributeBindings);
|
|
414
|
+
const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
|
|
415
|
+
if (childrenPropName) return `${indent}${openTag}\n${`${" ".repeat(depth + 1)}{${childrenPropName}}\n`}${indent}</${tagName}>\n`;
|
|
416
|
+
const children = node.children ?? [];
|
|
417
|
+
if (children.length === 0) return `${indent}${attributes ? `<${tagName} ${attributes} />` : `<${tagName} />`}\n`;
|
|
418
|
+
return `${indent}${openTag}\n${children.map((child) => emitJsxNode(child, depth + 1)).join("")}${indent}</${tagName}>\n`;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Walks a mapped AST and returns the first component node seen for each
|
|
422
|
+
* component name, so the test generator can mount components with the same
|
|
423
|
+
* props the design supplies.
|
|
424
|
+
*/
|
|
425
|
+
function collectComponentNodes(nodes) {
|
|
426
|
+
const map = /* @__PURE__ */ new Map();
|
|
427
|
+
function visit(list) {
|
|
428
|
+
for (const node of list) if (node.kind === "component") {
|
|
429
|
+
const name = node.component ?? node.importName;
|
|
430
|
+
if (name && !map.has(name)) map.set(name, node);
|
|
431
|
+
const childrenProp = node.props?.children;
|
|
432
|
+
visit(childrenProp?.kind === "children" ? childrenProp.value : node.children ?? []);
|
|
433
|
+
} else if (node.kind === "element") visit(node.children ?? []);
|
|
434
|
+
}
|
|
435
|
+
visit(nodes);
|
|
436
|
+
return map;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Emits the JSX used to mount a component in its visual test, forwarding the
|
|
440
|
+
* literal/attribute props as attributes and the text/children prop as the
|
|
441
|
+
* element body so the rendered component matches the source design.
|
|
442
|
+
*/
|
|
443
|
+
function emitComponentMount(componentName, node) {
|
|
444
|
+
const attributeParts = [];
|
|
445
|
+
let childrenJsx = "";
|
|
446
|
+
for (const [propName, prop] of Object.entries(node?.props ?? {})) {
|
|
447
|
+
if (prop.kind === "text") {
|
|
448
|
+
childrenJsx = escapeJsxText(prop.value);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (prop.kind === "children") {
|
|
452
|
+
childrenJsx = prop.value.map((child) => emitInlineJsx(child)).join("");
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const attribute = emitProp(propName, prop);
|
|
456
|
+
if (attribute) attributeParts.push(attribute);
|
|
457
|
+
}
|
|
458
|
+
const attributes = attributeParts.length > 0 ? ` ${attributeParts.join(" ")}` : "";
|
|
459
|
+
return childrenJsx ? `<${componentName}${attributes}>${childrenJsx}</${componentName}>` : `<${componentName}${attributes} />`;
|
|
460
|
+
}
|
|
461
|
+
/** Renders a node as single-line JSX for use inside a mount expression. */
|
|
462
|
+
function emitInlineJsx(node) {
|
|
463
|
+
if (node.kind === "text") return escapeJsxText(node.text ?? "");
|
|
464
|
+
if (node.kind === "component") return emitComponentMount(node.component ?? node.importName ?? "Component", node);
|
|
465
|
+
const tagName = node.tagName ?? "div";
|
|
466
|
+
const attributes = emitJsxAttributes(node.attributes ?? {}, node.styles ?? {}, node.generatedClassNames ?? []);
|
|
467
|
+
return `${attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`}${(node.children ?? []).map((child) => emitInlineJsx(child)).join("")}</${tagName}>`;
|
|
468
|
+
}
|
|
469
|
+
function findNodeBySelector(nodes, selector) {
|
|
470
|
+
const parsedSelector = parseSelector(selector);
|
|
471
|
+
if (!parsedSelector) return void 0;
|
|
472
|
+
const ps = parsedSelector;
|
|
473
|
+
function search(list) {
|
|
474
|
+
for (const node of list) {
|
|
475
|
+
if (matchesSelector(node, ps)) return node;
|
|
476
|
+
const found = search(node.children ?? []);
|
|
477
|
+
if (found) return found;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return search(nodes);
|
|
481
|
+
}
|
|
482
|
+
const VOID_ELEMENTS = new Set([
|
|
483
|
+
"area",
|
|
484
|
+
"base",
|
|
485
|
+
"br",
|
|
486
|
+
"col",
|
|
487
|
+
"embed",
|
|
488
|
+
"hr",
|
|
489
|
+
"img",
|
|
490
|
+
"input",
|
|
491
|
+
"link",
|
|
492
|
+
"meta",
|
|
493
|
+
"param",
|
|
494
|
+
"source",
|
|
495
|
+
"track",
|
|
496
|
+
"wbr"
|
|
497
|
+
]);
|
|
498
|
+
function serializeNodeToHtml(node) {
|
|
499
|
+
if (node.kind === "text") return node.text ?? "";
|
|
500
|
+
if (node.kind !== "element") return "";
|
|
501
|
+
const tagName = node.tagName ?? "div";
|
|
502
|
+
const attrs = Object.entries(node.attributes ?? {}).map(([name, value]) => value === "" ? name : `${name}="${value.replace(/"/g, """)}"`).join(" ");
|
|
503
|
+
const openTag = attrs ? `<${tagName} ${attrs}>` : `<${tagName}>`;
|
|
504
|
+
if (VOID_ELEMENTS.has(tagName)) return openTag;
|
|
505
|
+
return `${openTag}${(node.children ?? []).map(serializeNodeToHtml).join("")}</${tagName}>`;
|
|
506
|
+
}
|
|
507
|
+
function toPascalCase(value) {
|
|
508
|
+
return value.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
509
|
+
}
|
|
510
|
+
function toRelativeImport(fromFile, toFile) {
|
|
511
|
+
const fromParts = fromFile.split("/").slice(0, -1);
|
|
512
|
+
const toParts = toFile.split("/");
|
|
513
|
+
while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
|
|
514
|
+
fromParts.shift();
|
|
515
|
+
toParts.shift();
|
|
516
|
+
}
|
|
517
|
+
const relative = [...fromParts.map(() => ".."), ...toParts].join("/");
|
|
518
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
519
|
+
}
|
|
520
|
+
function emitReactView(nodes, viewName, options = {}) {
|
|
521
|
+
const allImports = [collectImports(nodes).map(({ importName, importPath }) => `import { ${importName} } from "${importPath}";`).join("\n"), options.cssModulePath ? `import styles from "./${options.cssModulePath}";` : ""].filter(Boolean).join("\n");
|
|
522
|
+
const body = nodes.length === 1 && nodes[0]?.kind !== "text" ? emitJsxNode(nodes[0], 2) : `${" ".repeat(2)}<>\n${nodes.map((node) => emitJsxNode(node, 3)).join("")}${" ".repeat(2)}</>\n`;
|
|
523
|
+
return `${allImports ? `${allImports}\n\n` : ""}export function ${viewName}() {\n\treturn (\n${body}\t);\n}\n`;
|
|
524
|
+
}
|
|
525
|
+
function transformStyles(nodes, css, config, diagnostics) {
|
|
526
|
+
const styleMode = config?.output?.styleMode ?? "inline";
|
|
527
|
+
const resolvedNodes = resolveCssStyles(nodes, parseCssRules(css, diagnostics));
|
|
528
|
+
if (styleMode === "inline") return { nodes: mapStyleNodes(resolvedNodes, (node) => ({
|
|
529
|
+
...node,
|
|
530
|
+
styles: snapStyleValues(node.styles ?? {}, config, diagnostics, node)
|
|
531
|
+
})) };
|
|
532
|
+
if (styleMode === "tailwind") return { nodes: mapStyleNodes(resolvedNodes, (node) => applyTailwindStyles(node, config, diagnostics)) };
|
|
533
|
+
if (styleMode === "css-modules") {
|
|
534
|
+
const rules = [];
|
|
535
|
+
let index = 0;
|
|
536
|
+
const moduleNodes = mapStyleNodes(resolvedNodes, (node) => {
|
|
537
|
+
const snapped = snapStyleValues(node.styles ?? {}, config, diagnostics, node);
|
|
538
|
+
if (Object.keys(snapped).length === 0) return {
|
|
539
|
+
...node,
|
|
540
|
+
styles: snapped
|
|
541
|
+
};
|
|
542
|
+
index += 1;
|
|
543
|
+
const className = `style${index}`;
|
|
544
|
+
rules.push(emitCssModuleRule(className, snapped));
|
|
545
|
+
return {
|
|
546
|
+
...node,
|
|
547
|
+
styles: {},
|
|
548
|
+
generatedClassNames: [...node.generatedClassNames ?? [], `module:${className}`]
|
|
549
|
+
};
|
|
550
|
+
});
|
|
551
|
+
const viewName = config?.output?.viewName ?? "DesignView";
|
|
552
|
+
return {
|
|
553
|
+
nodes: moduleNodes,
|
|
554
|
+
cssModule: rules.length > 0 ? `${rules.join("\n\n")}\n` : void 0,
|
|
555
|
+
cssModulePath: rules.length > 0 ? `${viewName}.module.css` : void 0
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
diagnostics.push({
|
|
559
|
+
code: "STYLE_MODE_UNSUPPORTED",
|
|
560
|
+
message: `Unsupported style mode: ${styleMode}`,
|
|
561
|
+
severity: "error"
|
|
562
|
+
});
|
|
563
|
+
return { nodes: resolvedNodes };
|
|
564
|
+
}
|
|
565
|
+
function parseInlineStyle(style) {
|
|
566
|
+
const styles = {};
|
|
567
|
+
if (!style) return styles;
|
|
568
|
+
for (const declaration of style.split(";")) {
|
|
569
|
+
const [property, ...valueParts] = declaration.split(":");
|
|
570
|
+
const value = valueParts.join(":").trim();
|
|
571
|
+
if (!property?.trim() || !value) continue;
|
|
572
|
+
styles[property.trim().toLowerCase()] = value;
|
|
573
|
+
}
|
|
574
|
+
return styles;
|
|
575
|
+
}
|
|
576
|
+
function parseSelector(selector) {
|
|
577
|
+
const trimmed = selector.trim();
|
|
578
|
+
if (!trimmed || /[\s>+~,:]/.test(trimmed)) return;
|
|
579
|
+
const parsed = {
|
|
580
|
+
classes: [],
|
|
581
|
+
attributes: {}
|
|
582
|
+
};
|
|
583
|
+
let rest = trimmed;
|
|
584
|
+
const tagMatch = rest.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
|
|
585
|
+
if (tagMatch?.[0]) {
|
|
586
|
+
parsed.tagName = tagMatch[0].toLowerCase();
|
|
587
|
+
rest = rest.slice(tagMatch[0].length);
|
|
588
|
+
}
|
|
589
|
+
while (rest) {
|
|
590
|
+
if (rest.startsWith(".")) {
|
|
591
|
+
const match = rest.match(/^\.([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
592
|
+
if (!match?.[1]) return;
|
|
593
|
+
parsed.classes.push(match[1]);
|
|
594
|
+
rest = rest.slice(match[0].length);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (rest.startsWith("#")) {
|
|
598
|
+
const match = rest.match(/^#([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
599
|
+
if (!match?.[1] || parsed.id) return;
|
|
600
|
+
parsed.id = match[1];
|
|
601
|
+
rest = rest.slice(match[0].length);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (rest.startsWith("[")) {
|
|
605
|
+
const match = rest.match(/^\[([a-zA-Z_][a-zA-Z0-9_.:-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]+)))?\]/);
|
|
606
|
+
if (!match?.[1]) return;
|
|
607
|
+
parsed.attributes[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
|
|
608
|
+
rest = rest.slice(match[0].length);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
return parsed;
|
|
614
|
+
}
|
|
615
|
+
function matchesSelector(node, selector) {
|
|
616
|
+
if (node.kind !== "element") return false;
|
|
617
|
+
const attributes = node.attributes ?? {};
|
|
618
|
+
if (selector.tagName && node.tagName !== selector.tagName) return false;
|
|
619
|
+
if (selector.id && attributes.id !== selector.id) return false;
|
|
620
|
+
const classNames = new Set((attributes.class ?? "").split(/\s+/).filter(Boolean));
|
|
621
|
+
for (const className of selector.classes) if (!classNames.has(className)) return false;
|
|
622
|
+
for (const [name, value] of Object.entries(selector.attributes)) {
|
|
623
|
+
if (!(name in attributes)) return false;
|
|
624
|
+
if (value !== "" && attributes[name] !== value) return false;
|
|
625
|
+
}
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
function parseCssRules(css, diagnostics) {
|
|
629
|
+
if (!css?.trim()) return [];
|
|
630
|
+
const rules = [];
|
|
631
|
+
let order = 0;
|
|
632
|
+
for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
633
|
+
const selectorText = match[1]?.trim() ?? "";
|
|
634
|
+
const declarations = parseInlineStyle(match[2]);
|
|
635
|
+
for (const selector of selectorText.split(",").map((item) => item.trim())) {
|
|
636
|
+
if (!selector) continue;
|
|
637
|
+
if (!parseSelector(selector)) {
|
|
638
|
+
diagnostics.push({
|
|
639
|
+
code: "CSS_SELECTOR_UNSUPPORTED",
|
|
640
|
+
message: `Unsupported CSS selector: ${selector}`,
|
|
641
|
+
severity: "warning",
|
|
642
|
+
selector
|
|
643
|
+
});
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
rules.push({
|
|
647
|
+
selector,
|
|
648
|
+
declarations,
|
|
649
|
+
order
|
|
650
|
+
});
|
|
651
|
+
order += 1;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (css.replace(/([^{}]+)\{([^{}]*)\}/g, "").trim()) diagnostics.push({
|
|
655
|
+
code: "CSS_SELECTOR_UNSUPPORTED",
|
|
656
|
+
message: "Unsupported CSS was ignored.",
|
|
657
|
+
severity: "warning"
|
|
658
|
+
});
|
|
659
|
+
return rules;
|
|
660
|
+
}
|
|
661
|
+
function resolveCssStyles(nodes, rules) {
|
|
662
|
+
return nodes.map((node) => {
|
|
663
|
+
if (node.kind !== "element") return node;
|
|
664
|
+
const matchedDeclarations = rules.filter((rule) => {
|
|
665
|
+
const selector = parseSelector(rule.selector);
|
|
666
|
+
return selector ? matchesSelector(node, selector) : false;
|
|
667
|
+
}).sort((left, right) => left.order - right.order);
|
|
668
|
+
const stylesFromCss = {};
|
|
669
|
+
for (const rule of matchedDeclarations) Object.assign(stylesFromCss, rule.declarations);
|
|
670
|
+
return {
|
|
671
|
+
...node,
|
|
672
|
+
styles: {
|
|
673
|
+
...stylesFromCss,
|
|
674
|
+
...node.styles ?? {}
|
|
675
|
+
},
|
|
676
|
+
children: resolveCssStyles(node.children ?? [], rules)
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function mapStyleNodes(nodes, mapper) {
|
|
681
|
+
return nodes.map((node) => {
|
|
682
|
+
if (node.kind !== "element") return node;
|
|
683
|
+
return mapper({
|
|
684
|
+
...node,
|
|
685
|
+
children: mapStyleNodes(node.children ?? [], mapper)
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function applyTailwindStyles(node, config, diagnostics) {
|
|
690
|
+
const remaining = {};
|
|
691
|
+
const generatedClassNames = [...node.generatedClassNames ?? []];
|
|
692
|
+
for (const [property, value] of sortedEntries(node.styles ?? {})) {
|
|
693
|
+
const match = matchToken(property, value, config, diagnostics, node);
|
|
694
|
+
if (!match) {
|
|
695
|
+
remaining[property] = value;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const className = config?.styleMappings?.[match.group]?.[`${property}:${match.group}.${match.name}`];
|
|
699
|
+
if (className) generatedClassNames.push(className);
|
|
700
|
+
else {
|
|
701
|
+
remaining[property] = match.value;
|
|
702
|
+
diagnostics.push({
|
|
703
|
+
code: "TOKEN_NO_MATCH",
|
|
704
|
+
message: `No Tailwind mapping for ${property}:${match.group}.${match.name}.`,
|
|
705
|
+
severity: "info",
|
|
706
|
+
source: node.source,
|
|
707
|
+
property
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
...node,
|
|
713
|
+
styles: remaining,
|
|
714
|
+
generatedClassNames
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
function snapStyleValues(styles, config, diagnostics, node) {
|
|
718
|
+
const snapped = {};
|
|
719
|
+
for (const [property, value] of sortedEntries(styles)) snapped[property] = matchToken(property, value, config, diagnostics, node)?.value ?? value;
|
|
720
|
+
return snapped;
|
|
721
|
+
}
|
|
722
|
+
function matchToken(property, value, config, diagnostics, node) {
|
|
723
|
+
const group = tokenGroupForProperty(property);
|
|
724
|
+
if (!group) {
|
|
725
|
+
diagnostics.push({
|
|
726
|
+
code: "STYLE_UNSUPPORTED_PROPERTY",
|
|
727
|
+
message: `No token group is configured for CSS property "${property}".`,
|
|
728
|
+
severity: "info",
|
|
729
|
+
source: node.source,
|
|
730
|
+
property
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (group === "colors") return matchColorToken(property, value, config, diagnostics, node);
|
|
735
|
+
if (group === "shadow") return matchStringToken(property, value, config?.tokens?.shadow, group);
|
|
736
|
+
return matchNumericToken(property, value, group === "spacing" ? config?.tokens?.spacing?.values : group === "sizing" ? config?.tokens?.sizing?.values : group === "typography" ? config?.tokens?.typography?.values : group === "radius" ? config?.tokens?.radius : config?.tokens?.borderWidth, group, group === "spacing" ? config?.tokens?.spacing?.unit ?? "px" : group === "sizing" ? config?.tokens?.sizing?.unit ?? "px" : group === "typography" ? config?.tokens?.typography?.unit ?? "px" : "px", group === "spacing" ? config?.tokens?.spacing?.threshold ?? 0 : group === "sizing" ? config?.tokens?.sizing?.threshold ?? 0 : group === "typography" ? config?.tokens?.typography?.threshold ?? 0 : 0, diagnostics, node);
|
|
737
|
+
}
|
|
738
|
+
function tokenGroupForProperty(property) {
|
|
739
|
+
if (/^(margin|padding)(-|$)|^gap$|^row-gap$|^column-gap$/.test(property)) return "spacing";
|
|
740
|
+
if (/^(width|height|min-width|min-height|max-width|max-height)$/.test(property)) return "sizing";
|
|
741
|
+
if (/^(font-size|line-height|font-weight)$/.test(property)) return "typography";
|
|
742
|
+
if (property === "border-radius") return "radius";
|
|
743
|
+
if (property === "border-width") return "borderWidth";
|
|
744
|
+
if (property === "box-shadow") return "shadow";
|
|
745
|
+
if (property === "color" || property === "background" || property === "background-color" || property === "border-color") return "colors";
|
|
746
|
+
}
|
|
747
|
+
function matchNumericToken(property, value, tokens, group, unit, threshold, diagnostics, node) {
|
|
748
|
+
if (!tokens) return;
|
|
749
|
+
const parsed = value.match(/^(-?\d+(?:\.\d+)?)(px|rem)?$/);
|
|
750
|
+
if (!parsed?.[1]) return;
|
|
751
|
+
const numericValue = Number(parsed[1]);
|
|
752
|
+
const candidates = sortedEntries(tokens).map(([name, tokenValue]) => ({
|
|
753
|
+
name,
|
|
754
|
+
tokenValue,
|
|
755
|
+
distance: Math.abs(tokenValue - numericValue)
|
|
756
|
+
})).filter(({ distance }) => distance <= threshold).sort((left, right) => left.distance - right.distance || left.name.localeCompare(right.name));
|
|
757
|
+
if (candidates.length === 0) {
|
|
758
|
+
diagnostics.push({
|
|
759
|
+
code: "TOKEN_NO_MATCH",
|
|
760
|
+
message: `${property}: ${value} did not match a ${group} token.`,
|
|
761
|
+
severity: "info",
|
|
762
|
+
source: node.source,
|
|
763
|
+
property
|
|
764
|
+
});
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (candidates.length > 1 && candidates[0]?.distance === candidates[1]?.distance) {
|
|
768
|
+
diagnostics.push({
|
|
769
|
+
code: "TOKEN_AMBIGUOUS_MATCH",
|
|
770
|
+
message: `${property}: ${value} matches multiple ${group} tokens.`,
|
|
771
|
+
severity: "error",
|
|
772
|
+
source: node.source,
|
|
773
|
+
property
|
|
774
|
+
});
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const candidate = candidates[0];
|
|
778
|
+
if (!candidate) return;
|
|
779
|
+
return {
|
|
780
|
+
group,
|
|
781
|
+
name: candidate.name,
|
|
782
|
+
value: `${formatNumber(candidate.tokenValue)}${unit}`
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function matchColorToken(property, value, config, diagnostics, node) {
|
|
786
|
+
const tokens = config?.tokens?.colors;
|
|
787
|
+
if (!tokens) return;
|
|
788
|
+
const color = parseColor(value);
|
|
789
|
+
if (!color) {
|
|
790
|
+
diagnostics.push({
|
|
791
|
+
code: "COLOR_PARSE_FAILED",
|
|
792
|
+
message: `Could not parse color value: ${value}`,
|
|
793
|
+
severity: "warning",
|
|
794
|
+
source: node.source,
|
|
795
|
+
property
|
|
796
|
+
});
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const threshold = config?.tokens?.colorThreshold ?? 0;
|
|
800
|
+
const candidates = sortedEntries(tokens).map(([name, tokenValue]) => {
|
|
801
|
+
const tokenColor = parseColor(tokenValue);
|
|
802
|
+
return tokenColor ? {
|
|
803
|
+
name,
|
|
804
|
+
tokenValue,
|
|
805
|
+
distance: colorDistance(color, tokenColor)
|
|
806
|
+
} : void 0;
|
|
807
|
+
}).filter((candidate) => Boolean(candidate && candidate.distance <= threshold)).sort((left, right) => left.distance - right.distance || left.name.localeCompare(right.name));
|
|
808
|
+
if (candidates.length === 0) {
|
|
809
|
+
diagnostics.push({
|
|
810
|
+
code: "TOKEN_NO_MATCH",
|
|
811
|
+
message: `${property}: ${value} did not match a color token.`,
|
|
812
|
+
severity: "info",
|
|
813
|
+
source: node.source,
|
|
814
|
+
property
|
|
815
|
+
});
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (candidates.length > 1 && candidates[0]?.distance === candidates[1]?.distance) {
|
|
819
|
+
diagnostics.push({
|
|
820
|
+
code: "TOKEN_AMBIGUOUS_MATCH",
|
|
821
|
+
message: `${property}: ${value} matches multiple color tokens.`,
|
|
822
|
+
severity: "error",
|
|
823
|
+
source: node.source,
|
|
824
|
+
property
|
|
825
|
+
});
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const candidate = candidates[0];
|
|
829
|
+
if (!candidate) return;
|
|
830
|
+
return {
|
|
831
|
+
group: "colors",
|
|
832
|
+
name: candidate.name,
|
|
833
|
+
value: normalizeHex(candidate.tokenValue)
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function matchStringToken(_property, value, tokens, group) {
|
|
837
|
+
const match = sortedEntries(tokens ?? {}).find(([, tokenValue]) => tokenValue === value);
|
|
838
|
+
if (!match) return;
|
|
839
|
+
return {
|
|
840
|
+
group,
|
|
841
|
+
name: match[0],
|
|
842
|
+
value: match[1]
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function parseColor(value) {
|
|
846
|
+
const trimmed = value.trim();
|
|
847
|
+
const hex = trimmed.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
848
|
+
if (hex?.[1]) {
|
|
849
|
+
const expanded = hex[1].length === 3 ? hex[1].split("").map((part) => `${part}${part}`).join("") : hex[1];
|
|
850
|
+
return [
|
|
851
|
+
Number.parseInt(expanded.slice(0, 2), 16),
|
|
852
|
+
Number.parseInt(expanded.slice(2, 4), 16),
|
|
853
|
+
Number.parseInt(expanded.slice(4, 6), 16)
|
|
854
|
+
];
|
|
855
|
+
}
|
|
856
|
+
const rgb = trimmed.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i);
|
|
857
|
+
if (rgb?.[1] && rgb[2] && rgb[3]) return [
|
|
858
|
+
Number(rgb[1]),
|
|
859
|
+
Number(rgb[2]),
|
|
860
|
+
Number(rgb[3])
|
|
861
|
+
];
|
|
862
|
+
}
|
|
863
|
+
function colorDistance(left, right) {
|
|
864
|
+
return Math.sqrt((left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2);
|
|
865
|
+
}
|
|
866
|
+
function normalizeHex(value) {
|
|
867
|
+
const color = parseColor(value);
|
|
868
|
+
if (!color) return value;
|
|
869
|
+
return `#${color.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
870
|
+
}
|
|
871
|
+
function emitCssModuleRule(className, styles) {
|
|
872
|
+
return `.${className} {\n${sortedEntries(styles).map(([property, value]) => `\t${property}: ${value};`).join("\n")}\n}`;
|
|
873
|
+
}
|
|
874
|
+
function sortedEntries(record) {
|
|
875
|
+
return Object.entries(record).sort(([left], [right]) => left.localeCompare(right));
|
|
876
|
+
}
|
|
877
|
+
function formatNumber(value) {
|
|
878
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
|
|
879
|
+
}
|
|
880
|
+
function collectImports(nodes) {
|
|
881
|
+
const imports = /* @__PURE__ */ new Map();
|
|
882
|
+
function visit(node) {
|
|
883
|
+
if (node.kind === "component" && node.importName && node.importPath) imports.set(`${node.importPath}:${node.importName}`, {
|
|
884
|
+
importName: node.importName,
|
|
885
|
+
importPath: node.importPath
|
|
886
|
+
});
|
|
887
|
+
for (const child of node.children ?? []) visit(child);
|
|
888
|
+
for (const prop of Object.values(node.props ?? {})) if (prop.kind === "children") for (const child of prop.value) visit(child);
|
|
889
|
+
}
|
|
890
|
+
for (const node of nodes) visit(node);
|
|
891
|
+
return [...imports.values()].sort((left, right) => left.importPath.localeCompare(right.importPath) || left.importName.localeCompare(right.importName));
|
|
892
|
+
}
|
|
893
|
+
function emitJsxNode(node, depth) {
|
|
894
|
+
if (!node) return "";
|
|
895
|
+
const indent = " ".repeat(depth);
|
|
896
|
+
if (node.kind === "text") return `${indent}${escapeJsxText(node.text ?? "")}\n`;
|
|
897
|
+
if (node.kind === "component") return emitComponentJsx(node, depth);
|
|
898
|
+
const tagName = node.tagName ?? "div";
|
|
899
|
+
const attributes = emitJsxAttributes(node.attributes ?? {}, node.styles ?? {}, node.generatedClassNames ?? []);
|
|
900
|
+
const children = node.children ?? [];
|
|
901
|
+
const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
|
|
902
|
+
if (children.length === 0) return `${indent}${openTag}</${tagName}>\n`;
|
|
903
|
+
return `${indent}${openTag}\n${children.map((child) => emitJsxNode(child, depth + 1)).join("")}${indent}</${tagName}>\n`;
|
|
904
|
+
}
|
|
905
|
+
function emitComponentJsx(node, depth) {
|
|
906
|
+
const indent = " ".repeat(depth);
|
|
907
|
+
const component = node.component ?? node.importName ?? "Component";
|
|
908
|
+
const childrenProp = node.props?.children;
|
|
909
|
+
const attributes = Object.entries(node.props ?? {}).filter(([name]) => name !== "children").sort(([left], [right]) => left.localeCompare(right)).map(([name, prop]) => emitProp(name, prop)).join(" ");
|
|
910
|
+
const openTag = attributes ? `<${component} ${attributes}>` : `<${component}>`;
|
|
911
|
+
if (childrenProp?.kind === "text") return `${indent}${openTag}${escapeJsxText(childrenProp.value)}</${component}>\n`;
|
|
912
|
+
if (childrenProp?.kind === "children") return `${indent}${openTag}\n${childrenProp.value.map((child) => emitJsxNode(child, depth + 1)).join("")}${indent}</${component}>\n`;
|
|
913
|
+
const children = node.children ?? [];
|
|
914
|
+
if (children.length === 0) return `${indent}${openTag}</${component}>\n`;
|
|
915
|
+
return `${indent}${openTag}\n${children.map((child) => emitJsxNode(child, depth + 1)).join("")}${indent}</${component}>\n`;
|
|
916
|
+
}
|
|
917
|
+
function emitProp(name, prop) {
|
|
918
|
+
if (prop.kind === "children") return "";
|
|
919
|
+
if (typeof prop.value === "boolean" || typeof prop.value === "number") return `${name}={${JSON.stringify(prop.value)}}`;
|
|
920
|
+
return `${name}="${escapeAttribute(prop.value)}"`;
|
|
921
|
+
}
|
|
922
|
+
function emitJsxAttributes(attributes, styles, generatedClassNames = [], attributeBindings = /* @__PURE__ */ new Map()) {
|
|
923
|
+
const mergedAttributes = { ...attributes };
|
|
924
|
+
const classNames = [...(attributes.class ?? "").split(/\s+/).filter(Boolean), ...generatedClassNames];
|
|
925
|
+
if (classNames.length > 0) mergedAttributes.class = classNames.join(" ");
|
|
926
|
+
return Object.entries(mergedAttributes).filter(([name]) => name !== "style").sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => {
|
|
927
|
+
const jsxName = toJsxAttributeName(name);
|
|
928
|
+
const binding = attributeBindings.get(name);
|
|
929
|
+
if (binding) return `${jsxName}={${binding}}`;
|
|
930
|
+
if (value === "") return jsxName;
|
|
931
|
+
if (name === "class" && generatedClassNames.some(isCssModuleReference)) return `${jsxName}={${emitClassNameExpression(classNames)}}`;
|
|
932
|
+
return `${jsxName}="${escapeAttribute(value)}"`;
|
|
933
|
+
}).concat(emitStyleAttribute(styles)).filter(Boolean).join(" ");
|
|
934
|
+
}
|
|
935
|
+
function emitClassNameExpression(classNames) {
|
|
936
|
+
return `[${classNames.map((className) => isCssModuleReference(className) ? `styles.${className.slice(7)}` : JSON.stringify(className)).join(", ")}].filter(Boolean).join(" ")`;
|
|
937
|
+
}
|
|
938
|
+
function isCssModuleReference(className) {
|
|
939
|
+
return className.startsWith("module:");
|
|
940
|
+
}
|
|
941
|
+
function emitStyleAttribute(styles) {
|
|
942
|
+
const entries = Object.entries(styles).sort(([left], [right]) => left.localeCompare(right));
|
|
943
|
+
if (entries.length === 0) return [];
|
|
944
|
+
return [`style={{ ${entries.map(([property, value]) => `${toCamelCase(property)}: ${JSON.stringify(value)}`).join(", ")} }}`];
|
|
945
|
+
}
|
|
946
|
+
function toJsxAttributeName(name) {
|
|
947
|
+
if (name === "class") return "className";
|
|
948
|
+
if (name === "for") return "htmlFor";
|
|
949
|
+
return name;
|
|
950
|
+
}
|
|
951
|
+
function toCamelCase(value) {
|
|
952
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
953
|
+
}
|
|
954
|
+
function escapeJsxText(value) {
|
|
955
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/{/g, "{").replace(/}/g, "}");
|
|
956
|
+
}
|
|
957
|
+
function escapeHtml(value) {
|
|
958
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
959
|
+
}
|
|
960
|
+
function escapeAttribute(value) {
|
|
961
|
+
return escapeHtml(value).replace(/"/g, """);
|
|
962
|
+
}
|
|
963
|
+
//#endregion
|
|
964
|
+
export { ReactTarget, emitReactView, reactTestGenerator };
|