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