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