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,17 +1,14 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
3
- import { type DesignEmbedConfig, loadConfig } from "@design-embed/config";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { getBooleanFlag, getFormat, getStringFlag } from "../args.ts";
4
+ import { loadConfig } from "../config/index.ts";
4
5
  import {
5
6
  checkGeneratedFiles,
6
7
  type Diagnostic,
7
8
  embed,
8
9
  formatDiagnosticText,
9
- type TargetEmitter,
10
- type TargetTestGenerator,
11
10
  toJsonDiagnostics,
12
- } from "@design-embed/core";
13
- import { getBooleanFlag, getFormat, getStringFlag } from "../args.ts";
14
- import { htmlEmitter } from "../targets/html.ts";
11
+ } from "../core/index.ts";
15
12
 
16
13
  export interface CompileCommandOptions {
17
14
  check?: boolean;
@@ -22,8 +19,6 @@ export async function runCompileCommand(
22
19
  options: CompileCommandOptions = {},
23
20
  ): Promise<number> {
24
21
  const cwd = resolve(process.cwd(), getStringFlag(flags, "--cwd") ?? ".");
25
- const inputPath =
26
- getStringFlag(flags, "--input") ?? getStringFlag(flags, "--");
27
22
  const explicitConfigPath = getStringFlag(flags, "--config");
28
23
  const defaultConfigPath = resolve(cwd, "design-embed.config.ts");
29
24
  const configPath =
@@ -31,77 +26,45 @@ export async function runCompileCommand(
31
26
  (existsSync(defaultConfigPath) ? "design-embed.config.ts" : undefined);
32
27
  const quiet = getBooleanFlag(flags, "--quiet");
33
28
  const format = getFormat(flags);
34
- const generateTests = !getBooleanFlag(flags, "--no-test");
35
29
  const diagnostics: Diagnostic[] = [];
36
30
 
37
- if (!inputPath) {
31
+ if (!configPath) {
38
32
  diagnostics.push({
39
- code: "INPUT_REQUIRED",
40
- message: "--input is required.",
33
+ code: "CONFIG_REQUIRED",
34
+ message:
35
+ "No config file found. Create design-embed.config.ts or use --config.",
41
36
  severity: "error",
42
37
  });
43
38
  printDiagnostics(diagnostics, format, quiet);
44
39
  return 2;
45
40
  }
46
41
 
47
- const resolvedInputPath = resolve(cwd, inputPath);
48
- if (!existsSync(resolvedInputPath)) {
49
- diagnostics.push({
50
- code: "INPUT_NOT_FOUND",
51
- message: `Input file not found: ${resolvedInputPath}`,
52
- severity: "error",
53
- file: inputPath,
54
- });
42
+ const configResult = await loadConfig(configPath, cwd);
43
+ diagnostics.push(...configResult.diagnostics);
44
+ const config = configResult.config;
45
+
46
+ if (hasErrors(diagnostics)) {
55
47
  printDiagnostics(diagnostics, format, quiet);
56
48
  return 2;
57
49
  }
58
50
 
59
- let config: DesignEmbedConfig | undefined;
60
- if (configPath) {
61
- const configResult = await loadConfig(configPath, cwd);
62
- diagnostics.push(...configResult.diagnostics);
63
- config = configResult.config;
64
-
65
- if (hasErrors(diagnostics)) {
66
- printDiagnostics(diagnostics, format, quiet);
67
- return 2;
68
- }
69
- }
70
-
71
- const targetAdapter = getTargetAdapter(config);
51
+ const isCheckMode = options.check && !getBooleanFlag(flags, "--write");
52
+ const generateTests = !getBooleanFlag(flags, "--no-test");
72
53
 
73
- const cssPath = getStringFlag(flags, "--css");
74
- const html = readFileSync(resolvedInputPath, "utf-8");
75
- const css = cssPath
76
- ? readFileSync(resolve(cwd, cssPath), "utf-8")
77
- : undefined;
78
54
  const result = await embed({
79
- html,
80
- css,
81
- configPath,
82
55
  config,
83
56
  cwd,
84
- targetEmitter: targetAdapter.emitter,
57
+ dryRun: isCheckMode,
58
+ generateTests,
85
59
  });
86
60
  diagnostics.push(...result.diagnostics);
87
61
 
88
- if (generateTests && targetAdapter.testGenerator) {
89
- const testResult = targetAdapter.testGenerator.generateTests({
90
- html,
91
- css,
92
- config: config ?? {},
93
- diagnostics,
94
- generatedFiles: result.files,
95
- });
96
- result.files.push(...testResult.files);
97
- }
98
-
99
62
  if (hasErrors(diagnostics)) {
100
63
  printDiagnostics(diagnostics, format, quiet);
101
64
  return 2;
102
65
  }
103
66
 
104
- if (options.check && !getBooleanFlag(flags, "--write")) {
67
+ if (isCheckMode) {
105
68
  const checkResult = checkGeneratedFiles({
106
69
  cwd,
107
70
  files: result.files,
@@ -109,21 +72,11 @@ export async function runCompileCommand(
109
72
  return existsSync(path) ? readFileSync(path, "utf-8") : undefined;
110
73
  },
111
74
  });
112
- const checkDiagnostics = checkResult.diagnostics;
113
- diagnostics.push(...checkDiagnostics);
75
+ diagnostics.push(...checkResult.diagnostics);
114
76
  printDiagnostics(diagnostics, format, quiet);
115
77
  return checkResult.ok ? 0 : 3;
116
78
  }
117
79
 
118
- for (const file of result.files) {
119
- const outPath = resolve(cwd, file.path);
120
- mkdirSync(dirname(outPath), { recursive: true });
121
- writeFileSync(outPath, file.contents, "utf-8");
122
- if (!quiet && format === "text") {
123
- console.log(`Wrote ${file.path}`);
124
- }
125
- }
126
-
127
80
  printDiagnostics(diagnostics, format, quiet);
128
81
  if (!quiet && format === "text") {
129
82
  console.log(`Success. Generated ${result.files.length} file(s).`);
@@ -155,27 +108,6 @@ export function printDiagnostics(
155
108
  }
156
109
  }
157
110
 
158
- interface ResolvedTargetAdapter {
159
- emitter: TargetEmitter;
160
- testGenerator?: TargetTestGenerator;
161
- }
162
-
163
- function getTargetAdapter(
164
- config: DesignEmbedConfig | undefined,
165
- ): ResolvedTargetAdapter {
166
- const target = config?.output?.target;
167
- if (!target || target === "html") {
168
- return { emitter: htmlEmitter };
169
- }
170
- return {
171
- emitter: target as TargetEmitter,
172
- testGenerator:
173
- "generateTests" in target
174
- ? (target as TargetEmitter & TargetTestGenerator)
175
- : undefined,
176
- };
177
- }
178
-
179
111
  function hasErrors(diagnostics: Diagnostic[]): boolean {
180
112
  return diagnostics.some((diagnostic) => diagnostic.severity === "error");
181
113
  }
@@ -1,8 +1,8 @@
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, TargetTestGenerator } from "@design-embed/core";
1
+ import { resolve } from "node:path";
5
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";
6
6
  import { printDiagnostics } from "./compile.ts";
7
7
 
8
8
  export async function runGenerateTestsCommand(
@@ -23,14 +23,7 @@ export async function runGenerateTestsCommand(
23
23
  return 2;
24
24
  }
25
25
 
26
- const source = readConfiguredSource(config, configPath, cwd, diagnostics);
27
- if (!source || hasErrors(diagnostics)) {
28
- printDiagnostics(diagnostics, format, quiet);
29
- return 2;
30
- }
31
-
32
- const testGenerator = getTestGenerator(config);
33
- if (!testGenerator) {
26
+ if (!getTestGenerator(config)) {
34
27
  diagnostics.push({
35
28
  code: "TEST_TARGET_UNSUPPORTED",
36
29
  message:
@@ -41,26 +34,13 @@ export async function runGenerateTestsCommand(
41
34
  return 2;
42
35
  }
43
36
 
44
- const result = testGenerator.generateTests({
45
- html: source.html,
46
- css: source.css,
47
- config,
48
- diagnostics,
49
- });
37
+ const result = await embed({ config, cwd, generateTests: true });
38
+ diagnostics.push(...result.diagnostics);
50
39
  if (hasErrors(diagnostics)) {
51
40
  printDiagnostics(diagnostics, format, quiet);
52
41
  return 2;
53
42
  }
54
43
 
55
- for (const file of result.files) {
56
- const outPath = resolve(cwd, file.path);
57
- mkdirSync(dirname(outPath), { recursive: true });
58
- writeFileSync(outPath, file.contents, "utf-8");
59
- if (!quiet && format === "text") {
60
- console.log(`Wrote ${file.path}`);
61
- }
62
- }
63
-
64
44
  printDiagnostics(diagnostics, format, quiet);
65
45
  if (!quiet && format === "text") {
66
46
  console.log(`Success. Generated ${result.files.length} test file(s).`);
@@ -68,71 +48,9 @@ export async function runGenerateTestsCommand(
68
48
  return 0;
69
49
  }
70
50
 
71
- interface SourceContents {
72
- html: string;
73
- css?: string;
74
- }
75
-
76
- function readConfiguredSource(
77
- config: DesignEmbedConfig,
78
- configPath: string,
79
- cwd: string,
80
- diagnostics: Diagnostic[],
81
- ): SourceContents | undefined {
82
- const source = config.tests?.source;
83
- if (!source?.html) {
84
- diagnostics.push({
85
- code: "TEST_SOURCE_HTML_REQUIRED",
86
- message: "tests.source.html is required for generate-tests.",
87
- severity: "error",
88
- });
89
- return undefined;
90
- }
91
-
92
- const configDir = dirname(resolve(cwd, configPath));
93
- const htmlPath = resolveConfigPath(source.html, configDir);
94
- if (!existsSync(htmlPath)) {
95
- diagnostics.push({
96
- code: "TEST_SOURCE_HTML_NOT_FOUND",
97
- message: `Test source HTML not found: ${htmlPath}`,
98
- severity: "error",
99
- file: source.html,
100
- });
101
- return undefined;
102
- }
103
-
104
- let css: string | undefined;
105
- if (source.css) {
106
- const cssPath = resolveConfigPath(source.css, configDir);
107
- if (!existsSync(cssPath)) {
108
- diagnostics.push({
109
- code: "TEST_SOURCE_CSS_NOT_FOUND",
110
- message: `Test source CSS not found: ${cssPath}`,
111
- severity: "error",
112
- file: source.css,
113
- });
114
- return undefined;
115
- }
116
- css = readFileSync(cssPath, "utf-8");
117
- }
118
-
119
- return {
120
- html: readFileSync(htmlPath, "utf-8"),
121
- css,
122
- };
123
- }
124
-
125
- function resolveConfigPath(path: string, configDir: string): string {
126
- return isAbsolute(path) ? path : resolve(configDir, path);
127
- }
128
-
129
- function getTestGenerator(
130
- config: DesignEmbedConfig,
131
- ): TargetTestGenerator | undefined {
51
+ function getTestGenerator(config: DesignEmbedConfig): boolean {
132
52
  const target = config.output?.target;
133
- return target && target !== "html" && "generateTests" in target
134
- ? (target as TargetTestGenerator)
135
- : undefined;
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(
@@ -52,13 +52,12 @@ export async function runInitCommand(
52
52
  function configTemplate(viewName: string): string {
53
53
  return `import {
54
54
  \tdefineConfig,
55
- \ttype PluginDefinition,
56
55
  \ttype SourcePlugin,
57
56
  \ttype SourcePluginInput,
58
57
  \ttype SourcePluginResult,
59
58
  } from "design-embed";
60
59
 
61
- class HtmlFetcherPlugin implements PluginDefinition, SourcePlugin {
60
+ class HtmlFetcherPlugin implements SourcePlugin {
62
61
  \treadonly name = "html-fetcher";
63
62
  \tprivate readonly options: { url: string };
64
63
 
@@ -100,11 +99,9 @@ class HtmlFetcherPlugin implements PluginDefinition, SourcePlugin {
100
99
  }
101
100
 
102
101
  export default defineConfig({
103
- \tplugins: [
104
- \t\tnew HtmlFetcherPlugin({
105
- \t\t\turl: "https://www.scrapethissite.com/pages/",
106
- \t\t}),
107
- \t],
102
+ \tsource: new HtmlFetcherPlugin({
103
+ \t\turl: "https://www.scrapethissite.com/pages/",
104
+ \t}),
108
105
  \toutput: {
109
106
  \t\tviewName: "${viewName}",
110
107
  \t\tviewsDir: "src/generated/views",
@@ -1,8 +1,7 @@
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,
@@ -31,7 +30,7 @@ export async function runPluginCommand(
31
30
  return 2;
32
31
  }
33
32
 
34
- const plugin = findSourcePlugin(configResult.config?.plugins);
33
+ const plugin = configResult.config?.source;
35
34
  if (!plugin) {
36
35
  console.error(
37
36
  "Error: config must include a source plugin instance in the plugins array (e.g. new FigmaHtmlPlugin({ ... })).",
@@ -39,7 +38,7 @@ export async function runPluginCommand(
39
38
  return 2;
40
39
  }
41
40
 
42
- const result = await plugin.run({ cwd, args: {} });
41
+ const result = await plugin.run({ cwd });
43
42
 
44
43
  for (const diagnostic of result.diagnostics) {
45
44
  const output = `${diagnostic.severity}: ${diagnostic.code}: ${diagnostic.message}`;
@@ -73,13 +72,3 @@ export async function runPluginCommand(
73
72
 
74
73
  return 0;
75
74
  }
76
-
77
- function isSourcePlugin(plugin: PluginDefinition): plugin is SourcePlugin {
78
- return typeof (plugin as SourcePlugin).run === "function";
79
- }
80
-
81
- function findSourcePlugin(
82
- plugins: PluginDefinition[] | undefined,
83
- ): SourcePlugin | undefined {
84
- return plugins?.find(isSourcePlugin);
85
- }
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import type { SourceLocation } from "../index.ts";
2
+
3
+ export type DiagnosticSeverity = "error" | "warning" | "info";
4
+
5
+ export interface Diagnostic {
6
+ code: string;
7
+ message: string;
8
+ severity: DiagnosticSeverity;
9
+ file?: string;
10
+ source?: SourceLocation;
11
+ selector?: string;
12
+ property?: string;
13
+ details?: Record<string, unknown>;
14
+ }
15
+
16
+ export function hasErrorDiagnostics(diagnostics: Diagnostic[]): boolean {
17
+ return diagnostics.some((diagnostic) => diagnostic.severity === "error");
18
+ }