@design-embed/target-vanjs 1.0.0

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