@design-embed/target-react 0.1.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 +5 -0
- package/dist/index.js +744 -0
- package/node_modules/@design-embed/config/README.md +5 -0
- package/node_modules/@design-embed/config/dist/index.js +283 -0
- package/node_modules/@design-embed/config/package.json +19 -0
- package/node_modules/@design-embed/config/src/index.ts +518 -0
- package/node_modules/@design-embed/core/README.md +5 -0
- package/node_modules/@design-embed/core/dist/diagnostics/diagnostic.js +3 -0
- package/node_modules/@design-embed/core/dist/diagnostics/jsonDiagnostic.js +35 -0
- package/node_modules/@design-embed/core/dist/index.js +351 -0
- package/node_modules/@design-embed/core/dist/pipeline/checkMode.js +29 -0
- package/node_modules/@design-embed/core/dist/plugins/pluginApi.js +1 -0
- package/node_modules/@design-embed/core/dist/plugins/pluginRegistry.js +25 -0
- package/node_modules/@design-embed/core/package.json +19 -0
- package/node_modules/@design-embed/core/src/diagnostics/diagnostic.ts +18 -0
- package/node_modules/@design-embed/core/src/diagnostics/jsonDiagnostic.ts +51 -0
- package/node_modules/@design-embed/core/src/index.ts +591 -0
- package/node_modules/@design-embed/core/src/pipeline/checkMode.ts +46 -0
- package/node_modules/@design-embed/core/src/plugins/pluginApi.ts +78 -0
- package/node_modules/@design-embed/core/src/plugins/pluginRegistry.ts +37 -0
- package/package.json +30 -0
- package/src/index.ts +998 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import { applyComponentMappings, matchesSelector, parseInlineStyle, parseSelector, } from "@design-embed/core";
|
|
2
|
+
export const reactEmitter = {
|
|
3
|
+
emit({ nodes, css, config, diagnostics }) {
|
|
4
|
+
const viewsDir = config?.output?.viewsDir ?? "src/generated/views";
|
|
5
|
+
const viewName = config?.output?.viewName ?? "DesignView";
|
|
6
|
+
const styleResult = transformStyles(nodes, css, config, diagnostics);
|
|
7
|
+
const transformed = applyComponentMappings(styleResult.nodes, config?.components ?? [], diagnostics);
|
|
8
|
+
const contents = emitReactView(transformed, viewName, {
|
|
9
|
+
cssModulePath: styleResult.cssModulePath,
|
|
10
|
+
});
|
|
11
|
+
const files = [
|
|
12
|
+
{ path: `${viewsDir}/${viewName}.view.tsx`, contents },
|
|
13
|
+
];
|
|
14
|
+
if (styleResult.cssModule && styleResult.cssModulePath) {
|
|
15
|
+
files.push({
|
|
16
|
+
path: `${viewsDir}/${styleResult.cssModulePath}`,
|
|
17
|
+
contents: styleResult.cssModule,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return { files };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export const reactTestGenerator = {
|
|
24
|
+
generateTests({ html, css, config, diagnostics, }) {
|
|
25
|
+
const tests = config.tests;
|
|
26
|
+
if (tests?.runner && tests.runner !== "playwright") {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
code: "TEST_RUNNER_UNSUPPORTED",
|
|
29
|
+
message: `Unsupported test runner: ${tests.runner}`,
|
|
30
|
+
severity: "error",
|
|
31
|
+
});
|
|
32
|
+
return { files: [] };
|
|
33
|
+
}
|
|
34
|
+
const viewsDir = config.output?.viewsDir ?? "src/generated/views";
|
|
35
|
+
const viewName = config.output?.viewName ?? "DesignView";
|
|
36
|
+
const outputDir = tests?.outputDir ?? "tests/generated/design-embed";
|
|
37
|
+
const fixturePath = `${outputDir}/${viewName}.reference.html`;
|
|
38
|
+
const specPath = `${outputDir}/${viewName}.visual.spec.tsx`;
|
|
39
|
+
const referenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${html}`;
|
|
40
|
+
return {
|
|
41
|
+
files: [
|
|
42
|
+
{
|
|
43
|
+
path: fixturePath,
|
|
44
|
+
contents: referenceHtml.endsWith("\n")
|
|
45
|
+
? referenceHtml
|
|
46
|
+
: `${referenceHtml}\n`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
path: specPath,
|
|
50
|
+
contents: emitReactVisualSpec({
|
|
51
|
+
viewName,
|
|
52
|
+
viewImportPath: toRelativeImport(specPath, `${viewsDir}/${viewName}.view`),
|
|
53
|
+
fixtureFileName: `${viewName}.reference.html`,
|
|
54
|
+
viewports: tests?.viewports ?? [
|
|
55
|
+
{ name: "default", width: 1440, height: 900 },
|
|
56
|
+
],
|
|
57
|
+
states: tests?.states ?? [{ name: "default" }],
|
|
58
|
+
assertions: {
|
|
59
|
+
screenshot: tests?.assertions?.screenshot ?? true,
|
|
60
|
+
layout: tests?.assertions?.layout ?? true,
|
|
61
|
+
layoutTolerance: tests?.assertions?.layoutTolerance ?? 0,
|
|
62
|
+
selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
function emitReactVisualSpec(input) {
|
|
71
|
+
const viewports = JSON.stringify(input.viewports, null, 2);
|
|
72
|
+
const states = JSON.stringify(input.states, null, 2);
|
|
73
|
+
const selectors = JSON.stringify(input.assertions.selectors, null, 2);
|
|
74
|
+
const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
|
|
75
|
+
const layoutEnabled = JSON.stringify(input.assertions.layout);
|
|
76
|
+
const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
|
|
77
|
+
return `import { readFileSync } from "node:fs";
|
|
78
|
+
import { dirname, resolve } from "node:path";
|
|
79
|
+
import { fileURLToPath } from "node:url";
|
|
80
|
+
import { expect, test } from "@playwright/experimental-ct-react";
|
|
81
|
+
import { ${input.viewName} } from "${input.viewImportPath}";
|
|
82
|
+
|
|
83
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
84
|
+
const referenceHtml = readFileSync(resolve(currentDir, "./${input.fixtureFileName}"), "utf-8");
|
|
85
|
+
const viewports = ${viewports};
|
|
86
|
+
const states = ${states};
|
|
87
|
+
const selectors = ${selectors};
|
|
88
|
+
const screenshotEnabled = ${screenshotEnabled};
|
|
89
|
+
const layoutEnabled = ${layoutEnabled};
|
|
90
|
+
const layoutTolerance = ${layoutTolerance};
|
|
91
|
+
|
|
92
|
+
for (const viewport of viewports) {
|
|
93
|
+
\tfor (const state of states) {
|
|
94
|
+
\t\tconst viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
|
|
95
|
+
\t\ttest("${input.viewName} matches source at " + viewportName + " / " + state.name, async ({ mount, page }) => {
|
|
96
|
+
\t\t\tawait page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
97
|
+
|
|
98
|
+
\t\t\tawait page.setContent(referenceHtml);
|
|
99
|
+
\t\t\tawait applyState(page, state);
|
|
100
|
+
\t\t\tconst expectedScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
|
|
101
|
+
\t\t\tconst expectedLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
|
|
102
|
+
|
|
103
|
+
\t\t\tawait page.setContent("");
|
|
104
|
+
\t\t\tconst component = await mount(<${input.viewName} />);
|
|
105
|
+
\t\t\tawait applyState(page, state);
|
|
106
|
+
\t\t\tconst actualScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
|
|
107
|
+
\t\t\tconst actualLayout = layoutEnabled ? await readLayout(component, selectors) : [];
|
|
108
|
+
|
|
109
|
+
\t\t\tif (screenshotEnabled) {
|
|
110
|
+
\t\t\t\texpect(actualScreenshot).toEqual(expectedScreenshot);
|
|
111
|
+
\t\t\t}
|
|
112
|
+
\t\t\tif (layoutEnabled) {
|
|
113
|
+
\t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
|
|
114
|
+
\t\t\t}
|
|
115
|
+
\t\t});
|
|
116
|
+
\t}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function applyState(page, state) {
|
|
120
|
+
\tif (state.waitFor) {
|
|
121
|
+
\t\tawait page.waitForSelector(state.waitFor);
|
|
122
|
+
\t}
|
|
123
|
+
\tif (state.hover) {
|
|
124
|
+
\t\tawait page.hover(state.hover);
|
|
125
|
+
\t}
|
|
126
|
+
\tif (state.focus) {
|
|
127
|
+
\t\tawait page.focus(state.focus);
|
|
128
|
+
\t}
|
|
129
|
+
\tif (state.click) {
|
|
130
|
+
\t\tawait page.click(state.click);
|
|
131
|
+
\t}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readLayout(root, selectorsToRead) {
|
|
135
|
+
\treturn root.evaluate((element, values) => {
|
|
136
|
+
\t\treturn values.flatMap((selector) => {
|
|
137
|
+
\t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
|
|
138
|
+
\t\t\treturn matches.map((matchedElement, index) => {
|
|
139
|
+
\t\t\t\tconst rect = matchedElement.getBoundingClientRect();
|
|
140
|
+
\t\t\t\treturn {
|
|
141
|
+
\t\t\t\t\tselector,
|
|
142
|
+
\t\t\t\t\tindex,
|
|
143
|
+
\t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
|
|
144
|
+
\t\t\t\t\tx: rect.x,
|
|
145
|
+
\t\t\t\t\ty: rect.y,
|
|
146
|
+
\t\t\t\t\twidth: rect.width,
|
|
147
|
+
\t\t\t\t\theight: rect.height,
|
|
148
|
+
\t\t\t\t};
|
|
149
|
+
\t\t\t});
|
|
150
|
+
\t\t});
|
|
151
|
+
\t}, selectorsToRead);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function expectLayoutToMatch(actual, expected, tolerance) {
|
|
155
|
+
\texpect(actual.length).toBe(expected.length);
|
|
156
|
+
\tfor (let index = 0; index < expected.length; index += 1) {
|
|
157
|
+
\t\tconst actualRect = actual[index];
|
|
158
|
+
\t\tconst expectedRect = expected[index];
|
|
159
|
+
\t\texpect(actualRect.selector).toBe(expectedRect.selector);
|
|
160
|
+
\t\texpect(actualRect.index).toBe(expectedRect.index);
|
|
161
|
+
\t\texpect(actualRect.tagName).toBe(expectedRect.tagName);
|
|
162
|
+
\t\tfor (const key of ["x", "y", "width", "height"]) {
|
|
163
|
+
\t\t\tconst drift = Math.abs(actualRect[key] - expectedRect[key]);
|
|
164
|
+
\t\t\texpect(drift, \`\${expectedRect.selector}[\${expectedRect.index}] \${key} drift\`).toBeLessThanOrEqual(tolerance);
|
|
165
|
+
\t\t}
|
|
166
|
+
\t}
|
|
167
|
+
}
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
function toRelativeImport(fromFile, toFile) {
|
|
171
|
+
const fromParts = fromFile.split("/").slice(0, -1);
|
|
172
|
+
const toParts = toFile.split("/");
|
|
173
|
+
while (fromParts.length > 0 &&
|
|
174
|
+
toParts.length > 0 &&
|
|
175
|
+
fromParts[0] === toParts[0]) {
|
|
176
|
+
fromParts.shift();
|
|
177
|
+
toParts.shift();
|
|
178
|
+
}
|
|
179
|
+
const prefix = fromParts.map(() => "..");
|
|
180
|
+
const relative = [...prefix, ...toParts].join("/");
|
|
181
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
182
|
+
}
|
|
183
|
+
export function emitReactView(nodes, viewName, options = {}) {
|
|
184
|
+
const imports = collectImports(nodes);
|
|
185
|
+
const importLines = imports
|
|
186
|
+
.map(({ importName, importPath }) => `import { ${importName} } from "${importPath}";`)
|
|
187
|
+
.join("\n");
|
|
188
|
+
const cssModuleImport = options.cssModulePath
|
|
189
|
+
? `import styles from "./${options.cssModulePath}";`
|
|
190
|
+
: "";
|
|
191
|
+
const allImports = [importLines, cssModuleImport].filter(Boolean).join("\n");
|
|
192
|
+
const body = nodes.length === 1
|
|
193
|
+
? emitJsxNode(nodes[0], 2)
|
|
194
|
+
: `${"\t".repeat(2)}<>\n${nodes.map((node) => emitJsxNode(node, 3)).join("")}${"\t".repeat(2)}</>\n`;
|
|
195
|
+
return `${allImports ? `${allImports}\n\n` : ""}export function ${viewName}() {\n\treturn (\n${body}\t);\n}\n`;
|
|
196
|
+
}
|
|
197
|
+
function transformStyles(nodes, css, config, diagnostics) {
|
|
198
|
+
const styleMode = config?.output?.styleMode ?? "inline";
|
|
199
|
+
const cssRules = parseCssRules(css, diagnostics);
|
|
200
|
+
const resolvedNodes = resolveCssStyles(nodes, cssRules);
|
|
201
|
+
if (styleMode === "inline") {
|
|
202
|
+
return {
|
|
203
|
+
nodes: mapStyleNodes(resolvedNodes, (node) => ({
|
|
204
|
+
...node,
|
|
205
|
+
styles: snapStyleValues(node.styles ?? {}, config, diagnostics, node),
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (styleMode === "tailwind") {
|
|
210
|
+
return {
|
|
211
|
+
nodes: mapStyleNodes(resolvedNodes, (node) => applyTailwindStyles(node, config, diagnostics)),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (styleMode === "css-modules") {
|
|
215
|
+
const rules = [];
|
|
216
|
+
let index = 0;
|
|
217
|
+
const moduleNodes = mapStyleNodes(resolvedNodes, (node) => {
|
|
218
|
+
const snapped = snapStyleValues(node.styles ?? {}, config, diagnostics, node);
|
|
219
|
+
if (Object.keys(snapped).length === 0) {
|
|
220
|
+
return { ...node, styles: snapped };
|
|
221
|
+
}
|
|
222
|
+
index += 1;
|
|
223
|
+
const className = `style${index}`;
|
|
224
|
+
rules.push(emitCssModuleRule(className, snapped));
|
|
225
|
+
return {
|
|
226
|
+
...node,
|
|
227
|
+
styles: {},
|
|
228
|
+
generatedClassNames: [
|
|
229
|
+
...(node.generatedClassNames ?? []),
|
|
230
|
+
`module:${className}`,
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
const viewName = config?.output?.viewName ?? "DesignView";
|
|
235
|
+
return {
|
|
236
|
+
nodes: moduleNodes,
|
|
237
|
+
cssModule: rules.length > 0 ? `${rules.join("\n\n")}\n` : undefined,
|
|
238
|
+
cssModulePath: rules.length > 0 ? `${viewName}.module.css` : undefined,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
diagnostics.push({
|
|
242
|
+
code: "STYLE_MODE_UNSUPPORTED",
|
|
243
|
+
message: `Unsupported style mode: ${styleMode}`,
|
|
244
|
+
severity: "error",
|
|
245
|
+
});
|
|
246
|
+
return { nodes: resolvedNodes };
|
|
247
|
+
}
|
|
248
|
+
function parseCssRules(css, diagnostics) {
|
|
249
|
+
if (!css?.trim()) {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
const rules = [];
|
|
253
|
+
let order = 0;
|
|
254
|
+
for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
255
|
+
const selectorText = match[1]?.trim() ?? "";
|
|
256
|
+
const declarations = parseInlineStyle(match[2]);
|
|
257
|
+
for (const selector of selectorText.split(",").map((item) => item.trim())) {
|
|
258
|
+
if (!selector) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (!parseSelector(selector)) {
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
code: "CSS_SELECTOR_UNSUPPORTED",
|
|
264
|
+
message: `Unsupported CSS selector: ${selector}`,
|
|
265
|
+
severity: "warning",
|
|
266
|
+
selector,
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
rules.push({ selector, declarations, order });
|
|
271
|
+
order += 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const unsupported = css.replace(/([^{}]+)\{([^{}]*)\}/g, "").trim();
|
|
275
|
+
if (unsupported) {
|
|
276
|
+
diagnostics.push({
|
|
277
|
+
code: "CSS_SELECTOR_UNSUPPORTED",
|
|
278
|
+
message: "Unsupported CSS was ignored.",
|
|
279
|
+
severity: "warning",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return rules;
|
|
283
|
+
}
|
|
284
|
+
function resolveCssStyles(nodes, rules) {
|
|
285
|
+
return nodes.map((node) => {
|
|
286
|
+
if (node.kind !== "element") {
|
|
287
|
+
return node;
|
|
288
|
+
}
|
|
289
|
+
const matchedDeclarations = rules
|
|
290
|
+
.filter((rule) => {
|
|
291
|
+
const selector = parseSelector(rule.selector);
|
|
292
|
+
return selector ? matchesSelector(node, selector) : false;
|
|
293
|
+
})
|
|
294
|
+
.sort((left, right) => left.order - right.order);
|
|
295
|
+
const stylesFromCss = {};
|
|
296
|
+
for (const rule of matchedDeclarations) {
|
|
297
|
+
Object.assign(stylesFromCss, rule.declarations);
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
...node,
|
|
301
|
+
styles: { ...stylesFromCss, ...(node.styles ?? {}) },
|
|
302
|
+
children: resolveCssStyles(node.children ?? [], rules),
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function mapStyleNodes(nodes, mapper) {
|
|
307
|
+
return nodes.map((node) => {
|
|
308
|
+
if (node.kind !== "element") {
|
|
309
|
+
return node;
|
|
310
|
+
}
|
|
311
|
+
return mapper({
|
|
312
|
+
...node,
|
|
313
|
+
children: mapStyleNodes(node.children ?? [], mapper),
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function applyTailwindStyles(node, config, diagnostics) {
|
|
318
|
+
const remaining = {};
|
|
319
|
+
const generatedClassNames = [...(node.generatedClassNames ?? [])];
|
|
320
|
+
for (const [property, value] of sortedEntries(node.styles ?? {})) {
|
|
321
|
+
const match = matchToken(property, value, config, diagnostics, node);
|
|
322
|
+
if (!match) {
|
|
323
|
+
remaining[property] = value;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const className = config?.styleMappings?.[match.group]?.[`${property}:${match.group}.${match.name}`];
|
|
327
|
+
if (className) {
|
|
328
|
+
generatedClassNames.push(className);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
remaining[property] = match.value;
|
|
332
|
+
diagnostics.push({
|
|
333
|
+
code: "TOKEN_NO_MATCH",
|
|
334
|
+
message: `No Tailwind mapping for ${property}:${match.group}.${match.name}.`,
|
|
335
|
+
severity: "info",
|
|
336
|
+
source: node.source,
|
|
337
|
+
property,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
...node,
|
|
343
|
+
styles: remaining,
|
|
344
|
+
generatedClassNames,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function snapStyleValues(styles, config, diagnostics, node) {
|
|
348
|
+
const snapped = {};
|
|
349
|
+
for (const [property, value] of sortedEntries(styles)) {
|
|
350
|
+
const match = matchToken(property, value, config, diagnostics, node);
|
|
351
|
+
snapped[property] = match?.value ?? value;
|
|
352
|
+
}
|
|
353
|
+
return snapped;
|
|
354
|
+
}
|
|
355
|
+
function matchToken(property, value, config, diagnostics, node) {
|
|
356
|
+
const group = tokenGroupForProperty(property);
|
|
357
|
+
if (!group) {
|
|
358
|
+
diagnostics.push({
|
|
359
|
+
code: "STYLE_UNSUPPORTED_PROPERTY",
|
|
360
|
+
message: `No token group is configured for CSS property "${property}".`,
|
|
361
|
+
severity: "info",
|
|
362
|
+
source: node.source,
|
|
363
|
+
property,
|
|
364
|
+
});
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
if (group === "colors") {
|
|
368
|
+
return matchColorToken(property, value, config, diagnostics, node);
|
|
369
|
+
}
|
|
370
|
+
if (group === "shadow") {
|
|
371
|
+
return matchStringToken(property, value, config?.tokens?.shadow, group);
|
|
372
|
+
}
|
|
373
|
+
const tokenValues = group === "spacing"
|
|
374
|
+
? config?.tokens?.spacing?.values
|
|
375
|
+
: group === "sizing"
|
|
376
|
+
? config?.tokens?.sizing?.values
|
|
377
|
+
: group === "typography"
|
|
378
|
+
? config?.tokens?.typography?.values
|
|
379
|
+
: group === "radius"
|
|
380
|
+
? config?.tokens?.radius
|
|
381
|
+
: config?.tokens?.borderWidth;
|
|
382
|
+
const unit = group === "spacing"
|
|
383
|
+
? (config?.tokens?.spacing?.unit ?? "px")
|
|
384
|
+
: group === "sizing"
|
|
385
|
+
? (config?.tokens?.sizing?.unit ?? "px")
|
|
386
|
+
: group === "typography"
|
|
387
|
+
? (config?.tokens?.typography?.unit ?? "px")
|
|
388
|
+
: "px";
|
|
389
|
+
const threshold = group === "spacing"
|
|
390
|
+
? (config?.tokens?.spacing?.threshold ?? 0)
|
|
391
|
+
: group === "sizing"
|
|
392
|
+
? (config?.tokens?.sizing?.threshold ?? 0)
|
|
393
|
+
: group === "typography"
|
|
394
|
+
? (config?.tokens?.typography?.threshold ?? 0)
|
|
395
|
+
: 0;
|
|
396
|
+
return matchNumericToken(property, value, tokenValues, group, unit, threshold, diagnostics, node);
|
|
397
|
+
}
|
|
398
|
+
function tokenGroupForProperty(property) {
|
|
399
|
+
if (/^(margin|padding)(-|$)|^gap$|^row-gap$|^column-gap$/.test(property)) {
|
|
400
|
+
return "spacing";
|
|
401
|
+
}
|
|
402
|
+
if (/^(width|height|min-width|min-height|max-width|max-height)$/.test(property)) {
|
|
403
|
+
return "sizing";
|
|
404
|
+
}
|
|
405
|
+
if (/^(font-size|line-height|font-weight)$/.test(property)) {
|
|
406
|
+
return "typography";
|
|
407
|
+
}
|
|
408
|
+
if (property === "border-radius") {
|
|
409
|
+
return "radius";
|
|
410
|
+
}
|
|
411
|
+
if (property === "border-width") {
|
|
412
|
+
return "borderWidth";
|
|
413
|
+
}
|
|
414
|
+
if (property === "box-shadow") {
|
|
415
|
+
return "shadow";
|
|
416
|
+
}
|
|
417
|
+
if (property === "color" ||
|
|
418
|
+
property === "background" ||
|
|
419
|
+
property === "background-color" ||
|
|
420
|
+
property === "border-color") {
|
|
421
|
+
return "colors";
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
function matchNumericToken(property, value, tokens, group, unit, threshold, diagnostics, node) {
|
|
426
|
+
if (!tokens) {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
const parsed = value.match(/^(-?\d+(?:\.\d+)?)(px|rem)?$/);
|
|
430
|
+
if (!parsed?.[1]) {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
const numericValue = Number(parsed[1]);
|
|
434
|
+
const candidates = sortedEntries(tokens)
|
|
435
|
+
.map(([name, tokenValue]) => ({
|
|
436
|
+
name,
|
|
437
|
+
tokenValue,
|
|
438
|
+
distance: Math.abs(tokenValue - numericValue),
|
|
439
|
+
}))
|
|
440
|
+
.filter(({ distance }) => distance <= threshold)
|
|
441
|
+
.sort((left, right) => left.distance - right.distance || left.name.localeCompare(right.name));
|
|
442
|
+
if (candidates.length === 0) {
|
|
443
|
+
diagnostics.push({
|
|
444
|
+
code: "TOKEN_NO_MATCH",
|
|
445
|
+
message: `${property}: ${value} did not match a ${group} token.`,
|
|
446
|
+
severity: "info",
|
|
447
|
+
source: node.source,
|
|
448
|
+
property,
|
|
449
|
+
});
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
if (candidates.length > 1 &&
|
|
453
|
+
candidates[0]?.distance === candidates[1]?.distance) {
|
|
454
|
+
diagnostics.push({
|
|
455
|
+
code: "TOKEN_AMBIGUOUS_MATCH",
|
|
456
|
+
message: `${property}: ${value} matches multiple ${group} tokens.`,
|
|
457
|
+
severity: "error",
|
|
458
|
+
source: node.source,
|
|
459
|
+
property,
|
|
460
|
+
});
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
const candidate = candidates[0];
|
|
464
|
+
if (!candidate) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
group,
|
|
469
|
+
name: candidate.name,
|
|
470
|
+
value: `${formatNumber(candidate.tokenValue)}${unit}`,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function matchColorToken(property, value, config, diagnostics, node) {
|
|
474
|
+
const tokens = config?.tokens?.colors;
|
|
475
|
+
if (!tokens) {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
const color = parseColor(value);
|
|
479
|
+
if (!color) {
|
|
480
|
+
diagnostics.push({
|
|
481
|
+
code: "COLOR_PARSE_FAILED",
|
|
482
|
+
message: `Could not parse color value: ${value}`,
|
|
483
|
+
severity: "warning",
|
|
484
|
+
source: node.source,
|
|
485
|
+
property,
|
|
486
|
+
});
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
const threshold = config?.tokens?.colorThreshold ?? 0;
|
|
490
|
+
const candidates = sortedEntries(tokens)
|
|
491
|
+
.map(([name, tokenValue]) => {
|
|
492
|
+
const tokenColor = parseColor(tokenValue);
|
|
493
|
+
return tokenColor
|
|
494
|
+
? { name, tokenValue, distance: colorDistance(color, tokenColor) }
|
|
495
|
+
: undefined;
|
|
496
|
+
})
|
|
497
|
+
.filter((candidate) => Boolean(candidate && candidate.distance <= threshold))
|
|
498
|
+
.sort((left, right) => left.distance - right.distance || left.name.localeCompare(right.name));
|
|
499
|
+
if (candidates.length === 0) {
|
|
500
|
+
diagnostics.push({
|
|
501
|
+
code: "TOKEN_NO_MATCH",
|
|
502
|
+
message: `${property}: ${value} did not match a color token.`,
|
|
503
|
+
severity: "info",
|
|
504
|
+
source: node.source,
|
|
505
|
+
property,
|
|
506
|
+
});
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
if (candidates.length > 1 &&
|
|
510
|
+
candidates[0]?.distance === candidates[1]?.distance) {
|
|
511
|
+
diagnostics.push({
|
|
512
|
+
code: "TOKEN_AMBIGUOUS_MATCH",
|
|
513
|
+
message: `${property}: ${value} matches multiple color tokens.`,
|
|
514
|
+
severity: "error",
|
|
515
|
+
source: node.source,
|
|
516
|
+
property,
|
|
517
|
+
});
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
const candidate = candidates[0];
|
|
521
|
+
if (!candidate) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
group: "colors",
|
|
526
|
+
name: candidate.name,
|
|
527
|
+
value: normalizeHex(candidate.tokenValue),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function matchStringToken(_property, value, tokens, group) {
|
|
531
|
+
const match = sortedEntries(tokens ?? {}).find(([, tokenValue]) => tokenValue === value);
|
|
532
|
+
if (!match) {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
return { group, name: match[0], value: match[1] };
|
|
536
|
+
}
|
|
537
|
+
function parseColor(value) {
|
|
538
|
+
const trimmed = value.trim();
|
|
539
|
+
const hex = trimmed.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
540
|
+
if (hex?.[1]) {
|
|
541
|
+
const expanded = hex[1].length === 3
|
|
542
|
+
? hex[1]
|
|
543
|
+
.split("")
|
|
544
|
+
.map((part) => `${part}${part}`)
|
|
545
|
+
.join("")
|
|
546
|
+
: hex[1];
|
|
547
|
+
return [
|
|
548
|
+
Number.parseInt(expanded.slice(0, 2), 16),
|
|
549
|
+
Number.parseInt(expanded.slice(2, 4), 16),
|
|
550
|
+
Number.parseInt(expanded.slice(4, 6), 16),
|
|
551
|
+
];
|
|
552
|
+
}
|
|
553
|
+
const rgb = trimmed.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i);
|
|
554
|
+
if (rgb?.[1] && rgb[2] && rgb[3]) {
|
|
555
|
+
return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
556
|
+
}
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
function colorDistance(left, right) {
|
|
560
|
+
return Math.sqrt((left[0] - right[0]) ** 2 +
|
|
561
|
+
(left[1] - right[1]) ** 2 +
|
|
562
|
+
(left[2] - right[2]) ** 2);
|
|
563
|
+
}
|
|
564
|
+
function normalizeHex(value) {
|
|
565
|
+
const color = parseColor(value);
|
|
566
|
+
if (!color) {
|
|
567
|
+
return value;
|
|
568
|
+
}
|
|
569
|
+
return `#${color.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
570
|
+
}
|
|
571
|
+
function emitCssModuleRule(className, styles) {
|
|
572
|
+
const declarations = sortedEntries(styles)
|
|
573
|
+
.map(([property, value]) => `\t${property}: ${value};`)
|
|
574
|
+
.join("\n");
|
|
575
|
+
return `.${className} {\n${declarations}\n}`;
|
|
576
|
+
}
|
|
577
|
+
function sortedEntries(record) {
|
|
578
|
+
return Object.entries(record).sort(([left], [right]) => left.localeCompare(right));
|
|
579
|
+
}
|
|
580
|
+
function formatNumber(value) {
|
|
581
|
+
return Number.isInteger(value)
|
|
582
|
+
? String(value)
|
|
583
|
+
: String(Number(value.toFixed(4)));
|
|
584
|
+
}
|
|
585
|
+
function collectImports(nodes) {
|
|
586
|
+
const imports = new Map();
|
|
587
|
+
function visit(node) {
|
|
588
|
+
if (node.kind === "component" && node.importName && node.importPath) {
|
|
589
|
+
imports.set(`${node.importPath}:${node.importName}`, {
|
|
590
|
+
importName: node.importName,
|
|
591
|
+
importPath: node.importPath,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
for (const child of node.children ?? []) {
|
|
595
|
+
visit(child);
|
|
596
|
+
}
|
|
597
|
+
for (const prop of Object.values(node.props ?? {})) {
|
|
598
|
+
if (prop.kind === "children") {
|
|
599
|
+
for (const child of prop.value) {
|
|
600
|
+
visit(child);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
for (const node of nodes) {
|
|
606
|
+
visit(node);
|
|
607
|
+
}
|
|
608
|
+
return [...imports.values()].sort((left, right) => left.importPath.localeCompare(right.importPath) ||
|
|
609
|
+
left.importName.localeCompare(right.importName));
|
|
610
|
+
}
|
|
611
|
+
function emitJsxNode(node, depth) {
|
|
612
|
+
if (!node) {
|
|
613
|
+
return "";
|
|
614
|
+
}
|
|
615
|
+
const indent = "\t".repeat(depth);
|
|
616
|
+
if (node.kind === "text") {
|
|
617
|
+
return `${indent}${escapeJsxText(node.text ?? "")}\n`;
|
|
618
|
+
}
|
|
619
|
+
if (node.kind === "component") {
|
|
620
|
+
return emitComponentJsx(node, depth);
|
|
621
|
+
}
|
|
622
|
+
const tagName = node.tagName ?? "div";
|
|
623
|
+
const attributes = emitJsxAttributes(node.attributes ?? {}, node.styles ?? {}, node.generatedClassNames ?? []);
|
|
624
|
+
const children = node.children ?? [];
|
|
625
|
+
const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
|
|
626
|
+
if (children.length === 0) {
|
|
627
|
+
return `${indent}${openTag}</${tagName}>\n`;
|
|
628
|
+
}
|
|
629
|
+
return `${indent}${openTag}\n${children
|
|
630
|
+
.map((child) => emitJsxNode(child, depth + 1))
|
|
631
|
+
.join("")}${indent}</${tagName}>\n`;
|
|
632
|
+
}
|
|
633
|
+
function emitComponentJsx(node, depth) {
|
|
634
|
+
const indent = "\t".repeat(depth);
|
|
635
|
+
const component = node.component ?? node.importName ?? "Component";
|
|
636
|
+
const childrenProp = node.props?.children;
|
|
637
|
+
const attributes = Object.entries(node.props ?? {})
|
|
638
|
+
.filter(([name]) => name !== "children")
|
|
639
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
640
|
+
.map(([name, prop]) => emitProp(name, prop))
|
|
641
|
+
.join(" ");
|
|
642
|
+
const openTag = attributes
|
|
643
|
+
? `<${component} ${attributes}>`
|
|
644
|
+
: `<${component}>`;
|
|
645
|
+
if (childrenProp?.kind === "text") {
|
|
646
|
+
return `${indent}${openTag}${escapeJsxText(childrenProp.value)}</${component}>\n`;
|
|
647
|
+
}
|
|
648
|
+
if (childrenProp?.kind === "children") {
|
|
649
|
+
return `${indent}${openTag}\n${childrenProp.value
|
|
650
|
+
.map((child) => emitJsxNode(child, depth + 1))
|
|
651
|
+
.join("")}${indent}</${component}>\n`;
|
|
652
|
+
}
|
|
653
|
+
const children = node.children ?? [];
|
|
654
|
+
if (children.length === 0) {
|
|
655
|
+
return `${indent}${openTag}</${component}>\n`;
|
|
656
|
+
}
|
|
657
|
+
return `${indent}${openTag}\n${children
|
|
658
|
+
.map((child) => emitJsxNode(child, depth + 1))
|
|
659
|
+
.join("")}${indent}</${component}>\n`;
|
|
660
|
+
}
|
|
661
|
+
function emitProp(name, prop) {
|
|
662
|
+
if (prop.kind === "children") {
|
|
663
|
+
return "";
|
|
664
|
+
}
|
|
665
|
+
if (typeof prop.value === "boolean" || typeof prop.value === "number") {
|
|
666
|
+
return `${name}={${JSON.stringify(prop.value)}}`;
|
|
667
|
+
}
|
|
668
|
+
return `${name}="${escapeAttribute(prop.value)}"`;
|
|
669
|
+
}
|
|
670
|
+
function emitJsxAttributes(attributes, styles, generatedClassNames = []) {
|
|
671
|
+
const mergedAttributes = { ...attributes };
|
|
672
|
+
const classNames = [
|
|
673
|
+
...(attributes.class ?? "").split(/\s+/).filter(Boolean),
|
|
674
|
+
...generatedClassNames,
|
|
675
|
+
];
|
|
676
|
+
if (classNames.length > 0) {
|
|
677
|
+
mergedAttributes.class = classNames.join(" ");
|
|
678
|
+
}
|
|
679
|
+
return Object.entries(mergedAttributes)
|
|
680
|
+
.filter(([name]) => name !== "style")
|
|
681
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
682
|
+
.map(([name, value]) => {
|
|
683
|
+
const jsxName = toJsxAttributeName(name);
|
|
684
|
+
if (value === "") {
|
|
685
|
+
return jsxName;
|
|
686
|
+
}
|
|
687
|
+
if (name === "class" && generatedClassNames.some(isCssModuleReference)) {
|
|
688
|
+
return `${jsxName}={${emitClassNameExpression(classNames)}}`;
|
|
689
|
+
}
|
|
690
|
+
return `${jsxName}="${escapeAttribute(value)}"`;
|
|
691
|
+
})
|
|
692
|
+
.concat(emitStyleAttribute(styles))
|
|
693
|
+
.filter(Boolean)
|
|
694
|
+
.join(" ");
|
|
695
|
+
}
|
|
696
|
+
function emitClassNameExpression(classNames) {
|
|
697
|
+
return `[${classNames
|
|
698
|
+
.map((className) => isCssModuleReference(className)
|
|
699
|
+
? `styles.${className.slice("module:".length)}`
|
|
700
|
+
: JSON.stringify(className))
|
|
701
|
+
.join(", ")}].filter(Boolean).join(" ")`;
|
|
702
|
+
}
|
|
703
|
+
function isCssModuleReference(className) {
|
|
704
|
+
return className.startsWith("module:");
|
|
705
|
+
}
|
|
706
|
+
function emitStyleAttribute(styles) {
|
|
707
|
+
const entries = Object.entries(styles).sort(([left], [right]) => left.localeCompare(right));
|
|
708
|
+
if (entries.length === 0) {
|
|
709
|
+
return [];
|
|
710
|
+
}
|
|
711
|
+
const styleObject = entries
|
|
712
|
+
.map(([property, value]) => `${toCamelCase(property)}: ${JSON.stringify(value)}`)
|
|
713
|
+
.join(", ");
|
|
714
|
+
return [`style={{ ${styleObject} }}`];
|
|
715
|
+
}
|
|
716
|
+
function toJsxAttributeName(name) {
|
|
717
|
+
if (name === "class") {
|
|
718
|
+
return "className";
|
|
719
|
+
}
|
|
720
|
+
if (name === "for") {
|
|
721
|
+
return "htmlFor";
|
|
722
|
+
}
|
|
723
|
+
return name;
|
|
724
|
+
}
|
|
725
|
+
function toCamelCase(value) {
|
|
726
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
727
|
+
}
|
|
728
|
+
function escapeJsxText(value) {
|
|
729
|
+
return value
|
|
730
|
+
.replace(/&/g, "&")
|
|
731
|
+
.replace(/</g, "<")
|
|
732
|
+
.replace(/>/g, ">")
|
|
733
|
+
.replace(/{/g, "{")
|
|
734
|
+
.replace(/}/g, "}");
|
|
735
|
+
}
|
|
736
|
+
function escapeHtml(value) {
|
|
737
|
+
return value
|
|
738
|
+
.replace(/&/g, "&")
|
|
739
|
+
.replace(/</g, "<")
|
|
740
|
+
.replace(/>/g, ">");
|
|
741
|
+
}
|
|
742
|
+
function escapeAttribute(value) {
|
|
743
|
+
return escapeHtml(value).replace(/"/g, """);
|
|
744
|
+
}
|