design-embed 0.1.1 → 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.
@@ -1,39 +1,150 @@
1
1
  import type {
2
2
  DesignNode,
3
+ Diagnostic,
4
+ GeneratedFile,
5
+ PropValue,
3
6
  TargetEmitInput,
4
7
  TargetEmitResult,
5
8
  TargetEmitter,
6
- } from "@design-embed/core";
9
+ TargetTestGenerateInput,
10
+ TargetTestGenerateResult,
11
+ TargetTestGenerator,
12
+ } from "../core/index.ts";
7
13
 
8
- export function emitHtmlDebug(nodes: DesignNode[], css?: string): string {
9
- const body = nodes.map((node) => emitNode(node, 0)).join("");
10
- if (!css?.trim()) {
11
- return body;
12
- }
13
- return `<style>\n${css.trim()}\n</style>\n${body}\n`;
14
+ // ---------------------------------------------------------------------------
15
+ // Public API
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface HtmlTargetOptions {
19
+ domModel?: "light" | "shadow";
14
20
  }
15
21
 
16
- export const htmlEmitter: TargetEmitter = {
22
+ export class HtmlTarget implements TargetEmitter, TargetTestGenerator {
23
+ private readonly domModel: "light" | "shadow";
24
+
25
+ constructor(options: HtmlTargetOptions = {}) {
26
+ this.domModel = options.domModel ?? "light";
27
+ }
28
+
17
29
  emit({ nodes, css, config }: TargetEmitInput): TargetEmitResult {
18
- const viewsDir = config?.output?.viewsDir ?? "src/generated/views";
30
+ const viewsDir = String(config?.output?.viewsDir ?? "src/generated/views");
31
+ const viewName = config?.output?.viewName ?? "index";
32
+
33
+ const components = collectComponents(nodes);
34
+ const scriptTag =
35
+ components.length > 0
36
+ ? `<script defer src="./${viewName}.js"></script>\n`
37
+ : "";
38
+
39
+ const files: GeneratedFile[] = [
40
+ {
41
+ path: `${viewsDir}/${viewName}.html`,
42
+ contents: emitHtml(nodes, css) + scriptTag,
43
+ },
44
+ ];
45
+
46
+ if (components.length > 0) {
47
+ files.push({
48
+ path: `${viewsDir}/${viewName}.ts`,
49
+ contents: emitWebComponentFile(components, this.domModel),
50
+ });
51
+ }
52
+
53
+ return { files };
54
+ }
55
+
56
+ generateTests(input: TargetTestGenerateInput): TargetTestGenerateResult {
57
+ return htmlTestGenerator.generateTests(input);
58
+ }
59
+ }
60
+
61
+ export const htmlTarget: TargetEmitter & TargetTestGenerator = new HtmlTarget();
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Test generator (unchanged)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const htmlTestGenerator: TargetTestGenerator = {
68
+ generateTests({
69
+ html,
70
+ css,
71
+ config,
72
+ }: TargetTestGenerateInput): TargetTestGenerateResult {
73
+ const diagnostics: Diagnostic[] = [];
74
+ const tests = config.tests;
75
+
76
+ if (tests?.runner && tests.runner !== "playwright") {
77
+ diagnostics.push({
78
+ code: "TEST_RUNNER_UNSUPPORTED",
79
+ message: `Unsupported test runner: ${tests.runner}`,
80
+ severity: "error",
81
+ });
82
+ return { files: [], diagnostics };
83
+ }
84
+
85
+ const viewsDir = config.output?.viewsDir ?? "src/generated/views";
86
+ const viewName = config.output?.viewName ?? "index";
87
+ const outputDir = tests?.outputDir ?? `${viewsDir}/tests`;
88
+ const fixturePath = `${outputDir}/${viewName}.reference.html`;
89
+ const specPath = `${outputDir}/${viewName}.spec.ts`;
90
+ const outputHtmlPath = `${viewsDir}/${viewName}.html`;
91
+ const referenceHtml = `${css?.trim() ? `<style>\n${css}\n</style>\n` : ""}${html}`;
92
+
19
93
  return {
94
+ diagnostics,
20
95
  files: [
21
96
  {
22
- path: `${viewsDir}/debug.html`,
23
- contents: emitHtmlDebug(nodes, css),
97
+ path: fixturePath,
98
+ contents: referenceHtml.endsWith("\n")
99
+ ? referenceHtml
100
+ : `${referenceHtml}\n`,
101
+ },
102
+ {
103
+ path: specPath,
104
+ contents: emitHtmlVisualSpec({
105
+ viewName,
106
+ relativeOutputPath: toRelativeFilePath(specPath, outputHtmlPath),
107
+ fixtureFileName: `${viewName}.reference.html`,
108
+ viewports: tests?.viewports ?? [
109
+ { name: "default", width: 1440, height: 900 },
110
+ ],
111
+ states: tests?.states ?? [{ name: "default" }],
112
+ assertions: {
113
+ screenshot: tests?.assertions?.screenshot ?? true,
114
+ layout: tests?.assertions?.layout ?? true,
115
+ layoutTolerance: tests?.assertions?.layoutTolerance ?? 0,
116
+ selectors: tests?.assertions?.selectors ?? [":scope", ":scope *"],
117
+ screenshotThreshold:
118
+ tests?.assertions?.screenshotThreshold ?? 0.1,
119
+ screenshotMaxDiffPixels:
120
+ tests?.assertions?.screenshotMaxDiffPixels ?? 0,
121
+ },
122
+ }),
24
123
  },
25
124
  ],
26
125
  };
27
126
  },
28
127
  };
29
128
 
129
+ // ---------------------------------------------------------------------------
130
+ // HTML emit
131
+ // ---------------------------------------------------------------------------
132
+
133
+ function emitHtml(nodes: DesignNode[], css?: string): string {
134
+ const body = nodes.map((node) => emitNode(node, 0)).join("");
135
+ if (!css?.trim()) {
136
+ return body;
137
+ }
138
+ return `<style>\n${css.trim()}\n</style>\n${body}\n`;
139
+ }
140
+
30
141
  function emitNode(node: DesignNode, depth: number): string {
31
142
  const indent = "\t".repeat(depth);
32
143
  if (node.kind === "text") {
33
144
  return `${indent}${escapeHtml(node.text ?? "")}\n`;
34
145
  }
35
146
  if (node.kind === "component") {
36
- return `${indent}<${node.component}></${node.component}>\n`;
147
+ return emitComponentHtml(node, depth);
37
148
  }
38
149
 
39
150
  const attributes = Object.entries(node.attributes ?? {})
@@ -56,6 +167,433 @@ function emitNode(node: DesignNode, depth: number): string {
56
167
  .join("")}${indent}</${node.tagName}>\n`;
57
168
  }
58
169
 
170
+ function emitComponentHtml(node: DesignNode, depth: number): string {
171
+ const indent = "\t".repeat(depth);
172
+ const tag = toCustomElementTag(node.component ?? "component");
173
+
174
+ const attrParts = Object.entries(node.props ?? {})
175
+ .filter(([name, prop]) => name !== "children" && prop.kind !== "children")
176
+ .sort(([left], [right]) => left.localeCompare(right))
177
+ .flatMap(([name, prop]) => {
178
+ const part = formatPropAsAttribute(name, prop);
179
+ return part !== null ? [part] : [];
180
+ })
181
+ .join(" ");
182
+ const openTag = attrParts ? `<${tag} ${attrParts}>` : `<${tag}>`;
183
+
184
+ const childrenProp = node.props?.children;
185
+ if (childrenProp?.kind === "text") {
186
+ return `${indent}${openTag}${escapeHtml(childrenProp.value)}</${tag}>\n`;
187
+ }
188
+ if (childrenProp?.kind === "children") {
189
+ const kids = childrenProp.value;
190
+ if (kids.length === 0) {
191
+ return `${indent}${openTag}</${tag}>\n`;
192
+ }
193
+ return `${indent}${openTag}\n${kids
194
+ .map((child) => emitNode(child, depth + 1))
195
+ .join("")}${indent}</${tag}>\n`;
196
+ }
197
+
198
+ const children = node.children ?? [];
199
+ if (children.length === 0) {
200
+ return `${indent}${openTag}</${tag}>\n`;
201
+ }
202
+ return `${indent}${openTag}\n${children
203
+ .map((child) => emitNode(child, depth + 1))
204
+ .join("")}${indent}</${tag}>\n`;
205
+ }
206
+
207
+ function formatPropAsAttribute(name: string, prop: PropValue): string | null {
208
+ if (prop.kind === "children") {
209
+ return null;
210
+ }
211
+ if (prop.value === false) {
212
+ return null;
213
+ }
214
+ if (prop.value === true) {
215
+ return name;
216
+ }
217
+ const value = String(prop.value);
218
+ return value === "" ? name : `${name}="${escapeAttribute(value)}"`;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Web component emit
223
+ // ---------------------------------------------------------------------------
224
+
225
+ const VOID_ELEMENTS = new Set([
226
+ "area",
227
+ "base",
228
+ "br",
229
+ "col",
230
+ "embed",
231
+ "hr",
232
+ "img",
233
+ "input",
234
+ "link",
235
+ "meta",
236
+ "param",
237
+ "source",
238
+ "track",
239
+ "wbr",
240
+ ]);
241
+
242
+ interface PropBinding {
243
+ propName: string;
244
+ attrName: string;
245
+ }
246
+
247
+ interface ComponentInfo {
248
+ tagName: string;
249
+ className: string;
250
+ observedAttributes: string[];
251
+ sourceTagName?: string;
252
+ staticAttrEntries: [string, string][];
253
+ propBindings: PropBinding[];
254
+ }
255
+
256
+ function collectComponents(nodes: DesignNode[]): ComponentInfo[] {
257
+ const seen = new Map<string, ComponentInfo>();
258
+
259
+ function visit(node: DesignNode): void {
260
+ if (node.kind === "component") {
261
+ const key = `${node.importPath ?? ""}::${node.importName ?? ""}`;
262
+ if (!seen.has(key) && node.importPath && node.importName) {
263
+ seen.set(key, buildComponentInfo(node));
264
+ }
265
+ for (const child of node.children ?? []) {
266
+ visit(child);
267
+ }
268
+ for (const prop of Object.values(node.props ?? {})) {
269
+ if (prop.kind === "children") {
270
+ for (const child of prop.value) {
271
+ visit(child);
272
+ }
273
+ }
274
+ }
275
+ } else if (node.kind === "element") {
276
+ for (const child of node.children ?? []) {
277
+ visit(child);
278
+ }
279
+ }
280
+ }
281
+
282
+ for (const node of nodes) {
283
+ visit(node);
284
+ }
285
+
286
+ return Array.from(seen.values());
287
+ }
288
+
289
+ function buildComponentInfo(node: DesignNode): ComponentInfo {
290
+ const importName = node.importName ?? node.component ?? "Component";
291
+ const tagName = toCustomElementTag(importName);
292
+ const className = toPascalCase(tagName);
293
+
294
+ const observedAttributes: string[] = [];
295
+ const propBindings: PropBinding[] = [];
296
+ const mappedOriginalAttrs = new Set<string>();
297
+
298
+ for (const [name, prop] of Object.entries(node.props ?? {})) {
299
+ if (prop.kind === "literal") {
300
+ observedAttributes.push(name);
301
+ if (prop.attribute) {
302
+ propBindings.push({ propName: name, attrName: prop.attribute });
303
+ mappedOriginalAttrs.add(prop.attribute);
304
+ }
305
+ }
306
+ }
307
+ observedAttributes.sort();
308
+ propBindings.sort((a, b) => a.attrName.localeCompare(b.attrName));
309
+
310
+ const sourceEl = node.sourceElement;
311
+ let sourceTagName: string | undefined;
312
+ let staticAttrEntries: [string, string][] = [];
313
+
314
+ if (sourceEl?.kind === "element" && sourceEl.tagName) {
315
+ sourceTagName = sourceEl.tagName;
316
+ staticAttrEntries = Object.entries(sourceEl.attributes ?? {})
317
+ .filter(([attr]) => !mappedOriginalAttrs.has(attr))
318
+ .sort(([a], [b]) => a.localeCompare(b));
319
+ }
320
+
321
+ return {
322
+ tagName,
323
+ className,
324
+ observedAttributes,
325
+ sourceTagName,
326
+ staticAttrEntries,
327
+ propBindings,
328
+ };
329
+ }
330
+
331
+ function emitWebComponentFile(
332
+ components: ComponentInfo[],
333
+ domModel: "light" | "shadow",
334
+ ): string {
335
+ const classes = components
336
+ .map((c) => emitWebComponentClass(c, domModel))
337
+ .join("\n\n");
338
+
339
+ const registrations = components
340
+ .map((c) => `customElements.define("${c.tagName}", ${c.className});`)
341
+ .join("\n");
342
+
343
+ return `${classes}\n\n${registrations}\n`;
344
+ }
345
+
346
+ function emitWebComponentClass(
347
+ info: ComponentInfo,
348
+ domModel: "light" | "shadow",
349
+ ): string {
350
+ const hasShadow = domModel === "shadow";
351
+ const {
352
+ className,
353
+ observedAttributes,
354
+ sourceTagName,
355
+ staticAttrEntries,
356
+ propBindings,
357
+ } = info;
358
+
359
+ const attrArray =
360
+ observedAttributes.length === 0
361
+ ? "[]"
362
+ : `[${observedAttributes.map((a) => JSON.stringify(a)).join(", ")}]`;
363
+
364
+ const shadowSetup = hasShadow
365
+ ? `\tprivate shadow: ShadowRoot;\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.shadow = this.attachShadow({ mode: "open" });\n\t}\n\n`
366
+ : "";
367
+
368
+ let renderBody: string;
369
+
370
+ if (sourceTagName && !hasShadow) {
371
+ const isVoid = VOID_ELEMENTS.has(sourceTagName);
372
+ const lines: string[] = ["\t\tif (!this.parentNode) return;"];
373
+
374
+ if (observedAttributes.length > 0) {
375
+ for (const a of observedAttributes) {
376
+ lines.push(`\t\tconst ${a} = this.getAttribute(${JSON.stringify(a)});`);
377
+ }
378
+ }
379
+
380
+ lines.push(
381
+ `\t\tconst el = document.createElement(${JSON.stringify(sourceTagName)});`,
382
+ );
383
+
384
+ for (const [attr, value] of staticAttrEntries) {
385
+ lines.push(
386
+ `\t\tel.setAttribute(${JSON.stringify(attr)}, ${JSON.stringify(value)});`,
387
+ );
388
+ }
389
+ for (const { propName, attrName } of propBindings) {
390
+ lines.push(
391
+ `\t\tif (${propName} !== null) el.setAttribute(${JSON.stringify(attrName)}, ${propName});`,
392
+ );
393
+ }
394
+
395
+ if (!isVoid) {
396
+ lines.push("\t\tel.innerHTML = this.innerHTML;");
397
+ }
398
+
399
+ lines.push("\t\tthis.replaceWith(el);");
400
+ renderBody = lines.join("\n");
401
+ } else {
402
+ const attrVars = observedAttributes
403
+ .map((a) => `\t\tconst ${a} = this.getAttribute("${a}");`)
404
+ .join("\n");
405
+ const fallbackLines: string[] = [];
406
+ if (attrVars) fallbackLines.push(attrVars);
407
+ if (hasShadow)
408
+ fallbackLines.push(`\t\tthis.shadow.innerHTML = \`<slot></slot>\`;`);
409
+ renderBody = fallbackLines.join("\n");
410
+ }
411
+
412
+ const renderMethod = renderBody
413
+ ? `\tprivate render(): void {\n${renderBody}\n\t}`
414
+ : `\tprivate render(): void {\n\t}`;
415
+
416
+ return `class ${className} extends HTMLElement {
417
+ ${shadowSetup}\tstatic get observedAttributes(): string[] {
418
+ \t\treturn ${attrArray};
419
+ \t}
420
+
421
+ \tconnectedCallback(): void {
422
+ \t\tthis.render();
423
+ \t}
424
+
425
+ \tattributeChangedCallback(): void {
426
+ \t\tthis.render();
427
+ \t}
428
+
429
+ ${renderMethod}
430
+ }`;
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Playwright test generator
435
+ // ---------------------------------------------------------------------------
436
+
437
+ interface HtmlVisualSpecInput {
438
+ viewName: string;
439
+ relativeOutputPath: string;
440
+ fixtureFileName: string;
441
+ viewports: Array<{ name?: string; width: number; height: number }>;
442
+ states: Array<{
443
+ name: string;
444
+ hover?: string;
445
+ focus?: string;
446
+ click?: string;
447
+ waitFor?: string;
448
+ }>;
449
+ assertions: {
450
+ screenshot: boolean;
451
+ layout: boolean;
452
+ layoutTolerance: number;
453
+ selectors: string[];
454
+ screenshotThreshold: number;
455
+ screenshotMaxDiffPixels: number;
456
+ };
457
+ }
458
+
459
+ function emitHtmlVisualSpec(input: HtmlVisualSpecInput): string {
460
+ const viewports = JSON.stringify(input.viewports, null, 2);
461
+ const states = JSON.stringify(input.states, null, 2);
462
+ const selectors = JSON.stringify(input.assertions.selectors, null, 2);
463
+ const screenshotEnabled = JSON.stringify(input.assertions.screenshot);
464
+ const layoutEnabled = JSON.stringify(input.assertions.layout);
465
+ const layoutTolerance = JSON.stringify(input.assertions.layoutTolerance);
466
+ const screenshotThreshold = JSON.stringify(
467
+ input.assertions.screenshotThreshold,
468
+ );
469
+ const screenshotMaxDiffPixels = JSON.stringify(
470
+ input.assertions.screenshotMaxDiffPixels,
471
+ );
472
+
473
+ return `import { readFileSync } from "node:fs";
474
+ import { dirname, resolve } from "node:path";
475
+ import { fileURLToPath } from "node:url";
476
+ import { expect, test } from "@playwright/test";
477
+ import pixelmatch from "pixelmatch";
478
+ import { PNG } from "pngjs";
479
+
480
+ const currentDir = dirname(fileURLToPath(import.meta.url));
481
+ const referenceHtml = readFileSync(resolve(currentDir, "./${input.fixtureFileName}"), "utf-8");
482
+ const outputHtmlPath = resolve(currentDir, "${input.relativeOutputPath}");
483
+ const viewports = ${viewports};
484
+ const states = ${states};
485
+ const selectors = ${selectors};
486
+ const screenshotEnabled = ${screenshotEnabled};
487
+ const layoutEnabled = ${layoutEnabled};
488
+ const layoutTolerance = ${layoutTolerance};
489
+ const screenshotThreshold = ${screenshotThreshold};
490
+ const screenshotMaxDiffPixels = ${screenshotMaxDiffPixels};
491
+
492
+ for (const viewport of viewports) {
493
+ \tfor (const state of states) {
494
+ \t\tconst viewportName = viewport.name ?? String(viewport.width) + "x" + String(viewport.height);
495
+ \t\ttest("${input.viewName} matches source at " + viewportName + " / " + state.name, async ({ page }) => {
496
+ \t\t\tawait page.setViewportSize({ width: viewport.width, height: viewport.height });
497
+
498
+ \t\t\tawait page.setContent(referenceHtml);
499
+ \t\t\tawait applyState(page, state);
500
+ \t\t\tconst expectedScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
501
+ \t\t\tconst expectedLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
502
+
503
+ \t\t\tawait page.goto("file://" + outputHtmlPath);
504
+ \t\t\tawait page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
505
+ \t\t\tawait applyState(page, state);
506
+ \t\t\tconst actualScreenshot = screenshotEnabled ? await page.screenshot({ fullPage: true }) : undefined;
507
+ \t\t\tconst actualLayout = layoutEnabled ? await readLayout(page.locator("body > *").first(), selectors) : [];
508
+
509
+ \t\t\tif (screenshotEnabled) {
510
+ \t\t\t\tconst expectedPng = PNG.sync.read(expectedScreenshot);
511
+ \t\t\t\tconst actualPng = PNG.sync.read(actualScreenshot);
512
+ \t\t\t\texpect(actualPng.width, "screenshot width").toBe(expectedPng.width);
513
+ \t\t\t\texpect(actualPng.height, "screenshot height").toBe(expectedPng.height);
514
+ \t\t\t\tconst diff = new PNG({ width: expectedPng.width, height: expectedPng.height });
515
+ \t\t\t\tconst diffPixelCount = pixelmatch(expectedPng.data, actualPng.data, diff.data, expectedPng.width, expectedPng.height, { threshold: screenshotThreshold });
516
+ \t\t\t\texpect(diffPixelCount, "screenshot diff pixels").toBeLessThanOrEqual(screenshotMaxDiffPixels);
517
+ \t\t\t}
518
+ \t\t\tif (layoutEnabled) {
519
+ \t\t\t\texpectLayoutToMatch(actualLayout, expectedLayout, layoutTolerance);
520
+ \t\t\t}
521
+ \t\t});
522
+ \t}
523
+ }
524
+
525
+ async function applyState(page, state) {
526
+ \tif (state.waitFor) {
527
+ \t\tawait page.waitForSelector(state.waitFor);
528
+ \t}
529
+ \tif (state.hover) {
530
+ \t\tawait page.hover(state.hover);
531
+ \t}
532
+ \tif (state.focus) {
533
+ \t\tawait page.focus(state.focus);
534
+ \t}
535
+ \tif (state.click) {
536
+ \t\tawait page.click(state.click);
537
+ \t}
538
+ }
539
+
540
+ async function readLayout(root, selectorsToRead) {
541
+ \treturn root.evaluate((element, values) => {
542
+ \t\treturn values.flatMap((selector) => {
543
+ \t\t\tconst matches = selector === ":scope" ? [element] : Array.from(element.querySelectorAll(selector));
544
+ \t\t\treturn matches.map((matchedElement, index) => {
545
+ \t\t\t\tconst rect = matchedElement.getBoundingClientRect();
546
+ \t\t\t\treturn {
547
+ \t\t\t\t\tselector,
548
+ \t\t\t\t\tindex,
549
+ \t\t\t\t\ttagName: matchedElement.tagName.toLowerCase(),
550
+ \t\t\t\t\tx: rect.x,
551
+ \t\t\t\t\ty: rect.y,
552
+ \t\t\t\t\twidth: rect.width,
553
+ \t\t\t\t\theight: rect.height,
554
+ \t\t\t\t};
555
+ \t\t\t});
556
+ \t\t});
557
+ \t}, selectorsToRead);
558
+ }
559
+
560
+ function expectLayoutToMatch(actual, expected, tolerance) {
561
+ \texpect(actual.length).toBe(expected.length);
562
+ \tfor (let index = 0; index < expected.length; index += 1) {
563
+ \t\tconst actualRect = actual[index];
564
+ \t\tconst expectedRect = expected[index];
565
+ \t\texpect(actualRect.selector).toBe(expectedRect.selector);
566
+ \t\texpect(actualRect.index).toBe(expectedRect.index);
567
+ \t\texpect(actualRect.tagName).toBe(expectedRect.tagName);
568
+ \t\tfor (const key of ["x", "y", "width", "height"]) {
569
+ \t\t\tconst drift = Math.abs(actualRect[key] - expectedRect[key]);
570
+ \t\t\texpect(drift, \`\${expectedRect.selector}[\${expectedRect.index}] \${key} drift\`).toBeLessThanOrEqual(tolerance);
571
+ \t\t}
572
+ \t}
573
+ }
574
+ `;
575
+ }
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // Utilities
579
+ // ---------------------------------------------------------------------------
580
+
581
+ function toRelativeFilePath(fromFile: string, toFile: string): string {
582
+ const fromParts = fromFile.split("/").slice(0, -1);
583
+ const toParts = toFile.split("/");
584
+ while (
585
+ fromParts.length > 0 &&
586
+ toParts.length > 0 &&
587
+ fromParts[0] === toParts[0]
588
+ ) {
589
+ fromParts.shift();
590
+ toParts.shift();
591
+ }
592
+ const prefix = fromParts.map(() => "..");
593
+ const relative = [...prefix, ...toParts].join("/");
594
+ return relative.startsWith(".") ? relative : `./${relative}`;
595
+ }
596
+
59
597
  function escapeHtml(value: string): string {
60
598
  return value
61
599
  .replace(/&/g, "&amp;")
@@ -66,3 +604,18 @@ function escapeHtml(value: string): string {
66
604
  function escapeAttribute(value: string): string {
67
605
  return escapeHtml(value).replace(/"/g, "&quot;");
68
606
  }
607
+
608
+ function toCustomElementTag(name: string): string {
609
+ const kebab = name
610
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
611
+ .toLowerCase()
612
+ .replace(/^-/, "");
613
+ return kebab.includes("-") ? kebab : `${kebab}-el`;
614
+ }
615
+
616
+ function toPascalCase(kebab: string): string {
617
+ return kebab
618
+ .split("-")
619
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
620
+ .join("");
621
+ }