design-embed 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +98 -2
  3. package/dist/cli.d.mts +1 -0
  4. package/dist/cli.mjs +273 -0
  5. package/dist/core-BLV62TaX.mjs +907 -0
  6. package/dist/index.d.mts +273 -0
  7. package/dist/index.mjs +2 -0
  8. package/package.json +6 -19
  9. package/src/cli.ts +8 -16
  10. package/src/commands/compile.ts +25 -110
  11. package/src/commands/generateTests.ts +14 -96
  12. package/src/commands/init.ts +52 -55
  13. package/src/commands/plugin.ts +6 -21
  14. package/src/config/index.ts +302 -0
  15. package/{node_modules/@design-embed/core/src → src/core}/index.ts +151 -163
  16. package/src/core/nodes.ts +74 -0
  17. package/src/core/plugins/pluginApi.ts +44 -0
  18. package/src/core/types.ts +120 -0
  19. package/src/index.ts +48 -2
  20. package/src/targets/html.ts +621 -0
  21. package/dist/args.js +0 -36
  22. package/dist/cli.js +0 -35
  23. package/dist/commands/check.js +0 -4
  24. package/dist/commands/compile.js +0 -157
  25. package/dist/commands/generateTests.js +0 -113
  26. package/dist/commands/init.js +0 -102
  27. package/dist/commands/plugin.js +0 -68
  28. package/dist/index.js +0 -2
  29. package/node_modules/@design-embed/config/README.md +0 -5
  30. package/node_modules/@design-embed/config/dist/index.js +0 -283
  31. package/node_modules/@design-embed/config/package.json +0 -19
  32. package/node_modules/@design-embed/config/src/index.ts +0 -518
  33. package/node_modules/@design-embed/core/README.md +0 -5
  34. package/node_modules/@design-embed/core/dist/diagnostics/diagnostic.js +0 -3
  35. package/node_modules/@design-embed/core/dist/diagnostics/jsonDiagnostic.js +0 -35
  36. package/node_modules/@design-embed/core/dist/index.js +0 -351
  37. package/node_modules/@design-embed/core/dist/pipeline/checkMode.js +0 -29
  38. package/node_modules/@design-embed/core/dist/plugins/pluginApi.js +0 -1
  39. package/node_modules/@design-embed/core/dist/plugins/pluginRegistry.js +0 -25
  40. package/node_modules/@design-embed/core/package.json +0 -19
  41. package/node_modules/@design-embed/core/src/plugins/pluginApi.ts +0 -78
  42. package/node_modules/@design-embed/core/src/plugins/pluginRegistry.ts +0 -37
  43. /package/{node_modules/@design-embed/core/src → src/core}/diagnostics/diagnostic.ts +0 -0
  44. /package/{node_modules/@design-embed/core/src → src/core}/diagnostics/jsonDiagnostic.ts +0 -0
  45. /package/{node_modules/@design-embed/core/src → src/core}/pipeline/checkMode.ts +0 -0
@@ -0,0 +1,907 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ //#region packages/design-embed/src/config/index.ts
5
+ function defineConfig(config) {
6
+ return config;
7
+ }
8
+ function fromFile(htmlPath, cssPath) {
9
+ const resolvedHtml = htmlPath instanceof URL ? fileURLToPath(htmlPath) : null;
10
+ const resolvedCss = cssPath ? cssPath instanceof URL ? fileURLToPath(cssPath) : null : null;
11
+ return {
12
+ name: "html-file",
13
+ async run({ cwd }) {
14
+ return {
15
+ html: readFileSync(resolvedHtml ?? resolve(cwd, htmlPath), "utf-8"),
16
+ css: cssPath ? readFileSync(resolvedCss ?? resolve(cwd, cssPath), "utf-8") : void 0,
17
+ diagnostics: []
18
+ };
19
+ }
20
+ };
21
+ }
22
+ async function loadConfig(configPath, cwd = process.cwd()) {
23
+ const diagnostics = [];
24
+ const resolvedPath = isAbsolute(configPath) ? configPath : resolve(cwd, configPath);
25
+ if (!existsSync(resolvedPath)) return { diagnostics: [{
26
+ code: "CONFIG_NOT_FOUND",
27
+ message: `Config file not found: ${resolvedPath}`,
28
+ severity: "error"
29
+ }] };
30
+ if (!/\.(ts|js|mjs)$/.test(resolvedPath)) return { diagnostics: [{
31
+ code: "CONFIG_UNSUPPORTED_FORMAT",
32
+ message: `Unsupported config format: ${resolvedPath}. Only .ts, .js, and .mjs are supported.`,
33
+ severity: "error"
34
+ }] };
35
+ try {
36
+ const module = await import(pathToFileURL(resolvedPath).href);
37
+ const config = module.default ?? module.config;
38
+ if (!config) return { diagnostics: [{
39
+ code: "CONFIG_INVALID",
40
+ message: `Config file must export a default object or a named 'config' object: ${resolvedPath}`,
41
+ severity: "error"
42
+ }] };
43
+ diagnostics.push(...validateConfig(config));
44
+ return {
45
+ config,
46
+ configPath: resolvedPath,
47
+ diagnostics
48
+ };
49
+ } catch (error) {
50
+ return { diagnostics: [{
51
+ code: "CONFIG_INVALID",
52
+ message: `Failed to load config file: ${error instanceof Error ? error.message : String(error)}`,
53
+ severity: "error"
54
+ }] };
55
+ }
56
+ }
57
+ function validateConfig(config) {
58
+ const diagnostics = [];
59
+ const target = config.output?.target;
60
+ const styleMode = config.output?.styleMode;
61
+ if (target && target !== "html" && (typeof target !== "object" || typeof target.emit !== "function")) diagnostics.push({
62
+ code: "TARGET_ADAPTER_INVALID",
63
+ message: "output.target must be a target adapter with emit().",
64
+ severity: "error"
65
+ });
66
+ if (styleMode && styleMode !== "inline" && styleMode !== "css-modules" && styleMode !== "tailwind") diagnostics.push({
67
+ code: "STYLE_MODE_UNSUPPORTED",
68
+ message: `Unsupported style mode: ${styleMode}`,
69
+ severity: "error"
70
+ });
71
+ for (const [index, component] of (config.components ?? []).entries()) {
72
+ if (!component.selector || typeof component.selector !== "string") diagnostics.push({
73
+ code: "COMPONENT_SELECTOR_INVALID",
74
+ message: `Component mapping ${index} must include a selector.`,
75
+ severity: "error"
76
+ });
77
+ if (!component.component || typeof component.component !== "string") diagnostics.push({
78
+ code: "COMPONENT_IMPORT_INVALID",
79
+ message: `Component mapping ${index} must include a component name.`,
80
+ severity: "error"
81
+ });
82
+ }
83
+ const spacing = config.tokens?.spacing;
84
+ if (spacing?.unit && spacing.unit !== "px" && spacing.unit !== "rem") diagnostics.push({
85
+ code: "TOKEN_SPACING_UNIT_INVALID",
86
+ message: `Unsupported spacing unit: ${spacing.unit}`,
87
+ severity: "error"
88
+ });
89
+ if (spacing?.threshold !== void 0 && !Number.isFinite(spacing.threshold)) diagnostics.push({
90
+ code: "TOKEN_SPACING_THRESHOLD_INVALID",
91
+ message: "Spacing threshold must be a finite number.",
92
+ severity: "error"
93
+ });
94
+ for (const [name, value] of Object.entries(spacing?.values ?? {})) if (!Number.isFinite(value)) diagnostics.push({
95
+ code: "TOKEN_SPACING_VALUE_INVALID",
96
+ message: `Spacing token ${name} must be a finite number.`,
97
+ severity: "error"
98
+ });
99
+ for (const [name, value] of Object.entries(config.tokens?.colors ?? {})) if (!/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value)) diagnostics.push({
100
+ code: "TOKEN_COLOR_INVALID",
101
+ message: `Color token ${name} must be a hex color.`,
102
+ severity: "error"
103
+ });
104
+ if (config.tokens?.colorThreshold !== void 0 && !Number.isFinite(config.tokens.colorThreshold)) diagnostics.push({
105
+ code: "TOKEN_COLOR_THRESHOLD_INVALID",
106
+ message: "Color threshold must be a finite number.",
107
+ severity: "error"
108
+ });
109
+ for (const [groupName, group] of Object.entries({
110
+ sizing: config.tokens?.sizing,
111
+ typography: config.tokens?.typography
112
+ })) {
113
+ if (!group) continue;
114
+ if (group.unit && group.unit !== "px" && group.unit !== "rem") diagnostics.push({
115
+ code: "TOKEN_NUMERIC_UNIT_INVALID",
116
+ message: `Unsupported ${groupName} unit: ${group.unit}`,
117
+ severity: "error"
118
+ });
119
+ if (group.threshold !== void 0 && !Number.isFinite(group.threshold)) diagnostics.push({
120
+ code: "TOKEN_NUMERIC_THRESHOLD_INVALID",
121
+ message: `${groupName} threshold must be a finite number.`,
122
+ severity: "error"
123
+ });
124
+ for (const [name, value] of Object.entries(group.values ?? {})) if (!Number.isFinite(value)) diagnostics.push({
125
+ code: "TOKEN_NUMERIC_VALUE_INVALID",
126
+ message: `${groupName} token ${name} must be a finite number.`,
127
+ severity: "error"
128
+ });
129
+ }
130
+ validateTestGeneration(config.tests, diagnostics);
131
+ return diagnostics;
132
+ }
133
+ function validateTestGeneration(tests, diagnostics) {
134
+ if (!tests) return;
135
+ if (tests.runner && tests.runner !== "playwright") diagnostics.push({
136
+ code: "TEST_RUNNER_UNSUPPORTED",
137
+ message: `Unsupported test runner: ${tests.runner}`,
138
+ severity: "error"
139
+ });
140
+ for (const [index, viewport] of (tests.viewports ?? []).entries()) {
141
+ if (!Number.isFinite(viewport.width) || viewport.width <= 0) diagnostics.push({
142
+ code: "TEST_VIEWPORT_WIDTH_INVALID",
143
+ message: `Test viewport ${index} width must be a positive finite number.`,
144
+ severity: "error"
145
+ });
146
+ if (!Number.isFinite(viewport.height) || viewport.height <= 0) diagnostics.push({
147
+ code: "TEST_VIEWPORT_HEIGHT_INVALID",
148
+ message: `Test viewport ${index} height must be a positive finite number.`,
149
+ severity: "error"
150
+ });
151
+ }
152
+ for (const [index, state] of (tests.states ?? []).entries()) if (!state.name || typeof state.name !== "string") diagnostics.push({
153
+ code: "TEST_STATE_NAME_INVALID",
154
+ message: `Test state ${index} must include a name.`,
155
+ severity: "error"
156
+ });
157
+ if (tests.assertions?.layoutTolerance !== void 0 && (!Number.isFinite(tests.assertions.layoutTolerance) || tests.assertions.layoutTolerance < 0)) diagnostics.push({
158
+ code: "TEST_LAYOUT_TOLERANCE_INVALID",
159
+ message: "Test layout tolerance must be a finite number greater than or equal to 0.",
160
+ severity: "error"
161
+ });
162
+ }
163
+ //#endregion
164
+ //#region packages/design-embed/src/targets/html.ts
165
+ var HtmlTarget = class {
166
+ domModel;
167
+ constructor(options = {}) {
168
+ this.domModel = options.domModel ?? "light";
169
+ }
170
+ emit({ nodes, css, config }) {
171
+ const viewsDir = String(config?.output?.viewsDir ?? "src/generated/views");
172
+ const viewName = config?.output?.viewName ?? "index";
173
+ const components = collectComponents(nodes);
174
+ const scriptTag = components.length > 0 ? `<script defer src="./${viewName}.js"><\/script>\n` : "";
175
+ const files = [{
176
+ path: `${viewsDir}/${viewName}.html`,
177
+ contents: emitHtml(nodes, css) + scriptTag
178
+ }];
179
+ if (components.length > 0) files.push({
180
+ path: `${viewsDir}/${viewName}.ts`,
181
+ contents: emitWebComponentFile(components, this.domModel)
182
+ });
183
+ return { files };
184
+ }
185
+ generateTests(input) {
186
+ return htmlTestGenerator.generateTests(input);
187
+ }
188
+ };
189
+ const htmlTarget = new HtmlTarget();
190
+ const htmlTestGenerator = { generateTests({ html, css, config }) {
191
+ const diagnostics = [];
192
+ const tests = config.tests;
193
+ if (tests?.runner && tests.runner !== "playwright") {
194
+ diagnostics.push({
195
+ code: "TEST_RUNNER_UNSUPPORTED",
196
+ message: `Unsupported test runner: ${tests.runner}`,
197
+ severity: "error"
198
+ });
199
+ return {
200
+ files: [],
201
+ diagnostics
202
+ };
203
+ }
204
+ const viewsDir = config.output?.viewsDir ?? "src/generated/views";
205
+ const viewName = config.output?.viewName ?? "index";
206
+ const outputDir = tests?.outputDir ?? `${viewsDir}/tests`;
207
+ const fixturePath = `${outputDir}/${viewName}.reference.html`;
208
+ const specPath = `${outputDir}/${viewName}.spec.ts`;
209
+ const outputHtmlPath = `${viewsDir}/${viewName}.html`;
210
+ const referenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${html}`;
211
+ return {
212
+ diagnostics,
213
+ files: [{
214
+ path: fixturePath,
215
+ contents: referenceHtml.endsWith("\n") ? referenceHtml : `${referenceHtml}\n`
216
+ }, {
217
+ path: specPath,
218
+ contents: emitHtmlVisualSpec({
219
+ viewName,
220
+ relativeOutputPath: toRelativeFilePath(specPath, outputHtmlPath),
221
+ fixtureFileName: `${viewName}.reference.html`,
222
+ viewports: tests?.viewports ?? [{
223
+ name: "default",
224
+ width: 1440,
225
+ height: 900
226
+ }],
227
+ states: tests?.states ?? [{ name: "default" }],
228
+ assertions: {
229
+ screenshot: tests?.assertions?.screenshot ?? true,
230
+ layout: tests?.assertions?.layout ?? true,
231
+ layoutTolerance: tests?.assertions?.layoutTolerance ?? 0,
232
+ selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
233
+ screenshotThreshold: tests?.assertions?.screenshotThreshold ?? .1,
234
+ screenshotMaxDiffPixels: tests?.assertions?.screenshotMaxDiffPixels ?? 0
235
+ }
236
+ })
237
+ }]
238
+ };
239
+ } };
240
+ function emitHtml(nodes, css) {
241
+ const body = nodes.map((node) => emitNode(node, 0)).join("");
242
+ if (!css?.trim()) return body;
243
+ return `<style>\n${css.trim()}\n</style>\n${body}\n`;
244
+ }
245
+ function emitNode(node, depth) {
246
+ const indent = " ".repeat(depth);
247
+ if (node.kind === "text") return `${indent}${escapeHtml(node.text ?? "")}\n`;
248
+ if (node.kind === "component") return emitComponentHtml(node, depth);
249
+ const attributes = Object.entries(node.attributes ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => value === "" ? name : `${name}="${escapeAttribute(value)}"`).join(" ");
250
+ const openTag = attributes ? `<${node.tagName} ${attributes}>` : `<${node.tagName}>`;
251
+ const children = node.children ?? [];
252
+ if (children.length === 0) return `${indent}${openTag}</${node.tagName}>\n`;
253
+ return `${indent}${openTag}\n${children.map((child) => emitNode(child, depth + 1)).join("")}${indent}</${node.tagName}>\n`;
254
+ }
255
+ function emitComponentHtml(node, depth) {
256
+ const indent = " ".repeat(depth);
257
+ const tag = toCustomElementTag(node.component ?? "component");
258
+ const attrParts = Object.entries(node.props ?? {}).filter(([name, prop]) => name !== "children" && prop.kind !== "children").sort(([left], [right]) => left.localeCompare(right)).flatMap(([name, prop]) => {
259
+ const part = formatPropAsAttribute(name, prop);
260
+ return part !== null ? [part] : [];
261
+ }).join(" ");
262
+ const openTag = attrParts ? `<${tag} ${attrParts}>` : `<${tag}>`;
263
+ const childrenProp = node.props?.children;
264
+ if (childrenProp?.kind === "text") return `${indent}${openTag}${escapeHtml(childrenProp.value)}</${tag}>\n`;
265
+ if (childrenProp?.kind === "children") {
266
+ const kids = childrenProp.value;
267
+ if (kids.length === 0) return `${indent}${openTag}</${tag}>\n`;
268
+ return `${indent}${openTag}\n${kids.map((child) => emitNode(child, depth + 1)).join("")}${indent}</${tag}>\n`;
269
+ }
270
+ const children = node.children ?? [];
271
+ if (children.length === 0) return `${indent}${openTag}</${tag}>\n`;
272
+ return `${indent}${openTag}\n${children.map((child) => emitNode(child, depth + 1)).join("")}${indent}</${tag}>\n`;
273
+ }
274
+ function formatPropAsAttribute(name, prop) {
275
+ if (prop.kind === "children") return null;
276
+ if (prop.value === false) return null;
277
+ if (prop.value === true) return name;
278
+ const value = String(prop.value);
279
+ return value === "" ? name : `${name}="${escapeAttribute(value)}"`;
280
+ }
281
+ const VOID_ELEMENTS = new Set([
282
+ "area",
283
+ "base",
284
+ "br",
285
+ "col",
286
+ "embed",
287
+ "hr",
288
+ "img",
289
+ "input",
290
+ "link",
291
+ "meta",
292
+ "param",
293
+ "source",
294
+ "track",
295
+ "wbr"
296
+ ]);
297
+ function collectComponents(nodes) {
298
+ const seen = /* @__PURE__ */ new Map();
299
+ function visit(node) {
300
+ if (node.kind === "component") {
301
+ const key = `${node.importPath ?? ""}::${node.importName ?? ""}`;
302
+ if (!seen.has(key) && node.importPath && node.importName) seen.set(key, buildComponentInfo(node));
303
+ for (const child of node.children ?? []) visit(child);
304
+ for (const prop of Object.values(node.props ?? {})) if (prop.kind === "children") for (const child of prop.value) visit(child);
305
+ } else if (node.kind === "element") for (const child of node.children ?? []) visit(child);
306
+ }
307
+ for (const node of nodes) visit(node);
308
+ return Array.from(seen.values());
309
+ }
310
+ function buildComponentInfo(node) {
311
+ const tagName = toCustomElementTag(node.importName ?? node.component ?? "Component");
312
+ const className = toPascalCase(tagName);
313
+ const observedAttributes = [];
314
+ const propBindings = [];
315
+ const mappedOriginalAttrs = /* @__PURE__ */ new Set();
316
+ for (const [name, prop] of Object.entries(node.props ?? {})) if (prop.kind === "literal") {
317
+ observedAttributes.push(name);
318
+ if (prop.attribute) {
319
+ propBindings.push({
320
+ propName: name,
321
+ attrName: prop.attribute
322
+ });
323
+ mappedOriginalAttrs.add(prop.attribute);
324
+ }
325
+ }
326
+ observedAttributes.sort();
327
+ propBindings.sort((a, b) => a.attrName.localeCompare(b.attrName));
328
+ const sourceEl = node.sourceElement;
329
+ let sourceTagName;
330
+ let staticAttrEntries = [];
331
+ if (sourceEl?.kind === "element" && sourceEl.tagName) {
332
+ sourceTagName = sourceEl.tagName;
333
+ staticAttrEntries = Object.entries(sourceEl.attributes ?? {}).filter(([attr]) => !mappedOriginalAttrs.has(attr)).sort(([a], [b]) => a.localeCompare(b));
334
+ }
335
+ return {
336
+ tagName,
337
+ className,
338
+ observedAttributes,
339
+ sourceTagName,
340
+ staticAttrEntries,
341
+ propBindings
342
+ };
343
+ }
344
+ function emitWebComponentFile(components, domModel) {
345
+ return `${components.map((c) => emitWebComponentClass(c, domModel)).join("\n\n")}\n\n${components.map((c) => `customElements.define("${c.tagName}", ${c.className});`).join("\n")}\n`;
346
+ }
347
+ function emitWebComponentClass(info, domModel) {
348
+ const hasShadow = domModel === "shadow";
349
+ const { className, observedAttributes, sourceTagName, staticAttrEntries, propBindings } = info;
350
+ const attrArray = observedAttributes.length === 0 ? "[]" : `[${observedAttributes.map((a) => JSON.stringify(a)).join(", ")}]`;
351
+ const shadowSetup = hasShadow ? `\tprivate shadow: ShadowRoot;\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.shadow = this.attachShadow({ mode: "open" });\n\t}\n\n` : "";
352
+ let renderBody;
353
+ if (sourceTagName && !hasShadow) {
354
+ const isVoid = VOID_ELEMENTS.has(sourceTagName);
355
+ const lines = [" if (!this.parentNode) return;"];
356
+ if (observedAttributes.length > 0) for (const a of observedAttributes) lines.push(`\t\tconst ${a} = this.getAttribute(${JSON.stringify(a)});`);
357
+ lines.push(`\t\tconst el = document.createElement(${JSON.stringify(sourceTagName)});`);
358
+ for (const [attr, value] of staticAttrEntries) lines.push(`\t\tel.setAttribute(${JSON.stringify(attr)}, ${JSON.stringify(value)});`);
359
+ for (const { propName, attrName } of propBindings) lines.push(`\t\tif (${propName} !== null) el.setAttribute(${JSON.stringify(attrName)}, ${propName});`);
360
+ if (!isVoid) lines.push(" el.innerHTML = this.innerHTML;");
361
+ lines.push(" this.replaceWith(el);");
362
+ renderBody = lines.join("\n");
363
+ } else {
364
+ const attrVars = observedAttributes.map((a) => `\t\tconst ${a} = this.getAttribute("${a}");`).join("\n");
365
+ const fallbackLines = [];
366
+ if (attrVars) fallbackLines.push(attrVars);
367
+ if (hasShadow) fallbackLines.push(`\t\tthis.shadow.innerHTML = \`<slot></slot>\`;`);
368
+ renderBody = fallbackLines.join("\n");
369
+ }
370
+ return `class ${className} extends HTMLElement {
371
+ ${shadowSetup}\tstatic get observedAttributes(): string[] {
372
+ \t\treturn ${attrArray};
373
+ \t}
374
+
375
+ \tconnectedCallback(): void {
376
+ \t\tthis.render();
377
+ \t}
378
+
379
+ \tattributeChangedCallback(): void {
380
+ \t\tthis.render();
381
+ \t}
382
+
383
+ ${renderBody ? `\tprivate render(): void {\n${renderBody}\n\t}` : `\tprivate render(): void {\n\t}`}
384
+ }`;
385
+ }
386
+ function emitHtmlVisualSpec(input) {
387
+ const viewports = JSON.stringify(input.viewports, null, 2);
388
+ const states = JSON.stringify(input.states, null, 2);
389
+ const selectors = JSON.stringify(input.assertions.selectors, null, 2);
390
+ const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
391
+ const layoutEnabled = JSON.stringify(input.assertions.layout);
392
+ const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
393
+ const screenshotThreshold = JSON.stringify(input.assertions.screenshotThreshold);
394
+ const screenshotMaxDiffPixels = JSON.stringify(input.assertions.screenshotMaxDiffPixels);
395
+ return `import { readFileSync } from "node:fs";
396
+ import { dirname, resolve } from "node:path";
397
+ import { fileURLToPath } from "node:url";
398
+ import { expect, test } from "@playwright/test";
399
+ import pixelmatch from "pixelmatch";
400
+ import { PNG } from "pngjs";
401
+
402
+ const currentDir = dirname(fileURLToPath(import.meta.url));
403
+ const referenceHtml = readFileSync(resolve(currentDir, "./${input.fixtureFileName}"), "utf-8");
404
+ const outputHtmlPath = resolve(currentDir, "${input.relativeOutputPath}");
405
+ const viewports = ${viewports};
406
+ const states = ${states};
407
+ const selectors = ${selectors};
408
+ const screenshotEnabled = ${screenshotEnabled};
409
+ const layoutEnabled = ${layoutEnabled};
410
+ const layoutTolerance = ${layoutTolerance};
411
+ const screenshotThreshold = ${screenshotThreshold};
412
+ const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
413
+
414
+ for (const viewport of viewports) {
415
+ \tfor (const state of states) {
416
+ \t\tconst viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
417
+ \t\ttest("${input.viewName} matches source at " + viewportName + " / " + state.name, async ({ page }) => {
418
+ \t\t\tawait page.setViewportSize({ width: viewport.width, height: viewport.height });
419
+
420
+ \t\t\tawait page.setContent(referenceHtml);
421
+ \t\t\tawait applyState(page, state);
422
+ \t\t\tconst expectedScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
423
+ \t\t\tconst expectedLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
424
+
425
+ \t\t\tawait page.goto("file://" + outputHtmlPath);
426
+ \t\t\tawait page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
427
+ \t\t\tawait applyState(page, state);
428
+ \t\t\tconst actualScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
429
+ \t\t\tconst actualLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
430
+
431
+ \t\t\tif (screenshotEnabled) {
432
+ \t\t\t\tconst expectedPng = PNG.sync.read(expectedScreenshot);
433
+ \t\t\t\tconst actualPng = PNG.sync.read(actualScreenshot);
434
+ \t\t\t\texpect(actualPng.width, "screenshot width").toBe(expectedPng.width);
435
+ \t\t\t\texpect(actualPng.height, "screenshot height").toBe(expectedPng.height);
436
+ \t\t\t\tconst diff = new PNG({ width: expectedPng.width, height: expectedPng.height });
437
+ \t\t\t\tconst diffPixelCount = pixelmatch(expectedPng.data, actualPng.data, diff.data, expectedPng.width, expectedPng.height, { threshold: screenshotThreshold });
438
+ \t\t\t\texpect(diffPixelCount, "screenshot diff pixels").toBeLessThanOrEqual(screenshotMaxDiffPixels);
439
+ \t\t\t}
440
+ \t\t\tif (layoutEnabled) {
441
+ \t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
442
+ \t\t\t}
443
+ \t\t});
444
+ \t}
445
+ }
446
+
447
+ async function applyState(page, state) {
448
+ \tif (state.waitFor) {
449
+ \t\tawait page.waitForSelector(state.waitFor);
450
+ \t}
451
+ \tif (state.hover) {
452
+ \t\tawait page.hover(state.hover);
453
+ \t}
454
+ \tif (state.focus) {
455
+ \t\tawait page.focus(state.focus);
456
+ \t}
457
+ \tif (state.click) {
458
+ \t\tawait page.click(state.click);
459
+ \t}
460
+ }
461
+
462
+ async function readLayout(root, selectorsToRead) {
463
+ \treturn root.evaluate((element, values) => {
464
+ \t\treturn values.flatMap((selector) => {
465
+ \t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
466
+ \t\t\treturn matches.map((matchedElement, index) => {
467
+ \t\t\t\tconst rect = matchedElement.getBoundingClientRect();
468
+ \t\t\t\treturn {
469
+ \t\t\t\t\tselector,
470
+ \t\t\t\t\tindex,
471
+ \t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
472
+ \t\t\t\t\tx: rect.x,
473
+ \t\t\t\t\ty: rect.y,
474
+ \t\t\t\t\twidth: rect.width,
475
+ \t\t\t\t\theight: rect.height,
476
+ \t\t\t\t};
477
+ \t\t\t});
478
+ \t\t});
479
+ \t}, selectorsToRead);
480
+ }
481
+
482
+ function expectLayoutToMatch(actual, expected, tolerance) {
483
+ \texpect(actual.length).toBe(expected.length);
484
+ \tfor (let index = 0; index < expected.length; index += 1) {
485
+ \t\tconst actualRect = actual[index];
486
+ \t\tconst expectedRect = expected[index];
487
+ \t\texpect(actualRect.selector).toBe(expectedRect.selector);
488
+ \t\texpect(actualRect.index).toBe(expectedRect.index);
489
+ \t\texpect(actualRect.tagName).toBe(expectedRect.tagName);
490
+ \t\tfor (const key of ["x", "y", "width", "height"]) {
491
+ \t\t\tconst drift = Math.abs(actualRect[key] - expectedRect[key]);
492
+ \t\t\texpect(drift, \`\${expectedRect.selector}[\${expectedRect.index}] \${key} drift\`).toBeLessThanOrEqual(tolerance);
493
+ \t\t}
494
+ \t}
495
+ }
496
+ `;
497
+ }
498
+ function toRelativeFilePath(fromFile, toFile) {
499
+ const fromParts = fromFile.split("/").slice(0, -1);
500
+ const toParts = toFile.split("/");
501
+ while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
502
+ fromParts.shift();
503
+ toParts.shift();
504
+ }
505
+ const relative = [...fromParts.map(() => ".."), ...toParts].join("/");
506
+ return relative.startsWith(".") ? relative : `./${relative}`;
507
+ }
508
+ function escapeHtml(value) {
509
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
510
+ }
511
+ function escapeAttribute(value) {
512
+ return escapeHtml(value).replace(/"/g, "&quot;");
513
+ }
514
+ function toCustomElementTag(name) {
515
+ const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/^-/, "");
516
+ return kebab.includes("-") ? kebab : `${kebab}-el`;
517
+ }
518
+ function toPascalCase(kebab) {
519
+ return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
520
+ }
521
+ //#endregion
522
+ //#region packages/design-embed/src/core/diagnostics/jsonDiagnostic.ts
523
+ function toJsonDiagnostic(diagnostic) {
524
+ const details = {
525
+ ...diagnostic.details,
526
+ ...diagnostic.selector ? { selector: diagnostic.selector } : {},
527
+ ...diagnostic.property ? { property: diagnostic.property } : {}
528
+ };
529
+ return {
530
+ code: diagnostic.code,
531
+ severity: diagnostic.severity,
532
+ message: redactSecrets(diagnostic.message),
533
+ ...diagnostic.file ? { file: diagnostic.file } : {},
534
+ ...diagnostic.source ? { line: diagnostic.source.line } : {},
535
+ ...diagnostic.source ? { column: diagnostic.source.column } : {},
536
+ ...Object.keys(details).length > 0 ? { details } : {}
537
+ };
538
+ }
539
+ function toJsonDiagnostics(diagnostics) {
540
+ return diagnostics.map(toJsonDiagnostic);
541
+ }
542
+ function formatDiagnosticText(diagnostic) {
543
+ const location = [
544
+ diagnostic.file,
545
+ diagnostic.source?.line,
546
+ diagnostic.source?.column
547
+ ].filter((part) => part !== void 0).join(":");
548
+ return `${location ? `${location}: ` : ""}${diagnostic.severity}: ${diagnostic.code}: ${redactSecrets(diagnostic.message)}`;
549
+ }
550
+ function redactSecrets(value) {
551
+ return value.replace(/figma[_-]?token\s*[:=]\s*[^\s]+/gi, "FIGMA_TOKEN=[redacted]").replace(/bearer\s+[a-z0-9._-]+/gi, "Bearer [redacted]");
552
+ }
553
+ //#endregion
554
+ //#region packages/design-embed/src/core/pipeline/checkMode.ts
555
+ function checkGeneratedFiles(input) {
556
+ const diagnostics = [];
557
+ for (const file of input.files) {
558
+ const absolutePath = resolve(input.cwd, file.path);
559
+ const current = input.readFile(absolutePath);
560
+ if (current === void 0) {
561
+ diagnostics.push({
562
+ code: "CHECK_FILE_MISSING",
563
+ message: `Generated file is missing: ${file.path}`,
564
+ severity: "error",
565
+ file: file.path
566
+ });
567
+ continue;
568
+ }
569
+ if (current !== file.contents) diagnostics.push({
570
+ code: "CHECK_FILE_STALE",
571
+ message: `Generated file is stale: ${file.path}`,
572
+ severity: "error",
573
+ file: file.path
574
+ });
575
+ }
576
+ return {
577
+ ok: diagnostics.length === 0,
578
+ diagnostics
579
+ };
580
+ }
581
+ //#endregion
582
+ //#region packages/design-embed/src/core/index.ts
583
+ /**
584
+ * The main compiler entry point.
585
+ * Parses HTML, applies component mappings, and emits files.
586
+ *
587
+ * @param input - The compilation input.
588
+ * @returns A promise resolving to the compilation result.
589
+ *
590
+ */
591
+ async function embed(input) {
592
+ const cwd = input.cwd ?? process.cwd();
593
+ if (!input.config?.source) return {
594
+ html: "",
595
+ files: [],
596
+ diagnostics: [{
597
+ code: "PLUGIN_REQUIRED",
598
+ message: "Config must include a source plugin.",
599
+ severity: "error"
600
+ }]
601
+ };
602
+ const sourceResult = await input.config.source.run({ cwd });
603
+ const diagnostics = [...sourceResult.diagnostics];
604
+ if (diagnostics.some((d) => d.severity === "error")) return {
605
+ html: "",
606
+ files: [],
607
+ diagnostics
608
+ };
609
+ if (!sourceResult.html) return {
610
+ html: "",
611
+ files: [],
612
+ diagnostics: [...diagnostics, {
613
+ code: "PLUGIN_NO_HTML",
614
+ message: "Source plugin produced no HTML.",
615
+ severity: "error"
616
+ }]
617
+ };
618
+ const html = sourceResult.html;
619
+ const css = sourceResult.css;
620
+ const config = patchOutputPaths(input.config, cwd);
621
+ const target = config?.output?.target;
622
+ const targetObj = !target || target === "html" ? htmlTarget : target;
623
+ const mappingDiagnostics = validateComponentMappings(config?.components ?? []);
624
+ diagnostics.push(...mappingDiagnostics);
625
+ if (diagnostics.some((d) => d.severity === "error")) return {
626
+ html,
627
+ files: [],
628
+ diagnostics
629
+ };
630
+ const mappedNodes = applyComponentMappings(parseHtml(html), config?.components ?? [], diagnostics);
631
+ const { files } = targetObj.emit({
632
+ nodes: mappedNodes,
633
+ css,
634
+ config,
635
+ diagnostics
636
+ });
637
+ if (input.generateTests && "generateTests" in targetObj) {
638
+ const testResult = targetObj.generateTests({
639
+ html,
640
+ css,
641
+ config
642
+ });
643
+ diagnostics.push(...testResult.diagnostics);
644
+ if (!diagnostics.some((d) => d.severity === "error")) files.push(...testResult.files);
645
+ }
646
+ if (!input.dryRun) for (const file of files) {
647
+ const outPath = resolve(cwd, file.path);
648
+ mkdirSync(dirname(outPath), { recursive: true });
649
+ writeFileSync(outPath, file.contents, "utf-8");
650
+ }
651
+ return {
652
+ html,
653
+ css,
654
+ files,
655
+ diagnostics
656
+ };
657
+ }
658
+ function patchOutputPaths(config, cwd) {
659
+ const viewsDir = config.output?.viewsDir;
660
+ if (!viewsDir) return config;
661
+ return {
662
+ ...config,
663
+ output: {
664
+ ...config.output,
665
+ viewsDir: resolveDir(viewsDir, cwd)
666
+ }
667
+ };
668
+ }
669
+ function resolveDir(dir, cwd) {
670
+ if (!dir) return void 0;
671
+ if (dir instanceof URL) return relative(cwd, fileURLToPath(dir));
672
+ return dir;
673
+ }
674
+ function applyComponentMappings(nodes, mappings, diagnostics = []) {
675
+ const parsedMappings = mappings.map((mapping, index) => ({
676
+ index,
677
+ mapping,
678
+ selector: parseSelector(mapping.selector)
679
+ })).filter(({ selector }) => selector !== void 0);
680
+ return nodes.map((node) => {
681
+ if (node.kind !== "element") return node;
682
+ const match = parsedMappings.find(({ selector }) => matchesSelector(node, selector));
683
+ if (match) {
684
+ const props = extractProps(node, match.mapping, diagnostics);
685
+ return {
686
+ kind: "component",
687
+ component: match.mapping.component,
688
+ importName: match.mapping.component,
689
+ importPath: `./${match.mapping.component}.view`,
690
+ props,
691
+ children: props.children?.kind === "children" ? void 0 : applyComponentMappings(node.children ?? [], mappings, diagnostics),
692
+ source: node.source,
693
+ sourceElement: node
694
+ };
695
+ }
696
+ return {
697
+ ...node,
698
+ children: applyComponentMappings(node.children ?? [], mappings, diagnostics)
699
+ };
700
+ });
701
+ }
702
+ function parseSelector(selector) {
703
+ const trimmed = selector.trim();
704
+ if (!trimmed || /[\s>+~,:]/.test(trimmed)) return;
705
+ const parsed = {
706
+ classes: [],
707
+ attributes: {}
708
+ };
709
+ let rest = trimmed;
710
+ const tagMatch = rest.match(/^[a-zA-Z][a-zA-Z0-9-]*/);
711
+ if (tagMatch?.[0]) {
712
+ parsed.tagName = tagMatch[0].toLowerCase();
713
+ rest = rest.slice(tagMatch[0].length);
714
+ }
715
+ while (rest) {
716
+ if (rest.startsWith(".")) {
717
+ const match = rest.match(/^\.([a-zA-Z_][a-zA-Z0-9_-]*)/);
718
+ if (!match?.[1]) return;
719
+ parsed.classes.push(match[1]);
720
+ rest = rest.slice(match[0].length);
721
+ continue;
722
+ }
723
+ if (rest.startsWith("#")) {
724
+ const match = rest.match(/^#([a-zA-Z_][a-zA-Z0-9_-]*)/);
725
+ if (!match?.[1] || parsed.id) return;
726
+ parsed.id = match[1];
727
+ rest = rest.slice(match[0].length);
728
+ continue;
729
+ }
730
+ if (rest.startsWith("[")) {
731
+ const match = rest.match(/^\[([a-zA-Z_][a-zA-Z0-9_.:-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]+)))?\]/);
732
+ if (!match?.[1]) return;
733
+ parsed.attributes[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
734
+ rest = rest.slice(match[0].length);
735
+ continue;
736
+ }
737
+ return;
738
+ }
739
+ return parsed;
740
+ }
741
+ function matchesSelector(node, selector) {
742
+ if (node.kind !== "element") return false;
743
+ const attributes = node.attributes ?? {};
744
+ if (selector.tagName && node.tagName !== selector.tagName) return false;
745
+ if (selector.id && attributes.id !== selector.id) return false;
746
+ const classNames = new Set((attributes.class ?? "").split(/\s+/).filter(Boolean));
747
+ for (const className of selector.classes) if (!classNames.has(className)) return false;
748
+ for (const [name, value] of Object.entries(selector.attributes)) {
749
+ if (!(name in attributes)) return false;
750
+ if (value !== "" && attributes[name] !== value) return false;
751
+ }
752
+ return true;
753
+ }
754
+ function parseHtml(html) {
755
+ const root = {
756
+ kind: "element",
757
+ tagName: "root",
758
+ attributes: {},
759
+ styles: {},
760
+ children: []
761
+ };
762
+ const stack = [root];
763
+ const tokens = html.matchAll(/<!--[\s\S]*?-->|<\/?[a-zA-Z][^>]*>|[^<]+/g);
764
+ for (const token of tokens) {
765
+ const value = token[0];
766
+ const offset = token.index;
767
+ const source = getSourceLocation(html, offset);
768
+ if (value.startsWith("<!--")) continue;
769
+ if (value.startsWith("</")) {
770
+ const tagName = value.slice(2, -1).trim().toLowerCase();
771
+ while (stack.length > 1) if (stack.pop()?.tagName === tagName) break;
772
+ continue;
773
+ }
774
+ if (value.startsWith("<")) {
775
+ const selfClosing = /\/>$/.test(value) || isVoidElement(value);
776
+ const node = parseElement(value, source);
777
+ currentParent(stack).children?.push(node);
778
+ if (!selfClosing) stack.push(node);
779
+ continue;
780
+ }
781
+ if (value.trim()) currentParent(stack).children?.push({
782
+ kind: "text",
783
+ text: collapseWhitespace(value),
784
+ source
785
+ });
786
+ }
787
+ return root.children ?? [];
788
+ }
789
+ function parseInlineStyle(style) {
790
+ const styles = {};
791
+ if (!style) return styles;
792
+ for (const declaration of style.split(";")) {
793
+ const [property, ...valueParts] = declaration.split(":");
794
+ const value = valueParts.join(":").trim();
795
+ if (!property?.trim() || !value) continue;
796
+ styles[property.trim().toLowerCase()] = value;
797
+ }
798
+ return styles;
799
+ }
800
+ function parseElement(rawTag, source) {
801
+ const tagBody = rawTag.replace(/^</, "").replace(/\/?>$/, "").trim();
802
+ const tagName = tagBody.split(/\s+/, 1)[0]?.toLowerCase() ?? "div";
803
+ const attributes = parseAttributes(tagBody.slice(tagName.length).trim());
804
+ return {
805
+ kind: "element",
806
+ tagName,
807
+ attributes,
808
+ styles: parseInlineStyle(attributes.style),
809
+ children: [],
810
+ source
811
+ };
812
+ }
813
+ function parseAttributes(source) {
814
+ const attributes = {};
815
+ for (const match of source.matchAll(/([:@a-zA-Z_][:@a-zA-Z0-9_.-]*)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) {
816
+ const name = match[1];
817
+ if (!name) continue;
818
+ attributes[name] = match[3] ?? match[4] ?? match[5] ?? "";
819
+ }
820
+ return attributes;
821
+ }
822
+ function validateComponentMappings(mappings) {
823
+ const diagnostics = [];
824
+ for (const [index, mapping] of mappings.entries()) {
825
+ if (mapping.selector && !parseSelector(mapping.selector)) diagnostics.push({
826
+ code: "SELECTOR_UNSUPPORTED",
827
+ message: `Component mapping ${index} uses an unsupported selector: ${mapping.selector}`,
828
+ severity: "error"
829
+ });
830
+ for (const [propName, expression] of Object.entries(mapping.props ?? {})) if (!isSupportedPropExpression(expression)) diagnostics.push({
831
+ code: "PROP_EXPRESSION_UNSUPPORTED",
832
+ message: `Component mapping ${index} prop "${propName}" uses an unsupported expression: ${expression}`,
833
+ severity: "error"
834
+ });
835
+ }
836
+ return diagnostics;
837
+ }
838
+ function isSupportedPropExpression(expression) {
839
+ return !expression.startsWith("$") || expression === "$text" || expression === "$children" || /^\$attr\.[a-zA-Z_][a-zA-Z0-9_.:-]*$/.test(expression);
840
+ }
841
+ function extractProps(node, mapping, diagnostics) {
842
+ const props = {};
843
+ for (const [propName, expression] of Object.entries(mapping.props ?? {})) {
844
+ if (expression === "$text") {
845
+ props[propName] = {
846
+ kind: "text",
847
+ value: collectText(node)
848
+ };
849
+ continue;
850
+ }
851
+ if (expression === "$children") {
852
+ props[propName] = {
853
+ kind: "children",
854
+ value: node.children ?? []
855
+ };
856
+ continue;
857
+ }
858
+ if (expression.startsWith("$attr.")) {
859
+ const attributeName = expression.slice(6);
860
+ const value = node.attributes?.[attributeName];
861
+ if (value === void 0) {
862
+ diagnostics.push({
863
+ code: "PROP_ATTRIBUTE_MISSING",
864
+ message: `Attribute "${attributeName}" is missing for prop "${propName}".`,
865
+ severity: "warning"
866
+ });
867
+ continue;
868
+ }
869
+ props[propName] = {
870
+ kind: "literal",
871
+ value,
872
+ attribute: attributeName
873
+ };
874
+ continue;
875
+ }
876
+ props[propName] = {
877
+ kind: "literal",
878
+ value: expression
879
+ };
880
+ }
881
+ return props;
882
+ }
883
+ function collectText(node) {
884
+ if (node.kind === "text") return node.text ?? "";
885
+ return (node.children ?? []).map((child) => collectText(child)).filter(Boolean).join(" ").trim();
886
+ }
887
+ function currentParent(stack) {
888
+ const parent = stack[stack.length - 1];
889
+ if (!parent) throw new Error("HTML parser stack is empty.");
890
+ return parent;
891
+ }
892
+ function getSourceLocation(source, offset) {
893
+ const lines = source.slice(0, offset).split(/\r?\n/);
894
+ return {
895
+ offset,
896
+ line: lines.length,
897
+ column: (lines[lines.length - 1]?.length ?? 0) + 1
898
+ };
899
+ }
900
+ function collapseWhitespace(value) {
901
+ return value.replace(/\s+/g, " ").trim();
902
+ }
903
+ function isVoidElement(tag) {
904
+ return /^<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)(\s|>|\/)/i.test(tag);
905
+ }
906
+ //#endregion
907
+ export { formatDiagnosticText as a, htmlTarget as c, loadConfig as d, validateConfig as f, checkGeneratedFiles as i, defineConfig as l, embed as n, toJsonDiagnostics as o, parseHtml as r, HtmlTarget as s, applyComponentMappings as t, fromFile as u };