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
@@ -1,30 +1,20 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, isAbsolute, resolve } from "node:path";
3
- import { type DesignEmbedConfig, loadConfig } from "@design-embed/config";
4
- import type { Diagnostic } from "@design-embed/core";
5
- import { reactTestGenerator } from "@design-embed/target-react";
1
+ import { resolve } from "node:path";
6
2
  import { getBooleanFlag, getFormat, getStringFlag } from "../args.ts";
3
+ import { loadConfig } from "../config/index.ts";
4
+ import { type Diagnostic, embed } from "../core/index.ts";
5
+ import type { DesignEmbedConfig } from "../core/types.ts";
7
6
  import { printDiagnostics } from "./compile.ts";
8
7
 
9
8
  export async function runGenerateTestsCommand(
10
9
  flags: Record<string, string | boolean>,
11
10
  ): Promise<number> {
12
11
  const cwd = resolve(process.cwd(), getStringFlag(flags, "--cwd") ?? ".");
13
- const configPath = getStringFlag(flags, "--config");
12
+ const configPath =
13
+ getStringFlag(flags, "--config") ?? "design-embed.config.ts";
14
14
  const quiet = getBooleanFlag(flags, "--quiet");
15
15
  const format = getFormat(flags);
16
16
  const diagnostics: Diagnostic[] = [];
17
17
 
18
- if (!configPath) {
19
- diagnostics.push({
20
- code: "CONFIG_REQUIRED",
21
- message: "--config is required for generate-tests.",
22
- severity: "error",
23
- });
24
- printDiagnostics(diagnostics, format, quiet);
25
- return 2;
26
- }
27
-
28
18
  const configResult = await loadConfig(configPath, cwd);
29
19
  diagnostics.push(...configResult.diagnostics);
30
20
  const config = configResult.config;
@@ -33,43 +23,24 @@ export async function runGenerateTestsCommand(
33
23
  return 2;
34
24
  }
35
25
 
36
- const source = readConfiguredSource(config, configPath, cwd, diagnostics);
37
- if (!source || hasErrors(diagnostics)) {
38
- printDiagnostics(diagnostics, format, quiet);
39
- return 2;
40
- }
41
-
42
- const target = config.output?.target ?? "html";
43
- if (target !== "react") {
26
+ if (!getTestGenerator(config)) {
44
27
  diagnostics.push({
45
28
  code: "TEST_TARGET_UNSUPPORTED",
46
- message: `generate-tests currently supports target "react"; received "${target}".`,
29
+ message:
30
+ "generate-tests requires output.target to be a target adapter with generateTests().",
47
31
  severity: "error",
48
32
  });
49
33
  printDiagnostics(diagnostics, format, quiet);
50
34
  return 2;
51
35
  }
52
36
 
53
- const result = reactTestGenerator.generateTests({
54
- html: source.html,
55
- css: source.css,
56
- config,
57
- diagnostics,
58
- });
37
+ const result = await embed({ config, cwd, generateTests: true });
38
+ diagnostics.push(...result.diagnostics);
59
39
  if (hasErrors(diagnostics)) {
60
40
  printDiagnostics(diagnostics, format, quiet);
61
41
  return 2;
62
42
  }
63
43
 
64
- for (const file of result.files) {
65
- const outPath = resolve(cwd, file.path);
66
- mkdirSync(dirname(outPath), { recursive: true });
67
- writeFileSync(outPath, file.contents, "utf-8");
68
- if (!quiet && format === "text") {
69
- console.log(`Wrote ${file.path}`);
70
- }
71
- }
72
-
73
44
  printDiagnostics(diagnostics, format, quiet);
74
45
  if (!quiet && format === "text") {
75
46
  console.log(`Success. Generated ${result.files.length} test file(s).`);
@@ -77,62 +48,9 @@ export async function runGenerateTestsCommand(
77
48
  return 0;
78
49
  }
79
50
 
80
- interface SourceContents {
81
- html: string;
82
- css?: string;
83
- }
84
-
85
- function readConfiguredSource(
86
- config: DesignEmbedConfig,
87
- configPath: string,
88
- cwd: string,
89
- diagnostics: Diagnostic[],
90
- ): SourceContents | undefined {
91
- const source = config.tests?.source;
92
- if (!source?.html) {
93
- diagnostics.push({
94
- code: "TEST_SOURCE_HTML_REQUIRED",
95
- message: "tests.source.html is required for generate-tests.",
96
- severity: "error",
97
- });
98
- return undefined;
99
- }
100
-
101
- const configDir = dirname(resolve(cwd, configPath));
102
- const htmlPath = resolveConfigPath(source.html, configDir);
103
- if (!existsSync(htmlPath)) {
104
- diagnostics.push({
105
- code: "TEST_SOURCE_HTML_NOT_FOUND",
106
- message: `Test source HTML not found: ${htmlPath}`,
107
- severity: "error",
108
- file: source.html,
109
- });
110
- return undefined;
111
- }
112
-
113
- let css: string | undefined;
114
- if (source.css) {
115
- const cssPath = resolveConfigPath(source.css, configDir);
116
- if (!existsSync(cssPath)) {
117
- diagnostics.push({
118
- code: "TEST_SOURCE_CSS_NOT_FOUND",
119
- message: `Test source CSS not found: ${cssPath}`,
120
- severity: "error",
121
- file: source.css,
122
- });
123
- return undefined;
124
- }
125
- css = readFileSync(cssPath, "utf-8");
126
- }
127
-
128
- return {
129
- html: readFileSync(htmlPath, "utf-8"),
130
- css,
131
- };
132
- }
133
-
134
- function resolveConfigPath(path: string, configDir: string): string {
135
- return isAbsolute(path) ? path : resolve(configDir, path);
51
+ function getTestGenerator(config: DesignEmbedConfig): boolean {
52
+ const target = config.output?.target;
53
+ return !!(target && target !== "html" && "generateTests" in target);
136
54
  }
137
55
 
138
56
  function hasErrors(diagnostics: Diagnostic[]): boolean {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
- import type { Diagnostic } from "@design-embed/core";
4
3
  import { getBooleanFlag, getFormat, getStringFlag } from "../args.ts";
4
+ import type { Diagnostic } from "../core/index.ts";
5
5
  import { printDiagnostics } from "./compile.ts";
6
6
 
7
7
  export async function runInitCommand(
@@ -19,14 +19,6 @@ export async function runInitCommand(
19
19
  path: "design-embed.config.ts",
20
20
  contents: configTemplate(viewName),
21
21
  },
22
- {
23
- path: "design.html",
24
- contents: designHtmlTemplate(),
25
- },
26
- {
27
- path: "playwright-ct.config.ts",
28
- contents: playwrightConfigTemplate(),
29
- },
30
22
  ];
31
23
 
32
24
  let written = 0;
@@ -52,62 +44,67 @@ export async function runInitCommand(
52
44
  printDiagnostics(diagnostics, format, quiet);
53
45
  if (!quiet && format === "text") {
54
46
  console.log(`Success. Initialized design-embed with ${written} file(s).`);
55
- console.log(
56
- "Next: pnpm exec design-embed --input ./design.html --config ./design-embed.config.ts",
57
- );
47
+ console.log("Next: pnpm exec design-embed --out ./design.html");
58
48
  }
59
49
  return 0;
60
50
  }
61
51
 
62
52
  function configTemplate(viewName: string): string {
63
- return `import { defineConfig } from "design-embed";
53
+ return `import {
54
+ \tdefineConfig,
55
+ \ttype SourcePlugin,
56
+ \ttype SourcePluginInput,
57
+ \ttype SourcePluginResult,
58
+ } from "design-embed";
64
59
 
65
- export default defineConfig({
66
- \toutput: {
67
- \t\ttarget: "react",
68
- \t\tviewName: "${viewName}",
69
- \t\tviewsDir: "src/generated/views",
70
- \t\tstyleMode: "inline",
71
- \t},
72
- \ttests: {
73
- \t\toutputDir: "tests/generated/design-embed",
74
- \t\trunner: "playwright",
75
- \t\tsource: {
76
- \t\t\thtml: "./design.html",
77
- \t\t},
78
- \t\tviewports: [
79
- \t\t\t{ name: "mobile", width: 390, height: 844 },
80
- \t\t\t{ name: "desktop", width: 1440, height: 900 },
81
- \t\t],
82
- \t\tstates: [{ name: "default" }],
83
- \t\tassertions: {
84
- \t\t\tscreenshot: true,
85
- \t\t\tlayout: true,
86
- \t\t\tlayoutTolerance: 1,
87
- \t\t\tselectors: [":scope", ":scope *"],
88
- \t\t},
89
- \t},
90
- });
91
- `;
92
- }
60
+ class HtmlFetcherPlugin implements SourcePlugin {
61
+ \treadonly name = "html-fetcher";
62
+ \tprivate readonly options: { url: string };
93
63
 
94
- function designHtmlTemplate(): string {
95
- return `<section style="box-sizing: border-box; width: 320px; padding: 24px; border-radius: 16px; background: #f8fafc; color: #0f172a; font-family: Arial, sans-serif;">
96
- \t<p style="margin: 0 0 8px; color: #2563eb; font-size: 14px; font-weight: 700;">Design Embed</p>
97
- \t<h1 style="margin: 0 0 12px; font-size: 32px; line-height: 1.1;">Welcome hero</h1>
98
- \t<p style="margin: 0 0 20px; font-size: 16px; line-height: 1.5;">Replace this file with HTML exported from your design source.</p>
99
- \t<button data-role="primary" style="border: 0; border-radius: 999px; padding: 12px 18px; background: #2563eb; color: white; font-size: 14px; font-weight: 700;">Get started</button>
100
- </section>
101
- `;
102
- }
64
+ \tconstructor(options: { url: string }) {
65
+ \t\tthis.options = options;
66
+ \t}
103
67
 
104
- function playwrightConfigTemplate(): string {
105
- return `import { defineConfig } from "@playwright/experimental-ct-react";
68
+ \tasync run(_input: SourcePluginInput): Promise<SourcePluginResult> {
69
+ \t\ttry {
70
+ \t\t\tconst response = await fetch(this.options.url);
71
+ \t\t\tif (!response.ok) {
72
+ \t\t\t\treturn {
73
+ \t\t\t\t\tdiagnostics: [
74
+ \t\t\t\t\t\t{
75
+ \t\t\t\t\t\t\tcode: "HTML_FETCH_FAILED",
76
+ \t\t\t\t\t\t\tmessage: \`Failed to fetch HTML: \${response.status} \${response.statusText}\`,
77
+ \t\t\t\t\t\t\tseverity: "error",
78
+ \t\t\t\t\t\t},
79
+ \t\t\t\t\t],
80
+ \t\t\t\t};
81
+ \t\t\t}
82
+
83
+ \t\t\treturn {
84
+ \t\t\t\thtml: await response.text(),
85
+ \t\t\t\tdiagnostics: [],
86
+ \t\t\t};
87
+ \t\t} catch (error) {
88
+ \t\t\treturn {
89
+ \t\t\t\tdiagnostics: [
90
+ \t\t\t\t\t{
91
+ \t\t\t\t\t\tcode: "HTML_FETCH_FAILED",
92
+ \t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),
93
+ \t\t\t\t\t\tseverity: "error",
94
+ \t\t\t\t\t},
95
+ \t\t\t\t],
96
+ \t\t\t};
97
+ \t\t}
98
+ \t}
99
+ }
106
100
 
107
101
  export default defineConfig({
108
- \ttestDir: ".",
109
- \tuse: {
110
- \t\tctPort: 3100,
102
+ \tsource: new HtmlFetcherPlugin({
103
+ \t\turl: "https://www.scrapethissite.com/pages/",
104
+ \t}),
105
+ \toutput: {
106
+ \t\tviewName: "${viewName}",
107
+ \t\tviewsDir: "src/generated/views",
111
108
  \t},
112
109
  });
113
110
  `;
@@ -1,20 +1,15 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
- import { loadConfig, type PluginDefinition } from "@design-embed/config";
4
- import type { SourcePlugin } from "@design-embed/core";
5
3
  import { getStringFlag } from "../args.ts";
4
+ import { loadConfig } from "../config/index.ts";
6
5
 
7
6
  export async function runPluginCommand(
8
7
  _name: string | undefined,
9
8
  flags: Record<string, string | boolean>,
10
9
  ): Promise<number> {
11
- const configPath = getStringFlag(flags, "--config");
12
- if (!configPath) {
13
- console.error("Error: --config is required.");
14
- return 2;
15
- }
16
-
17
- const cwd = process.cwd();
10
+ const cwd = resolve(process.cwd(), getStringFlag(flags, "--cwd") ?? ".");
11
+ const configPath =
12
+ getStringFlag(flags, "--config") ?? "design-embed.config.ts";
18
13
  const configResult = await loadConfig(configPath, cwd);
19
14
  for (const diagnostic of configResult.diagnostics) {
20
15
  if (diagnostic.severity === "error") {
@@ -35,7 +30,7 @@ export async function runPluginCommand(
35
30
  return 2;
36
31
  }
37
32
 
38
- const plugin = findSourcePlugin(configResult.config?.plugins);
33
+ const plugin = configResult.config?.source;
39
34
  if (!plugin) {
40
35
  console.error(
41
36
  "Error: config must include a source plugin instance in the plugins array (e.g. new FigmaHtmlPlugin({ ... })).",
@@ -43,7 +38,7 @@ export async function runPluginCommand(
43
38
  return 2;
44
39
  }
45
40
 
46
- const result = await plugin.run({ cwd, args: {} });
41
+ const result = await plugin.run({ cwd });
47
42
 
48
43
  for (const diagnostic of result.diagnostics) {
49
44
  const output = `${diagnostic.severity}: ${diagnostic.code}: ${diagnostic.message}`;
@@ -77,13 +72,3 @@ export async function runPluginCommand(
77
72
 
78
73
  return 0;
79
74
  }
80
-
81
- function isSourcePlugin(plugin: PluginDefinition): plugin is SourcePlugin {
82
- return typeof (plugin as SourcePlugin).run === "function";
83
- }
84
-
85
- function findSourcePlugin(
86
- plugins: PluginDefinition[] | undefined,
87
- ): SourcePlugin | undefined {
88
- return plugins?.find(isSourcePlugin);
89
- }
@@ -0,0 +1,302 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import type { Diagnostic } from "../core/diagnostics/diagnostic.ts";
5
+ import type {
6
+ SourcePlugin,
7
+ SourcePluginInput,
8
+ SourcePluginResult,
9
+ } from "../core/plugins/pluginApi.ts";
10
+ import type { DesignEmbedConfig, TestGenerationConfig } from "../core/types.ts";
11
+
12
+ export type { Diagnostic } from "../core/diagnostics/diagnostic.ts";
13
+
14
+ export interface LoadConfigResult {
15
+ config?: DesignEmbedConfig;
16
+ configPath?: string;
17
+ diagnostics: Diagnostic[];
18
+ }
19
+
20
+ export function defineConfig(config: DesignEmbedConfig): DesignEmbedConfig {
21
+ return config;
22
+ }
23
+
24
+ export function fromFile(
25
+ htmlPath: string | URL,
26
+ cssPath?: string | URL,
27
+ ): SourcePlugin {
28
+ const resolvedHtml = htmlPath instanceof URL ? fileURLToPath(htmlPath) : null;
29
+ const resolvedCss = cssPath
30
+ ? cssPath instanceof URL
31
+ ? fileURLToPath(cssPath)
32
+ : null
33
+ : null;
34
+ return {
35
+ name: "html-file",
36
+ async run({ cwd }: SourcePluginInput): Promise<SourcePluginResult> {
37
+ const html = readFileSync(
38
+ resolvedHtml ?? resolve(cwd, htmlPath as string),
39
+ "utf-8",
40
+ );
41
+ const css = cssPath
42
+ ? readFileSync(resolvedCss ?? resolve(cwd, cssPath as string), "utf-8")
43
+ : undefined;
44
+ return { html, css, diagnostics: [] };
45
+ },
46
+ };
47
+ }
48
+
49
+ export async function loadConfig(
50
+ configPath: string,
51
+ cwd = process.cwd(),
52
+ ): Promise<LoadConfigResult> {
53
+ const diagnostics: Diagnostic[] = [];
54
+ const resolvedPath = isAbsolute(configPath)
55
+ ? configPath
56
+ : resolve(cwd, configPath);
57
+
58
+ if (!existsSync(resolvedPath)) {
59
+ return {
60
+ diagnostics: [
61
+ {
62
+ code: "CONFIG_NOT_FOUND",
63
+ message: `Config file not found: ${resolvedPath}`,
64
+ severity: "error",
65
+ },
66
+ ],
67
+ };
68
+ }
69
+
70
+ if (!/\.(ts|js|mjs)$/.test(resolvedPath)) {
71
+ return {
72
+ diagnostics: [
73
+ {
74
+ code: "CONFIG_UNSUPPORTED_FORMAT",
75
+ message: `Unsupported config format: ${resolvedPath}. Only .ts, .js, and .mjs are supported.`,
76
+ severity: "error",
77
+ },
78
+ ],
79
+ };
80
+ }
81
+
82
+ try {
83
+ const module = await import(pathToFileURL(resolvedPath).href);
84
+ const config = module.default ?? module.config;
85
+
86
+ if (!config) {
87
+ return {
88
+ diagnostics: [
89
+ {
90
+ code: "CONFIG_INVALID",
91
+ message: `Config file must export a default object or a named 'config' object: ${resolvedPath}`,
92
+ severity: "error",
93
+ },
94
+ ],
95
+ };
96
+ }
97
+
98
+ diagnostics.push(...validateConfig(config));
99
+ return { config, configPath: resolvedPath, diagnostics };
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ return {
103
+ diagnostics: [
104
+ {
105
+ code: "CONFIG_INVALID",
106
+ message: `Failed to load config file: ${message}`,
107
+ severity: "error",
108
+ },
109
+ ],
110
+ };
111
+ }
112
+ }
113
+
114
+ export function validateConfig(config: DesignEmbedConfig): Diagnostic[] {
115
+ const diagnostics: Diagnostic[] = [];
116
+ const target = config.output?.target;
117
+ const styleMode = config.output?.styleMode;
118
+
119
+ if (
120
+ target &&
121
+ target !== "html" &&
122
+ (typeof target !== "object" || typeof target.emit !== "function")
123
+ ) {
124
+ diagnostics.push({
125
+ code: "TARGET_ADAPTER_INVALID",
126
+ message: "output.target must be a target adapter with emit().",
127
+ severity: "error",
128
+ });
129
+ }
130
+
131
+ if (
132
+ styleMode &&
133
+ styleMode !== "inline" &&
134
+ styleMode !== "css-modules" &&
135
+ styleMode !== "tailwind"
136
+ ) {
137
+ diagnostics.push({
138
+ code: "STYLE_MODE_UNSUPPORTED",
139
+ message: `Unsupported style mode: ${styleMode}`,
140
+ severity: "error",
141
+ });
142
+ }
143
+
144
+ for (const [index, component] of (config.components ?? []).entries()) {
145
+ if (!component.selector || typeof component.selector !== "string") {
146
+ diagnostics.push({
147
+ code: "COMPONENT_SELECTOR_INVALID",
148
+ message: `Component mapping ${index} must include a selector.`,
149
+ severity: "error",
150
+ });
151
+ }
152
+
153
+ if (!component.component || typeof component.component !== "string") {
154
+ diagnostics.push({
155
+ code: "COMPONENT_IMPORT_INVALID",
156
+ message: `Component mapping ${index} must include a component name.`,
157
+ severity: "error",
158
+ });
159
+ }
160
+ }
161
+
162
+ const spacing = config.tokens?.spacing;
163
+ if (spacing?.unit && spacing.unit !== "px" && spacing.unit !== "rem") {
164
+ diagnostics.push({
165
+ code: "TOKEN_SPACING_UNIT_INVALID",
166
+ message: `Unsupported spacing unit: ${spacing.unit}`,
167
+ severity: "error",
168
+ });
169
+ }
170
+
171
+ if (spacing?.threshold !== undefined && !Number.isFinite(spacing.threshold)) {
172
+ diagnostics.push({
173
+ code: "TOKEN_SPACING_THRESHOLD_INVALID",
174
+ message: "Spacing threshold must be a finite number.",
175
+ severity: "error",
176
+ });
177
+ }
178
+
179
+ for (const [name, value] of Object.entries(spacing?.values ?? {})) {
180
+ if (!Number.isFinite(value)) {
181
+ diagnostics.push({
182
+ code: "TOKEN_SPACING_VALUE_INVALID",
183
+ message: `Spacing token ${name} must be a finite number.`,
184
+ severity: "error",
185
+ });
186
+ }
187
+ }
188
+
189
+ for (const [name, value] of Object.entries(config.tokens?.colors ?? {})) {
190
+ if (!/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value)) {
191
+ diagnostics.push({
192
+ code: "TOKEN_COLOR_INVALID",
193
+ message: `Color token ${name} must be a hex color.`,
194
+ severity: "error",
195
+ });
196
+ }
197
+ }
198
+
199
+ if (
200
+ config.tokens?.colorThreshold !== undefined &&
201
+ !Number.isFinite(config.tokens.colorThreshold)
202
+ ) {
203
+ diagnostics.push({
204
+ code: "TOKEN_COLOR_THRESHOLD_INVALID",
205
+ message: "Color threshold must be a finite number.",
206
+ severity: "error",
207
+ });
208
+ }
209
+
210
+ for (const [groupName, group] of Object.entries({
211
+ sizing: config.tokens?.sizing,
212
+ typography: config.tokens?.typography,
213
+ })) {
214
+ if (!group) {
215
+ continue;
216
+ }
217
+ if (group.unit && group.unit !== "px" && group.unit !== "rem") {
218
+ diagnostics.push({
219
+ code: "TOKEN_NUMERIC_UNIT_INVALID",
220
+ message: `Unsupported ${groupName} unit: ${group.unit}`,
221
+ severity: "error",
222
+ });
223
+ }
224
+ if (group.threshold !== undefined && !Number.isFinite(group.threshold)) {
225
+ diagnostics.push({
226
+ code: "TOKEN_NUMERIC_THRESHOLD_INVALID",
227
+ message: `${groupName} threshold must be a finite number.`,
228
+ severity: "error",
229
+ });
230
+ }
231
+ for (const [name, value] of Object.entries(group.values ?? {})) {
232
+ if (!Number.isFinite(value)) {
233
+ diagnostics.push({
234
+ code: "TOKEN_NUMERIC_VALUE_INVALID",
235
+ message: `${groupName} token ${name} must be a finite number.`,
236
+ severity: "error",
237
+ });
238
+ }
239
+ }
240
+ }
241
+
242
+ validateTestGeneration(config.tests, diagnostics);
243
+
244
+ return diagnostics;
245
+ }
246
+
247
+ function validateTestGeneration(
248
+ tests: TestGenerationConfig | undefined,
249
+ diagnostics: Diagnostic[],
250
+ ): void {
251
+ if (!tests) {
252
+ return;
253
+ }
254
+
255
+ if (tests.runner && tests.runner !== "playwright") {
256
+ diagnostics.push({
257
+ code: "TEST_RUNNER_UNSUPPORTED",
258
+ message: `Unsupported test runner: ${tests.runner}`,
259
+ severity: "error",
260
+ });
261
+ }
262
+
263
+ for (const [index, viewport] of (tests.viewports ?? []).entries()) {
264
+ if (!Number.isFinite(viewport.width) || viewport.width <= 0) {
265
+ diagnostics.push({
266
+ code: "TEST_VIEWPORT_WIDTH_INVALID",
267
+ message: `Test viewport ${index} width must be a positive finite number.`,
268
+ severity: "error",
269
+ });
270
+ }
271
+ if (!Number.isFinite(viewport.height) || viewport.height <= 0) {
272
+ diagnostics.push({
273
+ code: "TEST_VIEWPORT_HEIGHT_INVALID",
274
+ message: `Test viewport ${index} height must be a positive finite number.`,
275
+ severity: "error",
276
+ });
277
+ }
278
+ }
279
+
280
+ for (const [index, state] of (tests.states ?? []).entries()) {
281
+ if (!state.name || typeof state.name !== "string") {
282
+ diagnostics.push({
283
+ code: "TEST_STATE_NAME_INVALID",
284
+ message: `Test state ${index} must include a name.`,
285
+ severity: "error",
286
+ });
287
+ }
288
+ }
289
+
290
+ if (
291
+ tests.assertions?.layoutTolerance !== undefined &&
292
+ (!Number.isFinite(tests.assertions.layoutTolerance) ||
293
+ tests.assertions.layoutTolerance < 0)
294
+ ) {
295
+ diagnostics.push({
296
+ code: "TEST_LAYOUT_TOLERANCE_INVALID",
297
+ message:
298
+ "Test layout tolerance must be a finite number greater than or equal to 0.",
299
+ severity: "error",
300
+ });
301
+ }
302
+ }