@design-embed/target-vue 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/src/index.ts ADDED
@@ -0,0 +1,1710 @@
1
+ import {
2
+ applyComponentMappings,
3
+ type DesignEmbedConfig,
4
+ type DesignNode,
5
+ type Diagnostic,
6
+ type PropValue,
7
+ parseHtml,
8
+ type TargetEmitInput,
9
+ type TargetEmitResult,
10
+ type TargetEmitter,
11
+ type TargetTestGenerateInput,
12
+ type TargetTestGenerateResult,
13
+ type TargetTestGenerator,
14
+ } from "design-embed";
15
+
16
+ export interface VueTargetOptions {
17
+ api?: "composition" | "options";
18
+ }
19
+
20
+ export class VueTarget implements TargetEmitter, TargetTestGenerator {
21
+ private options: VueTargetOptions;
22
+
23
+ constructor(options: VueTargetOptions = { api: "composition" }) {
24
+ this.options = options;
25
+ }
26
+
27
+ emit({ nodes, css, config, diagnostics }: TargetEmitInput): TargetEmitResult {
28
+ const viewsDir = String(config?.output?.viewsDir ?? "src/generated/views");
29
+ const viewName = config?.output?.viewName ?? "DesignView";
30
+
31
+ const styleResult = transformStyles(nodes, css, config, diagnostics);
32
+ const contents = emitVueView(styleResult.nodes, viewName, {
33
+ cssModule: styleResult.cssModule,
34
+ api: this.options.api,
35
+ });
36
+
37
+ const files: Array<{ path: string; contents: string }> = [
38
+ { path: `${viewsDir}/${viewName}.vue`, contents },
39
+ ];
40
+
41
+ for (const split of emitComponentSplitViews(
42
+ styleResult.nodes,
43
+ viewsDir,
44
+ this.options.api,
45
+ )) {
46
+ files.push(split);
47
+ }
48
+
49
+ return { files };
50
+ }
51
+
52
+ generateTests(input: TargetTestGenerateInput): TargetTestGenerateResult {
53
+ return vueTestGenerator.generateTests(input);
54
+ }
55
+ }
56
+
57
+ export const vueTestGenerator: TargetTestGenerator = {
58
+ generateTests({
59
+ html,
60
+ css,
61
+ config,
62
+ }: TargetTestGenerateInput): TargetTestGenerateResult {
63
+ const diagnostics: Diagnostic[] = [];
64
+ const tests = config.tests;
65
+ if (tests?.runner && tests.runner !== "playwright") {
66
+ diagnostics.push({
67
+ code: "TEST_RUNNER_UNSUPPORTED",
68
+ message: `Unsupported test runner: ${tests.runner}`,
69
+ severity: "error",
70
+ });
71
+ return { files: [], diagnostics };
72
+ }
73
+
74
+ const viewsDir = String(config.output?.viewsDir ?? "src/generated/views");
75
+ const viewName = config.output?.viewName ?? "DesignView";
76
+ const outputDir = tests?.outputDir ?? `${viewsDir}/tests`;
77
+ const fixturePath = `${outputDir}/${viewName}.reference.html`;
78
+ const specPath = `${outputDir}/${viewName}.visual.spec.ts`;
79
+ const referenceHtml =
80
+ (css?.trim() ? `<style>\n${css}\n</style>\n` : "") + html;
81
+
82
+ const assertionDefaults = {
83
+ screenshot: tests?.assertions?.screenshot ?? true,
84
+ layout: tests?.assertions?.layout ?? true,
85
+ layoutTolerance: tests?.assertions?.layoutTolerance ?? 1,
86
+ selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
87
+ screenshotThreshold: tests?.assertions?.screenshotThreshold ?? 0.2,
88
+ screenshotMaxDiffPixels:
89
+ tests?.assertions?.screenshotMaxDiffPixels ?? 500,
90
+ };
91
+ const viewportDefaults = tests?.viewports ?? [
92
+ { name: "default", width: 1440, height: 900 },
93
+ ];
94
+ const stateDefaults = tests?.states ?? [{ name: "default" }];
95
+ const referenceHtmlFileName = `${viewName}.reference.html`;
96
+
97
+ const files: Array<{ path: string; contents: string }> = [
98
+ {
99
+ path: fixturePath,
100
+ contents: referenceHtml.endsWith("\n")
101
+ ? referenceHtml
102
+ : `${referenceHtml}\n`,
103
+ },
104
+ {
105
+ path: specPath,
106
+ contents: emitVueVisualSpec({
107
+ viewName,
108
+ viewImportPath: toRelativeImport(
109
+ specPath,
110
+ `${viewsDir}/${viewName}.vue`,
111
+ ),
112
+ fixtureFileName: referenceHtmlFileName,
113
+ viewports: viewportDefaults,
114
+ states: stateDefaults,
115
+ assertions: assertionDefaults,
116
+ }),
117
+ },
118
+ ];
119
+
120
+ const parsedHtmlNodes = parseHtml(html);
121
+ const componentNodes = collectComponentNodes(
122
+ applyComponentMappings(parsedHtmlNodes, config.components ?? []),
123
+ );
124
+
125
+ for (const mapping of config.components ?? []) {
126
+ const componentName = mapping.component;
127
+ const componentSpecPath = `${outputDir}/${componentName}.visual.spec.ts`;
128
+ const componentReferenceHtmlFileName = `${componentName}.reference.html`;
129
+ const componentFixturePath = `${outputDir}/${componentReferenceHtmlFileName}`;
130
+ const matchingNode = findNodeBySelector(
131
+ parsedHtmlNodes,
132
+ mapping.selector,
133
+ );
134
+ const elementHtml = matchingNode ? serializeNodeToHtml(matchingNode) : "";
135
+ const componentReferenceHtml =
136
+ (css?.trim() ? `<style>\n${css}\n</style>\n` : "") + elementHtml;
137
+ const mountNode = componentNodes.get(componentName);
138
+ files.push({
139
+ path: componentFixturePath,
140
+ contents: componentReferenceHtml.endsWith("\n")
141
+ ? componentReferenceHtml
142
+ : `${componentReferenceHtml}\n`,
143
+ });
144
+ files.push({
145
+ path: componentSpecPath,
146
+ contents: emitComponentVisualSpec({
147
+ componentName,
148
+ selector: mapping.selector,
149
+ mountInfo: emitComponentMountInfo(componentName, mountNode),
150
+ componentImportPath: toRelativeImport(
151
+ componentSpecPath,
152
+ `${viewsDir}/${componentName}.vue`,
153
+ ),
154
+ referenceHtmlFileName: componentReferenceHtmlFileName,
155
+ viewports: viewportDefaults,
156
+ states: stateDefaults,
157
+ assertions: assertionDefaults,
158
+ }),
159
+ });
160
+ }
161
+
162
+ return { diagnostics, files };
163
+ },
164
+ };
165
+
166
+ interface VueVisualSpecInput {
167
+ viewName: string;
168
+ viewImportPath: string;
169
+ fixtureFileName: string;
170
+ viewports: Array<{ name?: string; width: number; height: number }>;
171
+ states: Array<{
172
+ name: string;
173
+ hover?: string;
174
+ focus?: string;
175
+ click?: string;
176
+ waitFor?: string;
177
+ }>;
178
+ assertions: {
179
+ screenshot: boolean;
180
+ layout: boolean;
181
+ layoutTolerance: number;
182
+ selectors: string[];
183
+ screenshotThreshold: number;
184
+ screenshotMaxDiffPixels: number;
185
+ };
186
+ }
187
+
188
+ function emitVueVisualSpec(input: VueVisualSpecInput): string {
189
+ const viewports = JSON.stringify(input.viewports, null, 2);
190
+ const states = JSON.stringify(input.states, null, 2);
191
+ const selectors = JSON.stringify(input.assertions.selectors, null, 2);
192
+ const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
193
+ const layoutEnabled = JSON.stringify(input.assertions.layout);
194
+ const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
195
+ const screenshotThreshold = JSON.stringify(
196
+ input.assertions.screenshotThreshold,
197
+ );
198
+ const screenshotMaxDiffPixels = JSON.stringify(
199
+ input.assertions.screenshotMaxDiffPixels,
200
+ );
201
+
202
+ return `import { readFileSync } from "node:fs";
203
+ import { dirname, resolve } from "node:path";
204
+ import { fileURLToPath } from "node:url";
205
+ import { expect, test } from "@playwright/experimental-ct-vue";
206
+ import pixelmatch from "pixelmatch";
207
+ import { PNG } from "pngjs";
208
+ import ${input.viewName} from "${input.viewImportPath}";
209
+
210
+ const currentDir = dirname(fileURLToPath(import.meta.url));
211
+ const referenceHtml = readFileSync(resolve(currentDir, "./${input.fixtureFileName}"), "utf-8");
212
+ const viewports = ${viewports};
213
+ const states = ${states};
214
+ const selectors = ${selectors};
215
+ const screenshotEnabled = ${screenshotEnabled};
216
+ const layoutEnabled = ${layoutEnabled};
217
+ const layoutTolerance = ${layoutTolerance};
218
+ const screenshotThreshold = ${screenshotThreshold};
219
+ const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
220
+
221
+ for (const viewport of viewports) {
222
+ for (const state of states) {
223
+ const viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
224
+ test("${input.viewName} matches source at " + viewportName + " / " + state.name, async ({ mount, page }) => {
225
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
226
+
227
+ await page.setContent(referenceHtml);
228
+ await applyState(page, state);
229
+ const expectedScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
230
+ const expectedLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
231
+
232
+ await page.setContent("");
233
+ const component = await mount(${input.viewName});
234
+ await applyState(page, state);
235
+ const actualScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
236
+ const actualLayout = layoutEnabled ? await readLayout(component, selectors) : [];
237
+
238
+ if (screenshotEnabled) {
239
+ compareScreenshots(actualScreenshot, expectedScreenshot, screenshotThreshold, screenshotMaxDiffPixels);
240
+ }
241
+ if (layoutEnabled) {
242
+ expectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
243
+ }
244
+ });
245
+ }
246
+ }
247
+
248
+ async function applyState(page, state) {
249
+ if (state.waitFor) {
250
+ await page.waitForSelector(state.waitFor);
251
+ }
252
+ if (state.hover) {
253
+ await page.hover(state.hover);
254
+ }
255
+ if (state.focus) {
256
+ await page.focus(state.focus);
257
+ }
258
+ if (state.click) {
259
+ await page.click(state.click);
260
+ }
261
+ }
262
+
263
+ async function readLayout(root, selectorsToRead) {
264
+ return root.evaluate((element, values) => {
265
+ const origin = element.getBoundingClientRect();
266
+ return values.flatMap((selector) => {
267
+ const matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
268
+ return matches.map((matchedElement, index) => {
269
+ const rect = matchedElement.getBoundingClientRect();
270
+ return {
271
+ selector,
272
+ index,
273
+ tagName: matchedElement.tagName.toLowerCase(),
274
+ x: rect.x - origin.x,
275
+ y: rect.y - origin.y,
276
+ width: rect.width,
277
+ height: rect.height,
278
+ };
279
+ });
280
+ });
281
+ }, selectorsToRead);
282
+ }
283
+
284
+ function compareScreenshots(actual, expected, threshold, maxDiffPixels) {
285
+ if (!actual || !expected) {
286
+ expect(actual).toEqual(expected);
287
+ return;
288
+ }
289
+ const actualPng = PNG.sync.read(actual);
290
+ const expectedPng = PNG.sync.read(expected);
291
+ expect(actualPng.width, "screenshot width").toBe(expectedPng.width);
292
+ expect(actualPng.height, "screenshot height").toBe(expectedPng.height);
293
+ const diffPixels = pixelmatch(actualPng.data, expectedPng.data, null, actualPng.width, actualPng.height, { threshold });
294
+ expect(diffPixels, "screenshot pixels differing beyond threshold").toBeLessThanOrEqual(maxDiffPixels);
295
+ }
296
+
297
+ function expectLayoutToMatch(actual, expected, tolerance) {
298
+ expect(actual.length).toBe(expected.length);
299
+ for (let index = 0; index < expected.length; index += 1) {
300
+ const actualRect = actual[index];
301
+ const expectedRect = expected[index];
302
+ expect(actualRect.selector).toBe(expectedRect.selector);
303
+ expect(actualRect.index).toBe(expectedRect.index);
304
+ expect(actualRect.tagName).toBe(expectedRect.tagName);
305
+ for (const key of ["x", "y", "width", "height"]) {
306
+ const drift = Math.abs(actualRect[key] - expectedRect[key]);
307
+ expect(drift, expectedRect.selector + "[" + expectedRect.index + "] " + key + " drift").toBeLessThanOrEqual(tolerance);
308
+ }
309
+ }
310
+ }
311
+ `;
312
+ }
313
+
314
+ interface ComponentVisualSpecInput {
315
+ componentName: string;
316
+ selector: string;
317
+ mountInfo: { props: Record<string, unknown>; slots: Record<string, string> };
318
+ componentImportPath: string;
319
+ referenceHtmlFileName: string;
320
+ viewports: Array<{ name?: string; width: number; height: number }>;
321
+ states: Array<{
322
+ name: string;
323
+ hover?: string;
324
+ focus?: string;
325
+ click?: string;
326
+ waitFor?: string;
327
+ }>;
328
+ assertions: {
329
+ screenshot: boolean;
330
+ layout: boolean;
331
+ layoutTolerance: number;
332
+ selectors: string[];
333
+ screenshotThreshold: number;
334
+ screenshotMaxDiffPixels: number;
335
+ };
336
+ }
337
+
338
+ function emitComponentVisualSpec(input: ComponentVisualSpecInput): string {
339
+ const viewports = JSON.stringify(input.viewports, null, 2);
340
+ const states = JSON.stringify(input.states, null, 2);
341
+ const selectors = JSON.stringify(input.assertions.selectors, null, 2);
342
+ const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
343
+ const layoutEnabled = JSON.stringify(input.assertions.layout);
344
+ const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
345
+ const screenshotThreshold = JSON.stringify(
346
+ input.assertions.screenshotThreshold,
347
+ );
348
+ const screenshotMaxDiffPixels = JSON.stringify(
349
+ input.assertions.screenshotMaxDiffPixels,
350
+ );
351
+ const selector = JSON.stringify(input.selector);
352
+ const mountProps = JSON.stringify(input.mountInfo.props, null, 2);
353
+ const mountSlots = JSON.stringify(input.mountInfo.slots, null, 2);
354
+
355
+ return `import { readFileSync } from "node:fs";
356
+ import { dirname, resolve } from "node:path";
357
+ import { fileURLToPath } from "node:url";
358
+ import { expect, test } from "@playwright/experimental-ct-vue";
359
+ import pixelmatch from "pixelmatch";
360
+ import { PNG } from "pngjs";
361
+ import ${input.componentName} from "${input.componentImportPath}";
362
+
363
+ const currentDir = dirname(fileURLToPath(import.meta.url));
364
+ const referenceHtml = readFileSync(resolve(currentDir, "./${input.referenceHtmlFileName}"), "utf-8");
365
+ const selector = ${selector};
366
+ const viewports = ${viewports};
367
+ const states = ${states};
368
+ const selectors = ${selectors};
369
+ const screenshotEnabled = ${screenshotEnabled};
370
+ const layoutEnabled = ${layoutEnabled};
371
+ const layoutTolerance = ${layoutTolerance};
372
+ const screenshotThreshold = ${screenshotThreshold};
373
+ const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
374
+
375
+ for (const viewport of viewports) {
376
+ for (const state of states) {
377
+ const viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
378
+ test("${input.componentName} matches source at " + viewportName + " / " + state.name, async ({ mount, page }) => {
379
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
380
+
381
+ await page.setContent(referenceHtml);
382
+ await applyState(page, state);
383
+ const expectedEl = page.locator(selector).first();
384
+ const expectedScreenshot = screenshotEnabled ? await expectedEl.screenshot() : undefined;
385
+ const expectedLayout = layoutEnabled ? await readLayout(expectedEl, selectors) : [];
386
+
387
+ const component = await mount(${input.componentName}, {
388
+ props: ${mountProps},
389
+ slots: ${mountSlots},
390
+ });
391
+ await applyState(page, state);
392
+ const actualScreenshot = screenshotEnabled ? await component.screenshot() : undefined;
393
+ const actualLayout = layoutEnabled ? await readLayout(component, selectors) : [];
394
+
395
+ if (screenshotEnabled) {
396
+ compareScreenshots(actualScreenshot, expectedScreenshot, screenshotThreshold, screenshotMaxDiffPixels);
397
+ }
398
+ if (layoutEnabled) {
399
+ expectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
400
+ }
401
+ });
402
+ }
403
+ }
404
+
405
+ async function applyState(page, state) {
406
+ if (state.waitFor) {
407
+ await page.waitForSelector(state.waitFor);
408
+ }
409
+ if (state.hover) {
410
+ await page.hover(state.hover);
411
+ }
412
+ if (state.focus) {
413
+ await page.focus(state.focus);
414
+ }
415
+ if (state.click) {
416
+ await page.click(state.click);
417
+ }
418
+ }
419
+
420
+ async function readLayout(root, selectorsToRead) {
421
+ return root.evaluate((element, values) => {
422
+ const origin = element.getBoundingClientRect();
423
+ return values.flatMap((selector) => {
424
+ const matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
425
+ return matches.map((matchedElement, index) => {
426
+ const rect = matchedElement.getBoundingClientRect();
427
+ return {
428
+ selector,
429
+ index,
430
+ tagName: matchedElement.tagName.toLowerCase(),
431
+ x: rect.x - origin.x,
432
+ y: rect.y - origin.y,
433
+ width: rect.width,
434
+ height: rect.height,
435
+ };
436
+ });
437
+ });
438
+ }, selectorsToRead);
439
+ }
440
+
441
+ function compareScreenshots(actual, expected, threshold, maxDiffPixels) {
442
+ if (!actual || !expected) {
443
+ expect(actual).toEqual(expected);
444
+ return;
445
+ }
446
+ const actualPng = PNG.sync.read(actual);
447
+ const expectedPng = PNG.sync.read(expected);
448
+ expect(actualPng.width, "screenshot width").toBe(expectedPng.width);
449
+ expect(actualPng.height, "screenshot height").toBe(expectedPng.height);
450
+ const diffPixels = pixelmatch(actualPng.data, expectedPng.data, null, actualPng.width, actualPng.height, { threshold });
451
+ expect(diffPixels, "screenshot pixels differing beyond threshold").toBeLessThanOrEqual(maxDiffPixels);
452
+ }
453
+
454
+ function expectLayoutToMatch(actual, expected, tolerance) {
455
+ expect(actual.length).toBe(expected.length);
456
+ for (let index = 0; index < expected.length; index += 1) {
457
+ const actualRect = actual[index];
458
+ const expectedRect = expected[index];
459
+ expect(actualRect.selector).toBe(expectedRect.selector);
460
+ expect(actualRect.index).toBe(expectedRect.index);
461
+ expect(actualRect.tagName).toBe(expectedRect.tagName);
462
+ for (const key of ["x", "y", "width", "height"]) {
463
+ const drift = Math.abs(actualRect[key] - expectedRect[key]);
464
+ expect(drift, expectedRect.selector + "[" + expectedRect.index + "] " + key + " drift").toBeLessThanOrEqual(tolerance);
465
+ }
466
+ }
467
+ }
468
+ `;
469
+ }
470
+
471
+ function emitComponentSplitViews(
472
+ nodes: DesignNode[],
473
+ viewsDir: string,
474
+ api: "composition" | "options" | undefined,
475
+ ): Array<{ path: string; contents: string }> {
476
+ const seen = new Set<string>();
477
+ const files: Array<{ path: string; contents: string }> = [];
478
+
479
+ function visit(node: DesignNode): void {
480
+ if (node.kind === "component") {
481
+ const importName = node.importName ?? node.component ?? "";
482
+ const childrenProp = node.props?.children;
483
+ const innerChildren: DesignNode[] =
484
+ childrenProp?.kind === "children"
485
+ ? childrenProp.value
486
+ : (node.children ?? []);
487
+
488
+ if (importName && !seen.has(importName)) {
489
+ seen.add(importName);
490
+ const funcName = toPascalCase(importName);
491
+ files.push({
492
+ path: `${viewsDir}/${importName}.vue`,
493
+ contents: emitVueView([node], funcName, { api }),
494
+ });
495
+ }
496
+
497
+ for (const child of innerChildren) {
498
+ visit(child);
499
+ }
500
+ } else if (node.kind === "element") {
501
+ for (const child of node.children ?? []) {
502
+ visit(child);
503
+ }
504
+ }
505
+ }
506
+
507
+ for (const node of nodes) {
508
+ visit(node);
509
+ }
510
+ return files;
511
+ }
512
+
513
+ export function emitVueView(
514
+ nodes: DesignNode[],
515
+ _viewName: string,
516
+ options: {
517
+ cssModule?: string;
518
+ api?: "composition" | "options";
519
+ } = {},
520
+ ): string {
521
+ const api = options.api ?? "composition";
522
+ const isComponentImplementation =
523
+ nodes.length === 1 && nodes[0]?.kind === "component";
524
+ const componentNode = isComponentImplementation ? nodes[0] : undefined;
525
+
526
+ let script = "";
527
+ let template = "";
528
+
529
+ if (componentNode) {
530
+ const props = componentNode.props ?? {};
531
+ const source = componentNode.sourceElement;
532
+ const propEntries = Object.entries(props);
533
+
534
+ const attributeBindings = new Map<string, string>();
535
+ const propsDefinitions: string[] = [];
536
+ let childrenPropName: string | undefined;
537
+
538
+ for (const [propName, prop] of propEntries) {
539
+ if (prop.kind === "text" || prop.kind === "children") {
540
+ childrenPropName = propName;
541
+ propsDefinitions.push(`${propName}: {}`);
542
+ continue;
543
+ }
544
+ propsDefinitions.push(`${propName}: String`);
545
+ if (prop.kind === "literal" && prop.attribute) {
546
+ attributeBindings.set(prop.attribute, propName);
547
+ }
548
+ }
549
+
550
+ const imports = collectImports(
551
+ childrenPropName ? [] : (componentNode.children ?? []),
552
+ );
553
+ const importLines = imports
554
+ .map(
555
+ ({ importName, importPath }) =>
556
+ "import " +
557
+ importName +
558
+ ' from "' +
559
+ importPath.replace(/\.(view|tsx)$/, ".vue") +
560
+ '";',
561
+ )
562
+ .join("\n");
563
+
564
+ if (api === "composition") {
565
+ script =
566
+ '<script setup lang="ts">\n' +
567
+ importLines +
568
+ (importLines ? "\n" : "") +
569
+ "defineProps<{\n" +
570
+ Object.keys(props)
571
+ .map((p) => `\t${p}?: any;`)
572
+ .join("\n") +
573
+ "\n}>();\n</script>\n";
574
+ } else {
575
+ script =
576
+ '<script lang="ts">\nimport { defineComponent } from "vue";\n' +
577
+ importLines +
578
+ (importLines ? "\n" : "") +
579
+ "export default defineComponent({\n\tcomponents: { " +
580
+ imports.map((i) => i.importName).join(", ") +
581
+ " },\n\tprops: {\n\t\t" +
582
+ propsDefinitions.join(",\n\t\t") +
583
+ "\n\t}\n});\n</script>\n";
584
+ }
585
+
586
+ template =
587
+ "<template>\n" +
588
+ emitVueComponentBody(
589
+ componentNode,
590
+ source,
591
+ attributeBindings,
592
+ childrenPropName,
593
+ 1,
594
+ ) +
595
+ "</template>\n";
596
+ } else {
597
+ const imports = collectImports(nodes);
598
+ const importLines = imports
599
+ .map(
600
+ ({ importName, importPath }) =>
601
+ "import " +
602
+ importName +
603
+ ' from "' +
604
+ importPath.replace(/\.(view|tsx)$/, ".vue") +
605
+ '";',
606
+ )
607
+ .join("\n");
608
+
609
+ if (api === "composition") {
610
+ script = importLines
611
+ ? `<script setup lang="ts">\n${importLines}\n</script>\n`
612
+ : "";
613
+ } else {
614
+ script =
615
+ '<script lang="ts">\nimport { defineComponent } from "vue";\n' +
616
+ importLines +
617
+ (importLines ? "\n" : "") +
618
+ "export default defineComponent({\n\tcomponents: { " +
619
+ imports.map((i) => i.importName).join(", ") +
620
+ " }\n});\n</script>\n";
621
+ }
622
+
623
+ const body =
624
+ nodes.length === 1 && nodes[0]?.kind !== "text"
625
+ ? emitVueNode(nodes[0], 1)
626
+ : '\t<template v-if="true">\n' +
627
+ nodes.map((node) => emitVueNode(node, 2)).join("") +
628
+ "\t</template>\n";
629
+
630
+ template = `<template>\n${body}</template>\n`;
631
+ }
632
+
633
+ const style = options.cssModule
634
+ ? `\n<style module>\n${options.cssModule}</style>\n`
635
+ : "";
636
+
637
+ return `${script}\n${template}${style}`;
638
+ }
639
+
640
+ function emitVueComponentBody(
641
+ node: DesignNode,
642
+ source: DesignNode | undefined,
643
+ attributeBindings: Map<string, string>,
644
+ childrenPropName: string | undefined,
645
+ depth: number,
646
+ ): string {
647
+ const indent = "\t".repeat(depth);
648
+
649
+ if (!source) {
650
+ const children = node.children ?? [];
651
+ return (
652
+ indent +
653
+ '<template v-if="true">\n' +
654
+ children.map((child) => emitVueNode(child, depth + 1)).join("") +
655
+ indent +
656
+ "</template>\n"
657
+ );
658
+ }
659
+
660
+ const tagName = source.tagName ?? "div";
661
+ const attributes = emitVueAttributes(
662
+ source.attributes ?? {},
663
+ source.styles ?? {},
664
+ source.generatedClassNames ?? [],
665
+ attributeBindings,
666
+ );
667
+ const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
668
+
669
+ if (childrenPropName) {
670
+ const inner =
671
+ "\t".repeat(depth + 1) +
672
+ '<slot name="' +
673
+ childrenPropName +
674
+ '">{{ ' +
675
+ childrenPropName +
676
+ " }}</slot>\n";
677
+ return `${indent + openTag}\n${inner}${indent}</${tagName}>\n`;
678
+ }
679
+
680
+ const children = node.children ?? [];
681
+ if (children.length === 0) {
682
+ return `${indent + openTag}</${tagName}>\n`;
683
+ }
684
+
685
+ return (
686
+ indent +
687
+ openTag +
688
+ "\n" +
689
+ children.map((child) => emitVueNode(child, depth + 1)).join("") +
690
+ indent +
691
+ "</" +
692
+ tagName +
693
+ ">\n"
694
+ );
695
+ }
696
+
697
+ function emitVueNode(node: DesignNode | undefined, depth: number): string {
698
+ if (!node) {
699
+ return "";
700
+ }
701
+ const indent = "\t".repeat(depth);
702
+ if (node.kind === "text") {
703
+ return `${indent + escapeHtml(node.text ?? "")}\n`;
704
+ }
705
+ if (node.kind === "component") {
706
+ return emitVueComponentJsx(node, depth);
707
+ }
708
+
709
+ const tagName = node.tagName ?? "div";
710
+ const attributes = emitVueAttributes(
711
+ node.attributes ?? {},
712
+ node.styles ?? {},
713
+ node.generatedClassNames ?? [],
714
+ );
715
+ const children = node.children ?? [];
716
+ const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
717
+ if (children.length === 0) {
718
+ return `${indent + openTag}</${tagName}>\n`;
719
+ }
720
+
721
+ return (
722
+ indent +
723
+ openTag +
724
+ "\n" +
725
+ children.map((child) => emitVueNode(child, depth + 1)).join("") +
726
+ indent +
727
+ "</" +
728
+ tagName +
729
+ ">\n"
730
+ );
731
+ }
732
+
733
+ function emitVueComponentJsx(node: DesignNode, depth: number): string {
734
+ const indent = "\t".repeat(depth);
735
+ const component = node.component ?? node.importName ?? "Component";
736
+ const childrenProp = node.props?.children;
737
+ const attributes = Object.entries(node.props ?? {})
738
+ .filter(([name]) => name !== "children")
739
+ .sort(([left], [right]) => left.localeCompare(right))
740
+ .map(([name, prop]) => emitVueProp(name, prop))
741
+ .join(" ");
742
+ const openTag = attributes
743
+ ? `<${component} ${attributes}>`
744
+ : `<${component}>`;
745
+
746
+ if (childrenProp?.kind === "text") {
747
+ return (
748
+ indent +
749
+ openTag +
750
+ "\n" +
751
+ "\t".repeat(depth + 1) +
752
+ "<template #children>" +
753
+ escapeHtml(childrenProp.value) +
754
+ "</template>\n" +
755
+ indent +
756
+ "</" +
757
+ component +
758
+ ">\n"
759
+ );
760
+ }
761
+ if (childrenProp?.kind === "children") {
762
+ return (
763
+ indent +
764
+ openTag +
765
+ "\n" +
766
+ "\t".repeat(depth + 1) +
767
+ "<template #children>\n" +
768
+ childrenProp.value
769
+ .map((child) => emitVueNode(child, depth + 2))
770
+ .join("") +
771
+ "\t".repeat(depth + 1) +
772
+ "</template>\n" +
773
+ indent +
774
+ "</" +
775
+ component +
776
+ ">\n"
777
+ );
778
+ }
779
+ const children = node.children ?? [];
780
+ if (children.length === 0) {
781
+ return `${indent + openTag}</${component}>\n`;
782
+ }
783
+ return (
784
+ indent +
785
+ openTag +
786
+ "\n" +
787
+ children.map((child) => emitVueNode(child, depth + 1)).join("") +
788
+ indent +
789
+ "</" +
790
+ component +
791
+ ">\n"
792
+ );
793
+ }
794
+
795
+ function emitVueProp(name: string, prop: PropValue): string {
796
+ if (prop.kind === "children") {
797
+ return "";
798
+ }
799
+ if (typeof prop.value === "boolean" || typeof prop.value === "number") {
800
+ return `:${name}="${JSON.stringify(prop.value).replace(/"/g, "'")}"`;
801
+ }
802
+ return `${name}="${escapeAttribute(prop.value)}"`;
803
+ }
804
+
805
+ function emitVueAttributes(
806
+ attributes: Record<string, string>,
807
+ styles: Record<string, string>,
808
+ generatedClassNames: string[] = [],
809
+ attributeBindings: Map<string, string> = new Map(),
810
+ ): string {
811
+ const mergedAttributes = { ...attributes };
812
+ const classNames = [
813
+ ...(attributes.class ?? "").split(/\s+/).filter(Boolean),
814
+ ...generatedClassNames,
815
+ ];
816
+ if (classNames.length > 0) {
817
+ mergedAttributes.class = classNames.join(" ");
818
+ }
819
+
820
+ const result = Object.entries(mergedAttributes)
821
+ .filter(([name]) => name !== "style")
822
+ .sort(([left], [right]) => left.localeCompare(right))
823
+ .map(([name, value]) => {
824
+ const binding = attributeBindings.get(name);
825
+ if (binding) {
826
+ return `:${name}="${binding}"`;
827
+ }
828
+ if (value === "") {
829
+ return name;
830
+ }
831
+ if (name === "class" && generatedClassNames.some(isCssModuleReference)) {
832
+ return `:class="${emitVueClassNameExpression(classNames)}"`;
833
+ }
834
+ return `${name}="${escapeAttribute(value)}"`;
835
+ });
836
+
837
+ const styleAttr = emitVueStyleAttribute(styles);
838
+ if (styleAttr) {
839
+ result.push(styleAttr);
840
+ }
841
+
842
+ return result.join(" ");
843
+ }
844
+
845
+ function emitVueClassNameExpression(classNames: string[]): string {
846
+ return (
847
+ "[" +
848
+ classNames
849
+ .map((className) =>
850
+ isCssModuleReference(className)
851
+ ? `$style.${className.slice(7)}`
852
+ : JSON.stringify(className),
853
+ )
854
+ .join(", ") +
855
+ "].filter(Boolean).join(' ')"
856
+ );
857
+ }
858
+
859
+ function emitVueStyleAttribute(
860
+ styles: Record<string, string>,
861
+ ): string | undefined {
862
+ const entries = Object.entries(styles).sort(([left], [right]) =>
863
+ left.localeCompare(right),
864
+ );
865
+ if (entries.length === 0) {
866
+ return undefined;
867
+ }
868
+ const styleObject = entries
869
+ .map(
870
+ ([property, value]) =>
871
+ "'" +
872
+ toCamelCase(property) +
873
+ "': " +
874
+ JSON.stringify(value).replace(/"/g, "'"),
875
+ )
876
+ .join(", ");
877
+ return `:style="{ ${styleObject} }"`;
878
+ }
879
+
880
+ function findNodeBySelector(
881
+ nodes: DesignNode[],
882
+ selector: string,
883
+ ): DesignNode | undefined {
884
+ const parsedSelector = parseSelector(selector);
885
+ if (!parsedSelector) return undefined;
886
+ const ps = parsedSelector;
887
+ function search(list: DesignNode[]): DesignNode | undefined {
888
+ for (const node of list) {
889
+ if (matchesSelector(node, ps)) return node;
890
+ const found = search(node.children ?? []);
891
+ if (found) return found;
892
+ }
893
+ return undefined;
894
+ }
895
+ return search(nodes);
896
+ }
897
+
898
+ const VOID_ELEMENTS = new Set([
899
+ "area",
900
+ "base",
901
+ "br",
902
+ "col",
903
+ "embed",
904
+ "hr",
905
+ "img",
906
+ "input",
907
+ "link",
908
+ "meta",
909
+ "param",
910
+ "source",
911
+ "track",
912
+ "wbr",
913
+ ]);
914
+
915
+ function serializeNodeToHtml(node: DesignNode): string {
916
+ if (node.kind === "text") return node.text ?? "";
917
+ if (node.kind !== "element") return "";
918
+ const tagName = node.tagName ?? "div";
919
+ const attrs = Object.entries(node.attributes ?? {})
920
+ .map(([name, value]) =>
921
+ value === "" ? name : `${name}="${value.replace(/"/g, "&quot;")}"`,
922
+ )
923
+ .join(" ");
924
+ const openTag = attrs ? `<${tagName} ${attrs}>` : `<${tagName}>`;
925
+ if (VOID_ELEMENTS.has(tagName)) return openTag;
926
+ const children = (node.children ?? []).map(serializeNodeToHtml).join("");
927
+ return `${openTag}${children}</${tagName}>`;
928
+ }
929
+
930
+ function toPascalCase(value: string): string {
931
+ return value
932
+ .split(/[-_\s]+/)
933
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
934
+ .join("");
935
+ }
936
+
937
+ function toCamelCase(value: string): string {
938
+ return value.replace(/-([a-z])/g, (_, letter: string) =>
939
+ letter.toUpperCase(),
940
+ );
941
+ }
942
+
943
+ function toRelativeImport(fromFile: string, toFile: string): string {
944
+ const fromParts = fromFile.split("/").slice(0, -1);
945
+ const toParts = toFile.split("/");
946
+ while (
947
+ fromParts.length > 0 &&
948
+ toParts.length > 0 &&
949
+ fromParts[0] === toParts[0]
950
+ ) {
951
+ fromParts.shift();
952
+ toParts.shift();
953
+ }
954
+ const prefix = fromParts.map(() => "..");
955
+ const relative = prefix.concat(toParts).join("/");
956
+ return relative.startsWith(".") ? relative : `./${relative}`;
957
+ }
958
+
959
+ function isCssModuleReference(className: string): boolean {
960
+ return className.startsWith("module:");
961
+ }
962
+
963
+ function escapeHtml(value: string): string {
964
+ return value
965
+ .replace(/&/g, "&amp;")
966
+ .replace(/</g, "&lt;")
967
+ .replace(/>/g, "&gt;");
968
+ }
969
+
970
+ function escapeAttribute(value: string): string {
971
+ return escapeHtml(value).replace(/"/g, "&quot;");
972
+ }
973
+
974
+ /**
975
+ * Walks a mapped AST and returns the first component node seen for each
976
+ * component name, so the test generator can mount components with the same
977
+ * props the design supplies.
978
+ */
979
+ function collectComponentNodes(nodes: DesignNode[]): Map<string, DesignNode> {
980
+ const map = new Map<string, DesignNode>();
981
+ function visit(list: DesignNode[]): void {
982
+ for (const node of list) {
983
+ if (node.kind === "component") {
984
+ const name = node.component ?? node.importName;
985
+ if (name && !map.has(name)) {
986
+ map.set(name, node);
987
+ }
988
+ const childrenProp = node.props?.children;
989
+ visit(
990
+ childrenProp?.kind === "children"
991
+ ? childrenProp.value
992
+ : (node.children ?? []),
993
+ );
994
+ } else if (node.kind === "element") {
995
+ visit(node.children ?? []);
996
+ }
997
+ }
998
+ }
999
+ visit(nodes);
1000
+ return map;
1001
+ }
1002
+
1003
+ function emitComponentMountInfo(
1004
+ _componentName: string,
1005
+ node: DesignNode | undefined,
1006
+ ): { props: Record<string, unknown>; slots: Record<string, string> } {
1007
+ const props: Record<string, unknown> = {};
1008
+ const slots: Record<string, string> = {};
1009
+ for (const [propName, prop] of Object.entries(node?.props ?? {})) {
1010
+ if (prop.kind === "text") {
1011
+ slots[propName] = prop.value;
1012
+ continue;
1013
+ }
1014
+ if (prop.kind === "children") {
1015
+ slots[propName] = prop.value
1016
+ .map((child) => emitInlineVue(child))
1017
+ .join("");
1018
+ continue;
1019
+ }
1020
+ props[propName] = prop.value;
1021
+ }
1022
+ return { props, slots };
1023
+ }
1024
+
1025
+ function emitInlineVue(node: DesignNode): string {
1026
+ if (node.kind === "text") {
1027
+ return escapeHtml(node.text ?? "");
1028
+ }
1029
+ if (node.kind === "component") {
1030
+ const info = emitComponentMountInfo(node.component ?? "Component", node);
1031
+ const propsStr = Object.entries(info.props)
1032
+ .map(([k, v]) => `${k}="${String(v)}"`)
1033
+ .join(" ");
1034
+ const slotsStr = Object.entries(info.slots)
1035
+ .map(([k, v]) => `<template #${k}>${v}</template>`)
1036
+ .join("");
1037
+ return (
1038
+ "<" +
1039
+ node.component +
1040
+ " " +
1041
+ propsStr +
1042
+ ">" +
1043
+ slotsStr +
1044
+ "</" +
1045
+ node.component +
1046
+ ">"
1047
+ );
1048
+ }
1049
+ const tagName = node.tagName ?? "div";
1050
+ const attributes = emitVueAttributes(
1051
+ node.attributes ?? {},
1052
+ node.styles ?? {},
1053
+ node.generatedClassNames ?? [],
1054
+ );
1055
+ const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`;
1056
+ const children = (node.children ?? [])
1057
+ .map((child) => emitInlineVue(child))
1058
+ .join("");
1059
+ return `${openTag + children}</${tagName}>`;
1060
+ }
1061
+
1062
+ // Reuse logic from target-react where applicable or implement similar
1063
+ // The following functions are copied and adapted from target-react
1064
+
1065
+ interface StyleTransformResult {
1066
+ nodes: DesignNode[];
1067
+ cssModule?: string;
1068
+ }
1069
+
1070
+ interface TokenMatch {
1071
+ group: string;
1072
+ name: string;
1073
+ value: string;
1074
+ }
1075
+
1076
+ function transformStyles(
1077
+ nodes: DesignNode[],
1078
+ css: string | undefined,
1079
+ config: DesignEmbedConfig | undefined,
1080
+ diagnostics: Diagnostic[],
1081
+ ): StyleTransformResult {
1082
+ const styleMode = config?.output?.styleMode ?? "inline";
1083
+ const cssRules = parseCssRules(css, diagnostics);
1084
+ const resolvedNodes = resolveCssStyles(nodes, cssRules);
1085
+
1086
+ if (styleMode === "inline") {
1087
+ return {
1088
+ nodes: mapStyleNodes(resolvedNodes, (node) => ({
1089
+ ...node,
1090
+ styles: snapStyleValues(node.styles ?? {}, config, diagnostics, node),
1091
+ })),
1092
+ };
1093
+ }
1094
+
1095
+ if (styleMode === "tailwind") {
1096
+ return {
1097
+ nodes: mapStyleNodes(resolvedNodes, (node) =>
1098
+ applyTailwindStyles(node, config, diagnostics),
1099
+ ),
1100
+ };
1101
+ }
1102
+
1103
+ if (styleMode === "css-modules") {
1104
+ const rules: string[] = [];
1105
+ let index = 0;
1106
+ const moduleNodes = mapStyleNodes(resolvedNodes, (node) => {
1107
+ const snapped = snapStyleValues(
1108
+ node.styles ?? {},
1109
+ config,
1110
+ diagnostics,
1111
+ node,
1112
+ );
1113
+ if (Object.keys(snapped).length === 0) {
1114
+ return { ...node, styles: snapped };
1115
+ }
1116
+ index += 1;
1117
+ const className = `style${index}`;
1118
+ rules.push(emitCssModuleRule(className, snapped));
1119
+ return {
1120
+ ...node,
1121
+ styles: {},
1122
+ generatedClassNames: [
1123
+ ...(node.generatedClassNames ?? []),
1124
+ `module:${className}`,
1125
+ ],
1126
+ };
1127
+ });
1128
+ return {
1129
+ nodes: moduleNodes,
1130
+ cssModule: rules.length > 0 ? `${rules.join("\n\n")}\n` : undefined,
1131
+ };
1132
+ }
1133
+
1134
+ diagnostics.push({
1135
+ code: "STYLE_MODE_UNSUPPORTED",
1136
+ message: `Unsupported style mode: ${styleMode}`,
1137
+ severity: "error",
1138
+ });
1139
+ return { nodes: resolvedNodes };
1140
+ }
1141
+
1142
+ // Parser and resolver functions (same as React target)
1143
+ function parseInlineStyle(style: string | undefined): Record<string, string> {
1144
+ const styles: Record<string, string> = {};
1145
+ if (!style) {
1146
+ return styles;
1147
+ }
1148
+ for (const declaration of style.split(";")) {
1149
+ const [property, ...valueParts] = declaration.split(":");
1150
+ const value = valueParts.join(":").trim();
1151
+ if (!property?.trim() || !value) {
1152
+ continue;
1153
+ }
1154
+ styles[property.trim().toLowerCase()] = value;
1155
+ }
1156
+ return styles;
1157
+ }
1158
+
1159
+ interface ParsedSelector {
1160
+ tagName?: string;
1161
+ id?: string;
1162
+ classes: string[];
1163
+ attributes: Record<string, string>;
1164
+ }
1165
+
1166
+ function parseSelector(selector: string): ParsedSelector | undefined {
1167
+ const trimmed = selector.trim();
1168
+ if (!trimmed || /[\s>+~,:]/.test(trimmed)) {
1169
+ return undefined;
1170
+ }
1171
+ const parsed: ParsedSelector = { classes: [], attributes: {} };
1172
+ let rest = trimmed;
1173
+ const tagMatch = rest.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
1174
+ if (tagMatch?.[0]) {
1175
+ parsed.tagName = tagMatch[0].toLowerCase();
1176
+ rest = rest.slice(tagMatch[0].length);
1177
+ }
1178
+ while (rest) {
1179
+ if (rest.startsWith(".")) {
1180
+ const match = rest.match(/^\.([a-zA-Z_][a-zA-Z0-9_-]*)/);
1181
+ if (!match?.[1]) {
1182
+ return undefined;
1183
+ }
1184
+ parsed.classes.push(match[1]);
1185
+ rest = rest.slice(match[0].length);
1186
+ continue;
1187
+ }
1188
+ if (rest.startsWith("#")) {
1189
+ const match = rest.match(/^#([a-zA-Z_][a-zA-Z0-9_-]*)/);
1190
+ if (!match?.[1] || parsed.id) {
1191
+ return undefined;
1192
+ }
1193
+ parsed.id = match[1];
1194
+ rest = rest.slice(match[0].length);
1195
+ continue;
1196
+ }
1197
+ if (rest.startsWith("[")) {
1198
+ const match = rest.match(
1199
+ /^\[([a-zA-Z_][a-zA-Z0-9_.:-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]+)))?\]/,
1200
+ );
1201
+ if (!match?.[1]) {
1202
+ return undefined;
1203
+ }
1204
+ parsed.attributes[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
1205
+ rest = rest.slice(match[0].length);
1206
+ continue;
1207
+ }
1208
+ return undefined;
1209
+ }
1210
+ return parsed;
1211
+ }
1212
+
1213
+ function matchesSelector(node: DesignNode, selector: ParsedSelector): boolean {
1214
+ if (node.kind !== "element") {
1215
+ return false;
1216
+ }
1217
+ const attributes = node.attributes ?? {};
1218
+ if (selector.tagName && node.tagName !== selector.tagName) {
1219
+ return false;
1220
+ }
1221
+ if (selector.id && attributes.id !== selector.id) {
1222
+ return false;
1223
+ }
1224
+ const classNames = new Set(
1225
+ (attributes.class ?? "").split(/\s+/).filter(Boolean),
1226
+ );
1227
+ for (const className of selector.classes) {
1228
+ if (!classNames.has(className)) {
1229
+ return false;
1230
+ }
1231
+ }
1232
+ for (const [name, value] of Object.entries(selector.attributes)) {
1233
+ if (!(name in attributes)) {
1234
+ return false;
1235
+ }
1236
+ if (value !== "" && attributes[name] !== value) {
1237
+ return false;
1238
+ }
1239
+ }
1240
+ return true;
1241
+ }
1242
+
1243
+ interface CssRule {
1244
+ selector: string;
1245
+ declarations: Record<string, string>;
1246
+ order: number;
1247
+ }
1248
+
1249
+ function parseCssRules(
1250
+ css: string | undefined,
1251
+ diagnostics: Diagnostic[],
1252
+ ): CssRule[] {
1253
+ if (!css?.trim()) {
1254
+ return [];
1255
+ }
1256
+ const rules: CssRule[] = [];
1257
+ let order = 0;
1258
+ for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
1259
+ const selectorText = match[1]?.trim() ?? "";
1260
+ const declarations = parseInlineStyle(match[2]);
1261
+ for (const selector of selectorText.split(",").map((item) => item.trim())) {
1262
+ if (!selector) {
1263
+ continue;
1264
+ }
1265
+ if (!parseSelector(selector)) {
1266
+ diagnostics.push({
1267
+ code: "CSS_SELECTOR_UNSUPPORTED",
1268
+ message: `Unsupported CSS selector: ${selector}`,
1269
+ severity: "warning",
1270
+ selector,
1271
+ });
1272
+ continue;
1273
+ }
1274
+ rules.push({ selector, declarations, order });
1275
+ order += 1;
1276
+ }
1277
+ }
1278
+ return rules;
1279
+ }
1280
+
1281
+ function resolveCssStyles(nodes: DesignNode[], rules: CssRule[]): DesignNode[] {
1282
+ return nodes.map((node) => {
1283
+ if (node.kind !== "element") {
1284
+ return node;
1285
+ }
1286
+ const matchedDeclarations = rules
1287
+ .filter((rule) => {
1288
+ const selector = parseSelector(rule.selector);
1289
+ return selector ? matchesSelector(node, selector) : false;
1290
+ })
1291
+ .sort((left, right) => left.order - right.order);
1292
+ const stylesFromCss: Record<string, string> = {};
1293
+ for (const rule of matchedDeclarations) {
1294
+ Object.assign(stylesFromCss, rule.declarations);
1295
+ }
1296
+ return {
1297
+ ...node,
1298
+ styles: { ...stylesFromCss, ...(node.styles ?? {}) },
1299
+ children: resolveCssStyles(node.children ?? [], rules),
1300
+ };
1301
+ });
1302
+ }
1303
+
1304
+ function mapStyleNodes(
1305
+ nodes: DesignNode[],
1306
+ mapper: (node: DesignNode) => DesignNode,
1307
+ ): DesignNode[] {
1308
+ return nodes.map((node) => {
1309
+ if (node.kind !== "element") {
1310
+ return node;
1311
+ }
1312
+ return mapper({
1313
+ ...node,
1314
+ children: mapStyleNodes(node.children ?? [], mapper),
1315
+ });
1316
+ });
1317
+ }
1318
+
1319
+ function snapStyleValues(
1320
+ styles: Record<string, string>,
1321
+ config: DesignEmbedConfig | undefined,
1322
+ diagnostics: Diagnostic[],
1323
+ node: DesignNode,
1324
+ ): Record<string, string> {
1325
+ const snapped: Record<string, string> = {};
1326
+ for (const [property, value] of sortedEntries(styles)) {
1327
+ const match = matchToken(property, value, config, diagnostics, node);
1328
+ snapped[property] = match?.value ?? value;
1329
+ }
1330
+ return snapped;
1331
+ }
1332
+
1333
+ function matchToken(
1334
+ property: string,
1335
+ value: string,
1336
+ config: DesignEmbedConfig | undefined,
1337
+ diagnostics: Diagnostic[],
1338
+ node: DesignNode,
1339
+ ): TokenMatch | undefined {
1340
+ const group = tokenGroupForProperty(property);
1341
+ if (!group) {
1342
+ diagnostics.push({
1343
+ code: "STYLE_UNSUPPORTED_PROPERTY",
1344
+ message: `No token group is configured for CSS property "${property}".`,
1345
+ severity: "info",
1346
+ source: node.source,
1347
+ property,
1348
+ });
1349
+ return undefined;
1350
+ }
1351
+ if (group === "colors") {
1352
+ return matchColorToken(property, value, config, diagnostics, node);
1353
+ }
1354
+ if (group === "shadow") {
1355
+ return matchStringToken(property, value, config?.tokens?.shadow, group);
1356
+ }
1357
+ const tokenValues =
1358
+ group === "spacing"
1359
+ ? config?.tokens?.spacing?.values
1360
+ : group === "sizing"
1361
+ ? config?.tokens?.sizing?.values
1362
+ : group === "typography"
1363
+ ? config?.tokens?.typography?.values
1364
+ : group === "radius"
1365
+ ? config?.tokens?.radius
1366
+ : config?.tokens?.borderWidth;
1367
+ const unit =
1368
+ group === "spacing"
1369
+ ? (config?.tokens?.spacing?.unit ?? "px")
1370
+ : group === "sizing"
1371
+ ? (config?.tokens?.sizing?.unit ?? "px")
1372
+ : group === "typography"
1373
+ ? (config?.tokens?.typography?.unit ?? "px")
1374
+ : "px";
1375
+ const threshold =
1376
+ group === "spacing"
1377
+ ? (config?.tokens?.spacing?.threshold ?? 0)
1378
+ : group === "sizing"
1379
+ ? (config?.tokens?.sizing?.threshold ?? 0)
1380
+ : group === "typography"
1381
+ ? (config?.tokens?.typography?.threshold ?? 0)
1382
+ : 0;
1383
+ return matchNumericToken(
1384
+ property,
1385
+ value,
1386
+ tokenValues,
1387
+ group,
1388
+ unit,
1389
+ threshold,
1390
+ diagnostics,
1391
+ node,
1392
+ );
1393
+ }
1394
+
1395
+ function tokenGroupForProperty(property: string): string | undefined {
1396
+ if (/^(margin|padding)(-|$)|^gap$|^row-gap$|^column-gap$/.test(property)) {
1397
+ return "spacing";
1398
+ }
1399
+ if (
1400
+ /^(width|height|min-width|min-height|max-width|max-height)$/.test(property)
1401
+ ) {
1402
+ return "sizing";
1403
+ }
1404
+ if (/^(font-size|line-height|font-weight)$/.test(property)) {
1405
+ return "typography";
1406
+ }
1407
+ if (property === "border-radius") {
1408
+ return "radius";
1409
+ }
1410
+ if (property === "border-width") {
1411
+ return "borderWidth";
1412
+ }
1413
+ if (property === "box-shadow") {
1414
+ return "shadow";
1415
+ }
1416
+ if (
1417
+ property === "color" ||
1418
+ property === "background" ||
1419
+ property === "background-color" ||
1420
+ property === "border-color"
1421
+ ) {
1422
+ return "colors";
1423
+ }
1424
+ return undefined;
1425
+ }
1426
+
1427
+ function matchNumericToken(
1428
+ property: string,
1429
+ value: string,
1430
+ tokens: Record<string, number> | undefined,
1431
+ group: string,
1432
+ unit: "px" | "rem",
1433
+ threshold: number,
1434
+ diagnostics: Diagnostic[],
1435
+ node: DesignNode,
1436
+ ): TokenMatch | undefined {
1437
+ if (!tokens) {
1438
+ return undefined;
1439
+ }
1440
+ const parsed = value.match(/^(-?\d+(?:\.\d+)?)(px|rem)?$/);
1441
+ if (!parsed?.[1]) {
1442
+ return undefined;
1443
+ }
1444
+ const numericValue = Number(parsed[1]);
1445
+ const candidates = sortedEntries(tokens)
1446
+ .map(([name, tokenValue]) => ({
1447
+ name,
1448
+ tokenValue,
1449
+ distance: Math.abs(tokenValue - numericValue),
1450
+ }))
1451
+ .filter(({ distance }) => distance <= threshold)
1452
+ .sort(
1453
+ (left, right) =>
1454
+ left.distance - right.distance || left.name.localeCompare(right.name),
1455
+ );
1456
+ if (candidates.length === 0) {
1457
+ diagnostics.push({
1458
+ code: "TOKEN_NO_MATCH",
1459
+ message: `${property}: ${value} did not match a ${group} token.`,
1460
+ severity: "info",
1461
+ source: node.source,
1462
+ property,
1463
+ });
1464
+ return undefined;
1465
+ }
1466
+ if (
1467
+ candidates.length > 1 &&
1468
+ candidates[0]?.distance === candidates[1]?.distance
1469
+ ) {
1470
+ diagnostics.push({
1471
+ code: "TOKEN_AMBIGUOUS_MATCH",
1472
+ message: `${property}: ${value} matches multiple ${group} tokens.`,
1473
+ severity: "error",
1474
+ source: node.source,
1475
+ property,
1476
+ });
1477
+ return undefined;
1478
+ }
1479
+ const candidate = candidates[0];
1480
+ if (!candidate) {
1481
+ return undefined;
1482
+ }
1483
+ return {
1484
+ group,
1485
+ name: candidate.name,
1486
+ value: formatNumber(candidate.tokenValue) + unit,
1487
+ };
1488
+ }
1489
+
1490
+ function matchColorToken(
1491
+ property: string,
1492
+ value: string,
1493
+ config: DesignEmbedConfig | undefined,
1494
+ diagnostics: Diagnostic[],
1495
+ node: DesignNode,
1496
+ ): TokenMatch | undefined {
1497
+ const tokens = config?.tokens?.colors;
1498
+ if (!tokens) {
1499
+ return undefined;
1500
+ }
1501
+ const color = parseColor(value);
1502
+ if (!color) {
1503
+ diagnostics.push({
1504
+ code: "COLOR_PARSE_FAILED",
1505
+ message: `Could not parse color value: ${value}`,
1506
+ severity: "warning",
1507
+ source: node.source,
1508
+ property,
1509
+ });
1510
+ return undefined;
1511
+ }
1512
+ const threshold = config?.tokens?.colorThreshold ?? 0;
1513
+ const candidates = sortedEntries(tokens)
1514
+ .map(([name, tokenValue]) => {
1515
+ const tokenColor = parseColor(tokenValue);
1516
+ return tokenColor
1517
+ ? { name, tokenValue, distance: colorDistance(color, tokenColor) }
1518
+ : undefined;
1519
+ })
1520
+ .filter(
1521
+ (
1522
+ candidate,
1523
+ ): candidate is {
1524
+ name: string;
1525
+ tokenValue: string;
1526
+ distance: number;
1527
+ } => Boolean(candidate && candidate.distance <= threshold),
1528
+ )
1529
+ .sort(
1530
+ (left, right) =>
1531
+ left.distance - right.distance || left.name.localeCompare(right.name),
1532
+ );
1533
+ if (candidates.length === 0) {
1534
+ diagnostics.push({
1535
+ code: "TOKEN_NO_MATCH",
1536
+ message: `${property}: ${value} did not match a color token.`,
1537
+ severity: "info",
1538
+ source: node.source,
1539
+ property,
1540
+ });
1541
+ return undefined;
1542
+ }
1543
+ if (
1544
+ candidates.length > 1 &&
1545
+ candidates[0]?.distance === candidates[1]?.distance
1546
+ ) {
1547
+ diagnostics.push({
1548
+ code: "TOKEN_AMBIGUOUS_MATCH",
1549
+ message: `${property}: ${value} matches multiple color tokens.`,
1550
+ severity: "error",
1551
+ source: node.source,
1552
+ property,
1553
+ });
1554
+ return undefined;
1555
+ }
1556
+ const candidate = candidates[0];
1557
+ if (!candidate) {
1558
+ return undefined;
1559
+ }
1560
+ return {
1561
+ group: "colors",
1562
+ name: candidate.name,
1563
+ value: normalizeHex(candidate.tokenValue),
1564
+ };
1565
+ }
1566
+
1567
+ function matchStringToken(
1568
+ _property: string,
1569
+ value: string,
1570
+ tokens: Record<string, string> | undefined,
1571
+ group: string,
1572
+ ): TokenMatch | undefined {
1573
+ const match = sortedEntries(tokens ?? {}).find(
1574
+ ([, tokenValue]) => tokenValue === value,
1575
+ );
1576
+ if (!match) {
1577
+ return undefined;
1578
+ }
1579
+ return { group, name: match[0], value: match[1] };
1580
+ }
1581
+
1582
+ function parseColor(value: string): [number, number, number] | undefined {
1583
+ const trimmed = value.trim();
1584
+ const hex = trimmed.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
1585
+ if (hex?.[1]) {
1586
+ const expanded =
1587
+ hex[1].length === 3
1588
+ ? hex[1]
1589
+ .split("")
1590
+ .map((part) => part + part)
1591
+ .join("")
1592
+ : hex[1];
1593
+ return [
1594
+ Number.parseInt(expanded.slice(0, 2), 16),
1595
+ Number.parseInt(expanded.slice(2, 4), 16),
1596
+ Number.parseInt(expanded.slice(4, 6), 16),
1597
+ ];
1598
+ }
1599
+ const rgb = trimmed.match(
1600
+ /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i,
1601
+ );
1602
+ if (rgb?.[1] && rgb[2] && rgb[3]) {
1603
+ return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
1604
+ }
1605
+ return undefined;
1606
+ }
1607
+
1608
+ function colorDistance(
1609
+ left: [number, number, number],
1610
+ right: [number, number, number],
1611
+ ): number {
1612
+ return Math.sqrt(
1613
+ (left[0] - right[0]) ** 2 +
1614
+ (left[1] - right[1]) ** 2 +
1615
+ (left[2] - right[2]) ** 2,
1616
+ );
1617
+ }
1618
+
1619
+ function normalizeHex(value: string): string {
1620
+ const color = parseColor(value);
1621
+ if (!color) {
1622
+ return value;
1623
+ }
1624
+ return `#${color.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
1625
+ }
1626
+
1627
+ function applyTailwindStyles(
1628
+ node: DesignNode,
1629
+ config: DesignEmbedConfig | undefined,
1630
+ diagnostics: Diagnostic[],
1631
+ ): DesignNode {
1632
+ const remaining: Record<string, string> = {};
1633
+ const generatedClassNames = [...(node.generatedClassNames ?? [])];
1634
+ for (const [property, value] of sortedEntries(node.styles ?? {})) {
1635
+ const match = matchToken(property, value, config, diagnostics, node);
1636
+ if (!match) {
1637
+ remaining[property] = value;
1638
+ continue;
1639
+ }
1640
+ const className =
1641
+ config?.styleMappings?.[match.group]?.[
1642
+ `${property}:${match.group}.${match.name}`
1643
+ ];
1644
+ if (className) {
1645
+ generatedClassNames.push(className);
1646
+ } else {
1647
+ remaining[property] = match.value;
1648
+ diagnostics.push({
1649
+ code: "TOKEN_NO_MATCH",
1650
+ message:
1651
+ "No Tailwind mapping for " +
1652
+ property +
1653
+ ":" +
1654
+ match.group +
1655
+ "." +
1656
+ match.name +
1657
+ ".",
1658
+ severity: "info",
1659
+ source: node.source,
1660
+ property,
1661
+ });
1662
+ }
1663
+ }
1664
+ return { ...node, styles: remaining, generatedClassNames };
1665
+ }
1666
+
1667
+ function emitCssModuleRule(
1668
+ className: string,
1669
+ styles: Record<string, string>,
1670
+ ): string {
1671
+ const declarations = sortedEntries(styles)
1672
+ .map(([property, value]) => `\t${property}: ${value};`)
1673
+ .join("\n");
1674
+ return `.${className} {\n${declarations}\n}`;
1675
+ }
1676
+
1677
+ function sortedEntries<T>(record: Record<string, T>): Array<[string, T]> {
1678
+ return Object.entries(record).sort(([left], [right]) =>
1679
+ left.localeCompare(right),
1680
+ );
1681
+ }
1682
+
1683
+ function formatNumber(value: number): string {
1684
+ return Number.isInteger(value)
1685
+ ? String(value)
1686
+ : String(Number(value.toFixed(4)));
1687
+ }
1688
+
1689
+ function collectImports(
1690
+ nodes: DesignNode[],
1691
+ ): Array<{ importName: string; importPath: string }> {
1692
+ const imports = new Map<string, { importName: string; importPath: string }>();
1693
+ function visit(node: DesignNode) {
1694
+ if (node.kind === "component" && node.importName && node.importPath) {
1695
+ imports.set(`${node.importPath}:${node.importName}`, {
1696
+ importName: node.importName,
1697
+ importPath: node.importPath,
1698
+ });
1699
+ }
1700
+ for (const child of node.children ?? []) visit(child);
1701
+ for (const prop of Object.values(node.props ?? {})) {
1702
+ if (prop.kind === "children")
1703
+ for (const child of prop.value) visit(child);
1704
+ }
1705
+ }
1706
+ for (const node of nodes) visit(node);
1707
+ return [...imports.values()].sort((a, b) =>
1708
+ a.importPath.localeCompare(b.importPath),
1709
+ );
1710
+ }