@carlesandres/webreel 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.
- package/LICENSE +201 -0
- package/README.md +206 -0
- package/dist/commands/__tests__/record.test.d.ts +2 -0
- package/dist/commands/__tests__/record.test.d.ts.map +1 -0
- package/dist/commands/__tests__/record.test.js +89 -0
- package/dist/commands/__tests__/record.test.js.map +1 -0
- package/dist/commands/composite.d.ts +3 -0
- package/dist/commands/composite.d.ts.map +1 -0
- package/dist/commands/composite.js +43 -0
- package/dist/commands/composite.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +50 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +3 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +71 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/preview.d.ts +3 -0
- package/dist/commands/preview.d.ts.map +1 -0
- package/dist/commands/preview.js +30 -0
- package/dist/commands/preview.js.map +1 -0
- package/dist/commands/record.d.ts +5 -0
- package/dist/commands/record.d.ts.map +1 -0
- package/dist/commands/record.js +151 -0
- package/dist/commands/record.js.map +1 -0
- package/dist/commands/validate.d.ts +3 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +43 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/__tests__/config.test.d.ts +2 -0
- package/dist/lib/__tests__/config.test.d.ts.map +1 -0
- package/dist/lib/__tests__/config.test.js +600 -0
- package/dist/lib/__tests__/config.test.js.map +1 -0
- package/dist/lib/__tests__/examples.test.d.ts +2 -0
- package/dist/lib/__tests__/examples.test.d.ts.map +1 -0
- package/dist/lib/__tests__/examples.test.js +19 -0
- package/dist/lib/__tests__/examples.test.js.map +1 -0
- package/dist/lib/__tests__/runner.test.d.ts +2 -0
- package/dist/lib/__tests__/runner.test.d.ts.map +1 -0
- package/dist/lib/__tests__/runner.test.js +141 -0
- package/dist/lib/__tests__/runner.test.js.map +1 -0
- package/dist/lib/config.d.ts +17 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +1002 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/runner.d.ts +18 -0
- package/dist/lib/runner.d.ts.map +1 -0
- package/dist/lib/runner.js +401 -0
- package/dist/lib/runner.js.map +1 -0
- package/dist/lib/types.d.ts +179 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +16 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname, isAbsolute, extname } from "node:path";
|
|
3
|
+
import { parse as parseJsonc, parseTree, getNodePath } from "jsonc-parser";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
import { VIEWPORT_PRESETS } from "./types.js";
|
|
6
|
+
export const DEFAULT_CONFIG_NAME = "webreel.config";
|
|
7
|
+
export const DEFAULT_CONFIG_FILE = "webreel.config.json";
|
|
8
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
9
|
+
const CONFIG_EXTENSIONS = [".json", ".ts", ".mts", ".js", ".mjs"];
|
|
10
|
+
const JSON_EXTENSIONS = new Set([".json"]);
|
|
11
|
+
export function parseSchemaVersion(schema) {
|
|
12
|
+
if (!schema)
|
|
13
|
+
return CURRENT_SCHEMA_VERSION;
|
|
14
|
+
const match = schema.match(/\/schema\/v(\d+)\.json/);
|
|
15
|
+
if (!match)
|
|
16
|
+
return -1;
|
|
17
|
+
return parseInt(match[1], 10);
|
|
18
|
+
}
|
|
19
|
+
async function resolveIncludes(config, configDir, seen) {
|
|
20
|
+
const includes = config.include;
|
|
21
|
+
if (!Array.isArray(includes) || includes.length === 0)
|
|
22
|
+
return [];
|
|
23
|
+
const prependedSteps = [];
|
|
24
|
+
for (const inc of includes) {
|
|
25
|
+
if (typeof inc !== "string")
|
|
26
|
+
continue;
|
|
27
|
+
const absPath = resolve(configDir, inc);
|
|
28
|
+
if (seen.has(absPath)) {
|
|
29
|
+
throw new Error(`Circular include detected: ${absPath}`);
|
|
30
|
+
}
|
|
31
|
+
seen.add(absPath);
|
|
32
|
+
const ext = extname(absPath);
|
|
33
|
+
let parsed;
|
|
34
|
+
if (JSON_EXTENSIONS.has(ext)) {
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = readFileSync(absPath, "utf-8");
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
throw new Error(`Include file not found: ${absPath}`, { cause: err });
|
|
41
|
+
}
|
|
42
|
+
parsed = parseJsonc(raw);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
try {
|
|
46
|
+
const mod = await loadTsConfig(absPath);
|
|
47
|
+
if (typeof mod !== "object" || mod === null) {
|
|
48
|
+
throw new Error(`Include file must export an object: ${absPath}`);
|
|
49
|
+
}
|
|
50
|
+
parsed = mod;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof Error && err.message.includes("must export"))
|
|
54
|
+
throw err;
|
|
55
|
+
throw new Error(`Include file not found or failed to load: ${absPath}`, {
|
|
56
|
+
cause: err,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(parsed.steps)) {
|
|
61
|
+
throw new Error(`Include file ${absPath} must export a "steps" array`);
|
|
62
|
+
}
|
|
63
|
+
const nestedSteps = await resolveIncludes(parsed, dirname(absPath), seen);
|
|
64
|
+
prependedSteps.push(...nestedSteps, ...parsed.steps);
|
|
65
|
+
}
|
|
66
|
+
return prependedSteps;
|
|
67
|
+
}
|
|
68
|
+
function resolveSfxPaths(sfx, configDir) {
|
|
69
|
+
if (!sfx)
|
|
70
|
+
return sfx;
|
|
71
|
+
const resolved = { ...sfx };
|
|
72
|
+
if (typeof resolved.click === "string" && !isAbsolute(resolved.click)) {
|
|
73
|
+
resolved.click = resolve(configDir, resolved.click);
|
|
74
|
+
}
|
|
75
|
+
if (typeof resolved.key === "string" && !isAbsolute(resolved.key)) {
|
|
76
|
+
resolved.key = resolve(configDir, resolved.key);
|
|
77
|
+
}
|
|
78
|
+
return resolved;
|
|
79
|
+
}
|
|
80
|
+
function resolveVideoDefaults(video, defaults, outDir, configDir) {
|
|
81
|
+
const resolved = { ...video };
|
|
82
|
+
if (!resolved.baseUrl && defaults.baseUrl)
|
|
83
|
+
resolved.baseUrl = defaults.baseUrl;
|
|
84
|
+
if (!resolved.capture && defaults.capture)
|
|
85
|
+
resolved.capture = defaults.capture;
|
|
86
|
+
if (!resolved.viewport && defaults.viewport)
|
|
87
|
+
resolved.viewport = defaults.viewport;
|
|
88
|
+
if (defaults.theme) {
|
|
89
|
+
resolved.theme = {
|
|
90
|
+
cursor: { ...defaults.theme.cursor, ...resolved.theme?.cursor },
|
|
91
|
+
hud: { ...defaults.theme.hud, ...resolved.theme?.hud },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!resolved.include && defaults.include)
|
|
95
|
+
resolved.include = defaults.include;
|
|
96
|
+
if (!resolved.sfx && defaults.sfx)
|
|
97
|
+
resolved.sfx = defaults.sfx;
|
|
98
|
+
resolved.sfx = resolveSfxPaths(resolved.sfx, configDir);
|
|
99
|
+
if (resolved.defaultDelay === undefined && defaults.defaultDelay !== undefined)
|
|
100
|
+
resolved.defaultDelay = defaults.defaultDelay;
|
|
101
|
+
if (resolved.clickDwell === undefined && defaults.clickDwell !== undefined)
|
|
102
|
+
resolved.clickDwell = defaults.clickDwell;
|
|
103
|
+
if (resolved.output && !isAbsolute(resolved.output) && outDir) {
|
|
104
|
+
resolved.output = resolve(outDir, resolved.output);
|
|
105
|
+
}
|
|
106
|
+
else if (!resolved.output && outDir) {
|
|
107
|
+
resolved.output = resolve(outDir, `${resolved.name}.mp4`);
|
|
108
|
+
}
|
|
109
|
+
return resolved;
|
|
110
|
+
}
|
|
111
|
+
async function loadTsConfig(filePath) {
|
|
112
|
+
const jiti = createJiti(filePath, { interopDefault: true });
|
|
113
|
+
const mod = await jiti.import(filePath);
|
|
114
|
+
return mod;
|
|
115
|
+
}
|
|
116
|
+
function resolveViewportValue(raw) {
|
|
117
|
+
if (typeof raw === "string")
|
|
118
|
+
return resolveViewportPreset(raw) ?? undefined;
|
|
119
|
+
if (typeof raw === "object" && raw !== null)
|
|
120
|
+
return raw;
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function substituteEnvVars(obj) {
|
|
124
|
+
if (typeof obj === "string") {
|
|
125
|
+
return obj.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (_match, braced, bare) => {
|
|
126
|
+
const name = braced ?? bare;
|
|
127
|
+
return process.env[name] ?? _match;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(obj))
|
|
131
|
+
return obj.map(substituteEnvVars);
|
|
132
|
+
if (typeof obj === "object" && obj !== null) {
|
|
133
|
+
const result = {};
|
|
134
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
135
|
+
result[key] = substituteEnvVars(value);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
return obj;
|
|
140
|
+
}
|
|
141
|
+
async function buildConfigFromParsed(parsed, filePath) {
|
|
142
|
+
if (!parsed.videos ||
|
|
143
|
+
typeof parsed.videos !== "object" ||
|
|
144
|
+
Array.isArray(parsed.videos)) {
|
|
145
|
+
throw new Error(`Config must contain a "videos" object`);
|
|
146
|
+
}
|
|
147
|
+
const videosObj = parsed.videos;
|
|
148
|
+
const configDir = dirname(resolve(filePath));
|
|
149
|
+
const outDir = resolve(configDir, parsed.outDir ?? "videos");
|
|
150
|
+
const defaults = {
|
|
151
|
+
baseUrl: parsed.baseUrl,
|
|
152
|
+
capture: parsed.capture,
|
|
153
|
+
viewport: resolveViewportValue(parsed.viewport),
|
|
154
|
+
theme: parsed.theme,
|
|
155
|
+
sfx: parsed.sfx,
|
|
156
|
+
include: parsed.include,
|
|
157
|
+
defaultDelay: parsed.defaultDelay,
|
|
158
|
+
clickDwell: parsed.clickDwell,
|
|
159
|
+
};
|
|
160
|
+
const videoList = [];
|
|
161
|
+
for (const [name, body] of Object.entries(videosObj)) {
|
|
162
|
+
const videoBody = { ...body };
|
|
163
|
+
if (typeof videoBody.viewport === "string") {
|
|
164
|
+
videoBody.viewport =
|
|
165
|
+
resolveViewportPreset(videoBody.viewport) ?? videoBody.viewport;
|
|
166
|
+
}
|
|
167
|
+
const video = { ...videoBody, name };
|
|
168
|
+
const resolved = resolveVideoDefaults(video, defaults, outDir, configDir);
|
|
169
|
+
videoList.push(await resolveVideo(resolved, filePath));
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
$schema: parsed.$schema,
|
|
173
|
+
outDir: parsed.outDir,
|
|
174
|
+
baseUrl: parsed.baseUrl,
|
|
175
|
+
capture: parsed.capture,
|
|
176
|
+
viewport: resolveViewportValue(parsed.viewport),
|
|
177
|
+
theme: parsed.theme,
|
|
178
|
+
sfx: parsed.sfx,
|
|
179
|
+
include: parsed.include,
|
|
180
|
+
defaultDelay: parsed.defaultDelay,
|
|
181
|
+
clickDwell: parsed.clickDwell,
|
|
182
|
+
videos: videoList,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
export async function loadWebreelConfig(filePath) {
|
|
186
|
+
const ext = extname(filePath);
|
|
187
|
+
if (JSON_EXTENSIONS.has(ext)) {
|
|
188
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
189
|
+
const parsed = substituteEnvVars(parseJsonc(raw));
|
|
190
|
+
const schemaUrl = typeof parsed === "object" && parsed !== null
|
|
191
|
+
? parsed.$schema
|
|
192
|
+
: undefined;
|
|
193
|
+
const version = parseSchemaVersion(typeof schemaUrl === "string" ? schemaUrl : undefined);
|
|
194
|
+
const errors = validateWebreelConfig(parsed, version);
|
|
195
|
+
if (errors.length > 0) {
|
|
196
|
+
const lineMap = buildLineMap(raw);
|
|
197
|
+
throw new Error(formatValidationErrors(filePath, errors, lineMap));
|
|
198
|
+
}
|
|
199
|
+
return buildConfigFromParsed(parsed, filePath);
|
|
200
|
+
}
|
|
201
|
+
const raw = await loadTsConfig(filePath);
|
|
202
|
+
if (typeof raw !== "object" || raw === null) {
|
|
203
|
+
throw new Error(`Config file must export an object: ${filePath}`);
|
|
204
|
+
}
|
|
205
|
+
const rawConfig = substituteEnvVars(raw);
|
|
206
|
+
const errors = validateWebreelConfig(rawConfig);
|
|
207
|
+
if (errors.length > 0) {
|
|
208
|
+
throw new Error(formatValidationErrors(filePath, errors));
|
|
209
|
+
}
|
|
210
|
+
return buildConfigFromParsed(rawConfig, filePath);
|
|
211
|
+
}
|
|
212
|
+
async function resolveVideo(video, filePath) {
|
|
213
|
+
if (video.include && video.include.length > 0) {
|
|
214
|
+
const absConfigPath = resolve(filePath);
|
|
215
|
+
const seen = new Set([absConfigPath]);
|
|
216
|
+
const includedSteps = await resolveIncludes(video, dirname(absConfigPath), seen);
|
|
217
|
+
const includeErrors = [];
|
|
218
|
+
for (let i = 0; i < includedSteps.length; i++) {
|
|
219
|
+
includeErrors.push(...validateStep(includedSteps[i], i).map((e) => ({
|
|
220
|
+
...e,
|
|
221
|
+
path: `include:${e.path}`,
|
|
222
|
+
})));
|
|
223
|
+
}
|
|
224
|
+
if (includeErrors.length > 0) {
|
|
225
|
+
const msgs = includeErrors.map((e) => e.path ? `${e.path}: ${e.message}` : e.message);
|
|
226
|
+
throw new Error(`Invalid included steps for video "${video.name}":\n ${msgs.join("\n ")}`);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
...video,
|
|
230
|
+
steps: [...includedSteps, ...video.steps],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return video;
|
|
234
|
+
}
|
|
235
|
+
const VALID_ACTIONS = new Set([
|
|
236
|
+
"pause",
|
|
237
|
+
"click",
|
|
238
|
+
"key",
|
|
239
|
+
"drag",
|
|
240
|
+
"moveTo",
|
|
241
|
+
"type",
|
|
242
|
+
"scroll",
|
|
243
|
+
"wait",
|
|
244
|
+
"screenshot",
|
|
245
|
+
"navigate",
|
|
246
|
+
"hover",
|
|
247
|
+
"select",
|
|
248
|
+
]);
|
|
249
|
+
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
250
|
+
"$schema",
|
|
251
|
+
"outDir",
|
|
252
|
+
"baseUrl",
|
|
253
|
+
"capture",
|
|
254
|
+
"viewport",
|
|
255
|
+
"theme",
|
|
256
|
+
"sfx",
|
|
257
|
+
"include",
|
|
258
|
+
"defaultDelay",
|
|
259
|
+
"clickDwell",
|
|
260
|
+
"videos",
|
|
261
|
+
]);
|
|
262
|
+
const KNOWN_VIDEO_KEYS = new Set([
|
|
263
|
+
"url",
|
|
264
|
+
"baseUrl",
|
|
265
|
+
"capture",
|
|
266
|
+
"viewport",
|
|
267
|
+
"zoom",
|
|
268
|
+
"fps",
|
|
269
|
+
"quality",
|
|
270
|
+
"waitFor",
|
|
271
|
+
"output",
|
|
272
|
+
"thumbnail",
|
|
273
|
+
"include",
|
|
274
|
+
"theme",
|
|
275
|
+
"sfx",
|
|
276
|
+
"defaultDelay",
|
|
277
|
+
"clickDwell",
|
|
278
|
+
"steps",
|
|
279
|
+
]);
|
|
280
|
+
const KNOWN_STEP_KEYS = {
|
|
281
|
+
pause: new Set(["action", "ms", "label", "description"]),
|
|
282
|
+
click: new Set([
|
|
283
|
+
"action",
|
|
284
|
+
"text",
|
|
285
|
+
"selector",
|
|
286
|
+
"within",
|
|
287
|
+
"modifiers",
|
|
288
|
+
"label",
|
|
289
|
+
"delay",
|
|
290
|
+
"description",
|
|
291
|
+
]),
|
|
292
|
+
key: new Set(["action", "key", "target", "label", "delay", "description"]),
|
|
293
|
+
drag: new Set(["action", "from", "to", "label", "delay", "description"]),
|
|
294
|
+
moveTo: new Set([
|
|
295
|
+
"action",
|
|
296
|
+
"text",
|
|
297
|
+
"selector",
|
|
298
|
+
"within",
|
|
299
|
+
"label",
|
|
300
|
+
"delay",
|
|
301
|
+
"description",
|
|
302
|
+
]),
|
|
303
|
+
type: new Set([
|
|
304
|
+
"action",
|
|
305
|
+
"text",
|
|
306
|
+
"selector",
|
|
307
|
+
"within",
|
|
308
|
+
"charDelay",
|
|
309
|
+
"label",
|
|
310
|
+
"delay",
|
|
311
|
+
"description",
|
|
312
|
+
]),
|
|
313
|
+
scroll: new Set([
|
|
314
|
+
"action",
|
|
315
|
+
"x",
|
|
316
|
+
"y",
|
|
317
|
+
"text",
|
|
318
|
+
"selector",
|
|
319
|
+
"within",
|
|
320
|
+
"label",
|
|
321
|
+
"delay",
|
|
322
|
+
"description",
|
|
323
|
+
]),
|
|
324
|
+
wait: new Set([
|
|
325
|
+
"action",
|
|
326
|
+
"selector",
|
|
327
|
+
"text",
|
|
328
|
+
"within",
|
|
329
|
+
"timeout",
|
|
330
|
+
"label",
|
|
331
|
+
"delay",
|
|
332
|
+
"description",
|
|
333
|
+
]),
|
|
334
|
+
screenshot: new Set(["action", "output", "label", "delay", "description"]),
|
|
335
|
+
navigate: new Set(["action", "url", "label", "delay", "description"]),
|
|
336
|
+
hover: new Set([
|
|
337
|
+
"action",
|
|
338
|
+
"text",
|
|
339
|
+
"selector",
|
|
340
|
+
"within",
|
|
341
|
+
"label",
|
|
342
|
+
"delay",
|
|
343
|
+
"description",
|
|
344
|
+
]),
|
|
345
|
+
select: new Set([
|
|
346
|
+
"action",
|
|
347
|
+
"text",
|
|
348
|
+
"selector",
|
|
349
|
+
"within",
|
|
350
|
+
"value",
|
|
351
|
+
"label",
|
|
352
|
+
"delay",
|
|
353
|
+
"description",
|
|
354
|
+
]),
|
|
355
|
+
};
|
|
356
|
+
function levenshtein(a, b) {
|
|
357
|
+
const m = a.length;
|
|
358
|
+
const n = b.length;
|
|
359
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
360
|
+
for (let i = 0; i <= m; i++)
|
|
361
|
+
dp[i][0] = i;
|
|
362
|
+
for (let j = 0; j <= n; j++)
|
|
363
|
+
dp[0][j] = j;
|
|
364
|
+
for (let i = 1; i <= m; i++) {
|
|
365
|
+
for (let j = 1; j <= n; j++) {
|
|
366
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return dp[m][n];
|
|
370
|
+
}
|
|
371
|
+
function suggestKey(unknown, known) {
|
|
372
|
+
let best = null;
|
|
373
|
+
let bestDist = Infinity;
|
|
374
|
+
for (const k of known) {
|
|
375
|
+
const d = levenshtein(unknown.toLowerCase(), k.toLowerCase());
|
|
376
|
+
if (d < bestDist && d <= 2) {
|
|
377
|
+
bestDist = d;
|
|
378
|
+
best = k;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return best;
|
|
382
|
+
}
|
|
383
|
+
function checkUnknownKeys(obj, known, prefix) {
|
|
384
|
+
const errors = [];
|
|
385
|
+
for (const key of Object.keys(obj)) {
|
|
386
|
+
if (!known.has(key)) {
|
|
387
|
+
const suggestion = suggestKey(key, known);
|
|
388
|
+
const hint = suggestion ? ` (did you mean "${suggestion}"?)` : "";
|
|
389
|
+
errors.push({
|
|
390
|
+
path: `${prefix}.${key}`,
|
|
391
|
+
message: `Unknown property${hint}`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return errors;
|
|
396
|
+
}
|
|
397
|
+
function validateStep(step, index) {
|
|
398
|
+
const errors = [];
|
|
399
|
+
const prefix = `steps[${index}]`;
|
|
400
|
+
if (typeof step !== "object" || step === null) {
|
|
401
|
+
errors.push({ path: prefix, message: "Step must be an object" });
|
|
402
|
+
return errors;
|
|
403
|
+
}
|
|
404
|
+
const s = step;
|
|
405
|
+
if (!s.action || typeof s.action !== "string") {
|
|
406
|
+
errors.push({ path: `${prefix}.action`, message: "Missing or invalid action" });
|
|
407
|
+
return errors;
|
|
408
|
+
}
|
|
409
|
+
if (!VALID_ACTIONS.has(s.action)) {
|
|
410
|
+
errors.push({
|
|
411
|
+
path: `${prefix}.action`,
|
|
412
|
+
message: `Unknown action "${s.action}". Valid actions: ${[...VALID_ACTIONS].join(", ")}`,
|
|
413
|
+
});
|
|
414
|
+
return errors;
|
|
415
|
+
}
|
|
416
|
+
const knownKeys = KNOWN_STEP_KEYS[s.action];
|
|
417
|
+
if (knownKeys) {
|
|
418
|
+
errors.push(...checkUnknownKeys(s, knownKeys, prefix));
|
|
419
|
+
}
|
|
420
|
+
switch (s.action) {
|
|
421
|
+
case "pause":
|
|
422
|
+
if (!Number.isFinite(s.ms) || s.ms < 0) {
|
|
423
|
+
errors.push({ path: `${prefix}.ms`, message: "Must be a non-negative number" });
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
case "click":
|
|
427
|
+
if (!s.text && !s.selector) {
|
|
428
|
+
errors.push({
|
|
429
|
+
path: prefix,
|
|
430
|
+
message: 'Click requires "text" or "selector"',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
case "key":
|
|
435
|
+
if (typeof s.key !== "string" || s.key.length === 0) {
|
|
436
|
+
errors.push({ path: `${prefix}.key`, message: "Must be a non-empty string" });
|
|
437
|
+
}
|
|
438
|
+
if (s.target !== undefined &&
|
|
439
|
+
typeof s.target !== "string" &&
|
|
440
|
+
(typeof s.target !== "object" || s.target === null)) {
|
|
441
|
+
errors.push({
|
|
442
|
+
path: `${prefix}.target`,
|
|
443
|
+
message: "Must be a CSS selector string or an element target object",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
case "drag": {
|
|
448
|
+
if (!s.from || typeof s.from !== "object") {
|
|
449
|
+
errors.push({
|
|
450
|
+
path: `${prefix}.from`,
|
|
451
|
+
message: "Must be an object with text or selector",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
const f = s.from;
|
|
456
|
+
if (!f.text && !f.selector) {
|
|
457
|
+
errors.push({
|
|
458
|
+
path: `${prefix}.from`,
|
|
459
|
+
message: 'Requires "text" or "selector"',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!s.to || typeof s.to !== "object") {
|
|
464
|
+
errors.push({
|
|
465
|
+
path: `${prefix}.to`,
|
|
466
|
+
message: "Must be an object with text or selector",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
const t = s.to;
|
|
471
|
+
if (!t.text && !t.selector) {
|
|
472
|
+
errors.push({ path: `${prefix}.to`, message: 'Requires "text" or "selector"' });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case "type":
|
|
478
|
+
if (typeof s.text !== "string" || s.text.length === 0) {
|
|
479
|
+
errors.push({ path: `${prefix}.text`, message: "Must be a non-empty string" });
|
|
480
|
+
}
|
|
481
|
+
if (s.charDelay !== undefined &&
|
|
482
|
+
(!Number.isFinite(s.charDelay) || s.charDelay < 0)) {
|
|
483
|
+
errors.push({
|
|
484
|
+
path: `${prefix}.charDelay`,
|
|
485
|
+
message: "Must be a non-negative number",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
break;
|
|
489
|
+
case "scroll":
|
|
490
|
+
if (s.x !== undefined && !Number.isFinite(s.x)) {
|
|
491
|
+
errors.push({ path: `${prefix}.x`, message: "Must be a finite number" });
|
|
492
|
+
}
|
|
493
|
+
if (s.y !== undefined && !Number.isFinite(s.y)) {
|
|
494
|
+
errors.push({ path: `${prefix}.y`, message: "Must be a finite number" });
|
|
495
|
+
}
|
|
496
|
+
break;
|
|
497
|
+
case "wait": {
|
|
498
|
+
if (!s.selector && !s.text) {
|
|
499
|
+
errors.push({
|
|
500
|
+
path: prefix,
|
|
501
|
+
message: 'wait requires "selector" or "text"',
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (s.timeout !== undefined &&
|
|
505
|
+
(!Number.isFinite(s.timeout) || s.timeout <= 0)) {
|
|
506
|
+
errors.push({ path: `${prefix}.timeout`, message: "Must be a positive number" });
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case "moveTo":
|
|
511
|
+
if (!s.text && !s.selector) {
|
|
512
|
+
errors.push({
|
|
513
|
+
path: prefix,
|
|
514
|
+
message: 'moveTo requires "text" or "selector"',
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
case "screenshot":
|
|
519
|
+
if (typeof s.output !== "string" || s.output.length === 0) {
|
|
520
|
+
errors.push({
|
|
521
|
+
path: `${prefix}.output`,
|
|
522
|
+
message: "Must be a non-empty string",
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
break;
|
|
526
|
+
case "navigate":
|
|
527
|
+
if (typeof s.url !== "string" || s.url.length === 0) {
|
|
528
|
+
errors.push({ path: `${prefix}.url`, message: "Must be a non-empty string" });
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
case "hover":
|
|
532
|
+
if (!s.text && !s.selector) {
|
|
533
|
+
errors.push({
|
|
534
|
+
path: prefix,
|
|
535
|
+
message: 'hover requires "text" or "selector"',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
break;
|
|
539
|
+
case "select": {
|
|
540
|
+
if (!s.selector && !s.text) {
|
|
541
|
+
errors.push({
|
|
542
|
+
path: prefix,
|
|
543
|
+
message: 'select requires "text" or "selector"',
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (typeof s.value !== "string") {
|
|
547
|
+
errors.push({ path: `${prefix}.value`, message: "Must be a string" });
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (s.delay !== undefined && (!Number.isFinite(s.delay) || s.delay < 0)) {
|
|
553
|
+
errors.push({ path: `${prefix}.delay`, message: "Must be a non-negative number" });
|
|
554
|
+
}
|
|
555
|
+
if (s.label !== undefined && typeof s.label !== "string") {
|
|
556
|
+
errors.push({ path: `${prefix}.label`, message: "Must be a string" });
|
|
557
|
+
}
|
|
558
|
+
if (s.description !== undefined && typeof s.description !== "string") {
|
|
559
|
+
errors.push({ path: `${prefix}.description`, message: "Must be a string" });
|
|
560
|
+
}
|
|
561
|
+
return errors;
|
|
562
|
+
}
|
|
563
|
+
function resolveViewportPreset(value) {
|
|
564
|
+
return VIEWPORT_PRESETS[value] ?? null;
|
|
565
|
+
}
|
|
566
|
+
function validateViewport(viewport, prefix) {
|
|
567
|
+
const errors = [];
|
|
568
|
+
if (typeof viewport === "string") {
|
|
569
|
+
if (!resolveViewportPreset(viewport)) {
|
|
570
|
+
const presetNames = Object.keys(VIEWPORT_PRESETS).join(", ");
|
|
571
|
+
errors.push({
|
|
572
|
+
path: prefix,
|
|
573
|
+
message: `Unknown viewport preset "${viewport}". Valid presets: ${presetNames}`,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
else if (typeof viewport !== "object" || viewport === null) {
|
|
578
|
+
errors.push({
|
|
579
|
+
path: prefix,
|
|
580
|
+
message: "Must be a preset string or an object with width and height",
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
const v = viewport;
|
|
585
|
+
if (!Number.isFinite(v.width) || v.width <= 0) {
|
|
586
|
+
errors.push({ path: `${prefix}.width`, message: "Must be a positive number" });
|
|
587
|
+
}
|
|
588
|
+
if (!Number.isFinite(v.height) || v.height <= 0) {
|
|
589
|
+
errors.push({ path: `${prefix}.height`, message: "Must be a positive number" });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return errors;
|
|
593
|
+
}
|
|
594
|
+
function validateInclude(include, prefix) {
|
|
595
|
+
const errors = [];
|
|
596
|
+
if (!Array.isArray(include)) {
|
|
597
|
+
errors.push({ path: prefix, message: "Must be an array of file paths" });
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
for (let i = 0; i < include.length; i++) {
|
|
601
|
+
if (typeof include[i] !== "string" || include[i].length === 0) {
|
|
602
|
+
errors.push({ path: `${prefix}[${i}]`, message: "Must be a non-empty string" });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return errors;
|
|
607
|
+
}
|
|
608
|
+
function validateCapture(capture, prefix) {
|
|
609
|
+
const errors = [];
|
|
610
|
+
if (typeof capture !== "object" || capture === null) {
|
|
611
|
+
errors.push({ path: prefix, message: "Must be an object" });
|
|
612
|
+
return errors;
|
|
613
|
+
}
|
|
614
|
+
const c = capture;
|
|
615
|
+
errors.push(...checkUnknownKeys(c, new Set(["format"]), prefix));
|
|
616
|
+
if (c.format !== undefined && c.format !== "jpeg" && c.format !== "png") {
|
|
617
|
+
errors.push({
|
|
618
|
+
path: `${prefix}.format`,
|
|
619
|
+
message: 'Must be "jpeg" or "png"',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return errors;
|
|
623
|
+
}
|
|
624
|
+
const VALID_SFX_VARIANTS = new Set([1, 2, 3, 4]);
|
|
625
|
+
function isValidSfxValue(value) {
|
|
626
|
+
return VALID_SFX_VARIANTS.has(value) || typeof value === "string";
|
|
627
|
+
}
|
|
628
|
+
function validateSfx(sfx, prefix) {
|
|
629
|
+
const errors = [];
|
|
630
|
+
if (typeof sfx !== "object" || sfx === null) {
|
|
631
|
+
errors.push({ path: prefix, message: "Must be an object" });
|
|
632
|
+
return errors;
|
|
633
|
+
}
|
|
634
|
+
const s = sfx;
|
|
635
|
+
if (s.click !== undefined && !isValidSfxValue(s.click)) {
|
|
636
|
+
errors.push({
|
|
637
|
+
path: `${prefix}.click`,
|
|
638
|
+
message: "Must be 1, 2, 3, 4, or a file path",
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (s.key !== undefined && !isValidSfxValue(s.key)) {
|
|
642
|
+
errors.push({ path: `${prefix}.key`, message: "Must be 1, 2, 3, 4, or a file path" });
|
|
643
|
+
}
|
|
644
|
+
return errors;
|
|
645
|
+
}
|
|
646
|
+
function validateTheme(theme, prefix) {
|
|
647
|
+
const errors = [];
|
|
648
|
+
if (typeof theme !== "object" || theme === null) {
|
|
649
|
+
errors.push({ path: prefix, message: "Must be an object" });
|
|
650
|
+
return errors;
|
|
651
|
+
}
|
|
652
|
+
const t = theme;
|
|
653
|
+
if (t.cursor !== undefined) {
|
|
654
|
+
if (typeof t.cursor !== "object" || t.cursor === null) {
|
|
655
|
+
errors.push({ path: `${prefix}.cursor`, message: "Must be an object" });
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
const cur = t.cursor;
|
|
659
|
+
if (cur.image !== undefined && typeof cur.image !== "string") {
|
|
660
|
+
errors.push({
|
|
661
|
+
path: `${prefix}.cursor.image`,
|
|
662
|
+
message: "Must be a string (file path)",
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (cur.size !== undefined &&
|
|
666
|
+
(!Number.isFinite(cur.size) || cur.size <= 0)) {
|
|
667
|
+
errors.push({
|
|
668
|
+
path: `${prefix}.cursor.size`,
|
|
669
|
+
message: "Must be a positive number",
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (cur.hotspot !== undefined &&
|
|
673
|
+
cur.hotspot !== "top-left" &&
|
|
674
|
+
cur.hotspot !== "center") {
|
|
675
|
+
errors.push({
|
|
676
|
+
path: `${prefix}.cursor.hotspot`,
|
|
677
|
+
message: 'Must be "top-left" or "center"',
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (t.hud !== undefined) {
|
|
683
|
+
if (typeof t.hud !== "object" || t.hud === null) {
|
|
684
|
+
errors.push({ path: `${prefix}.hud`, message: "Must be an object" });
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
const h = t.hud;
|
|
688
|
+
if (h.background !== undefined && typeof h.background !== "string") {
|
|
689
|
+
errors.push({ path: `${prefix}.hud.background`, message: "Must be a string" });
|
|
690
|
+
}
|
|
691
|
+
if (h.color !== undefined && typeof h.color !== "string") {
|
|
692
|
+
errors.push({ path: `${prefix}.hud.color`, message: "Must be a string" });
|
|
693
|
+
}
|
|
694
|
+
if (h.fontSize !== undefined &&
|
|
695
|
+
(!Number.isFinite(h.fontSize) || h.fontSize <= 0)) {
|
|
696
|
+
errors.push({
|
|
697
|
+
path: `${prefix}.hud.fontSize`,
|
|
698
|
+
message: "Must be a positive number",
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
if (h.fontFamily !== undefined && typeof h.fontFamily !== "string") {
|
|
702
|
+
errors.push({ path: `${prefix}.hud.fontFamily`, message: "Must be a string" });
|
|
703
|
+
}
|
|
704
|
+
if (h.borderRadius !== undefined &&
|
|
705
|
+
(!Number.isFinite(h.borderRadius) || h.borderRadius < 0)) {
|
|
706
|
+
errors.push({
|
|
707
|
+
path: `${prefix}.hud.borderRadius`,
|
|
708
|
+
message: "Must be a non-negative number",
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
if (h.position !== undefined && h.position !== "top" && h.position !== "bottom") {
|
|
712
|
+
errors.push({
|
|
713
|
+
path: `${prefix}.hud.position`,
|
|
714
|
+
message: 'Must be "top" or "bottom"',
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return errors;
|
|
720
|
+
}
|
|
721
|
+
export function validateWebreelConfig(config, version = CURRENT_SCHEMA_VERSION) {
|
|
722
|
+
if (version !== 1) {
|
|
723
|
+
return [
|
|
724
|
+
{
|
|
725
|
+
path: "$schema",
|
|
726
|
+
message: `Unsupported schema version: v${version}. This version of webreel supports v1.`,
|
|
727
|
+
},
|
|
728
|
+
];
|
|
729
|
+
}
|
|
730
|
+
const errors = [];
|
|
731
|
+
if (typeof config !== "object" || config === null) {
|
|
732
|
+
errors.push({ path: "", message: "Config must be an object" });
|
|
733
|
+
return errors;
|
|
734
|
+
}
|
|
735
|
+
const c = config;
|
|
736
|
+
errors.push(...checkUnknownKeys(c, KNOWN_TOP_LEVEL_KEYS, ""));
|
|
737
|
+
if (c.outDir !== undefined && (typeof c.outDir !== "string" || c.outDir.length === 0)) {
|
|
738
|
+
errors.push({ path: "outDir", message: "Must be a non-empty string" });
|
|
739
|
+
}
|
|
740
|
+
if (c.baseUrl !== undefined && typeof c.baseUrl !== "string") {
|
|
741
|
+
errors.push({ path: "baseUrl", message: "Must be a string" });
|
|
742
|
+
}
|
|
743
|
+
if (c.capture !== undefined) {
|
|
744
|
+
errors.push(...validateCapture(c.capture, "capture"));
|
|
745
|
+
}
|
|
746
|
+
if (c.viewport !== undefined) {
|
|
747
|
+
errors.push(...validateViewport(c.viewport, "viewport"));
|
|
748
|
+
}
|
|
749
|
+
if (c.defaultDelay !== undefined &&
|
|
750
|
+
(!Number.isFinite(c.defaultDelay) || c.defaultDelay < 0)) {
|
|
751
|
+
errors.push({ path: "defaultDelay", message: "Must be a non-negative number" });
|
|
752
|
+
}
|
|
753
|
+
if (c.clickDwell !== undefined &&
|
|
754
|
+
(!Number.isFinite(c.clickDwell) || c.clickDwell < 0)) {
|
|
755
|
+
errors.push({ path: "clickDwell", message: "Must be a non-negative number" });
|
|
756
|
+
}
|
|
757
|
+
if (c.include !== undefined) {
|
|
758
|
+
errors.push(...validateInclude(c.include, "include"));
|
|
759
|
+
}
|
|
760
|
+
if (c.theme !== undefined) {
|
|
761
|
+
errors.push(...validateTheme(c.theme, "theme"));
|
|
762
|
+
}
|
|
763
|
+
if (c.sfx !== undefined) {
|
|
764
|
+
errors.push(...validateSfx(c.sfx, "sfx"));
|
|
765
|
+
}
|
|
766
|
+
if (c.videos === undefined ||
|
|
767
|
+
c.videos === null ||
|
|
768
|
+
typeof c.videos !== "object" ||
|
|
769
|
+
Array.isArray(c.videos)) {
|
|
770
|
+
errors.push({
|
|
771
|
+
path: "videos",
|
|
772
|
+
message: "Required, must be an object mapping names to video configs",
|
|
773
|
+
});
|
|
774
|
+
return errors;
|
|
775
|
+
}
|
|
776
|
+
const videos = c.videos;
|
|
777
|
+
const names = Object.keys(videos);
|
|
778
|
+
if (names.length === 0) {
|
|
779
|
+
errors.push({ path: "videos", message: "Must contain at least one video" });
|
|
780
|
+
}
|
|
781
|
+
for (const name of names) {
|
|
782
|
+
const video = videos[name];
|
|
783
|
+
const prefix = `videos.${name}`;
|
|
784
|
+
if (typeof video !== "object" || video === null) {
|
|
785
|
+
errors.push({ path: prefix, message: "Must be a video config object" });
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const d = video;
|
|
789
|
+
errors.push(...checkUnknownKeys(d, KNOWN_VIDEO_KEYS, prefix));
|
|
790
|
+
if (typeof d.url !== "string" || d.url.length === 0) {
|
|
791
|
+
errors.push({
|
|
792
|
+
path: `${prefix}.url`,
|
|
793
|
+
message: "Required, must be a non-empty string",
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
if (d.zoom !== undefined && (!Number.isFinite(d.zoom) || d.zoom <= 0)) {
|
|
797
|
+
errors.push({ path: `${prefix}.zoom`, message: "Must be a positive number" });
|
|
798
|
+
}
|
|
799
|
+
if (d.capture !== undefined) {
|
|
800
|
+
errors.push(...validateCapture(d.capture, `${prefix}.capture`));
|
|
801
|
+
}
|
|
802
|
+
if (d.fps !== undefined &&
|
|
803
|
+
(!Number.isFinite(d.fps) || d.fps < 1 || d.fps > 120)) {
|
|
804
|
+
errors.push({
|
|
805
|
+
path: `${prefix}.fps`,
|
|
806
|
+
message: "Must be a number between 1 and 120",
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
if (d.quality !== undefined &&
|
|
810
|
+
(!Number.isFinite(d.quality) ||
|
|
811
|
+
d.quality < 1 ||
|
|
812
|
+
d.quality > 100)) {
|
|
813
|
+
errors.push({
|
|
814
|
+
path: `${prefix}.quality`,
|
|
815
|
+
message: "Must be a number between 1 and 100",
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
if (d.viewport !== undefined) {
|
|
819
|
+
errors.push(...validateViewport(d.viewport, `${prefix}.viewport`));
|
|
820
|
+
}
|
|
821
|
+
if (d.include !== undefined) {
|
|
822
|
+
errors.push(...validateInclude(d.include, `${prefix}.include`));
|
|
823
|
+
}
|
|
824
|
+
if (d.output !== undefined &&
|
|
825
|
+
(typeof d.output !== "string" || d.output.length === 0)) {
|
|
826
|
+
errors.push({ path: `${prefix}.output`, message: "Must be a non-empty string" });
|
|
827
|
+
}
|
|
828
|
+
if (d.waitFor !== undefined) {
|
|
829
|
+
if (typeof d.waitFor === "string") {
|
|
830
|
+
if (d.waitFor.length === 0) {
|
|
831
|
+
errors.push({
|
|
832
|
+
path: `${prefix}.waitFor`,
|
|
833
|
+
message: "Must be a non-empty string",
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
else if (typeof d.waitFor === "object" && d.waitFor !== null) {
|
|
838
|
+
const wf = d.waitFor;
|
|
839
|
+
if (!wf.selector && !wf.text) {
|
|
840
|
+
errors.push({
|
|
841
|
+
path: `${prefix}.waitFor`,
|
|
842
|
+
message: 'Must have "selector" or "text"',
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
errors.push({
|
|
848
|
+
path: `${prefix}.waitFor`,
|
|
849
|
+
message: "Must be a CSS selector string or an object with selector/text",
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (d.defaultDelay !== undefined &&
|
|
854
|
+
(!Number.isFinite(d.defaultDelay) || d.defaultDelay < 0)) {
|
|
855
|
+
errors.push({
|
|
856
|
+
path: `${prefix}.defaultDelay`,
|
|
857
|
+
message: "Must be a non-negative number",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (d.clickDwell !== undefined &&
|
|
861
|
+
(!Number.isFinite(d.clickDwell) || d.clickDwell < 0)) {
|
|
862
|
+
errors.push({
|
|
863
|
+
path: `${prefix}.clickDwell`,
|
|
864
|
+
message: "Must be a non-negative number",
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
if (d.thumbnail !== undefined) {
|
|
868
|
+
if (typeof d.thumbnail !== "object" || d.thumbnail === null) {
|
|
869
|
+
errors.push({ path: `${prefix}.thumbnail`, message: "Must be an object" });
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
const th = d.thumbnail;
|
|
873
|
+
if (th.time !== undefined &&
|
|
874
|
+
(!Number.isFinite(th.time) || th.time < 0)) {
|
|
875
|
+
errors.push({
|
|
876
|
+
path: `${prefix}.thumbnail.time`,
|
|
877
|
+
message: "Must be a non-negative number (seconds)",
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
if (th.enabled !== undefined && typeof th.enabled !== "boolean") {
|
|
881
|
+
errors.push({
|
|
882
|
+
path: `${prefix}.thumbnail.enabled`,
|
|
883
|
+
message: "Must be a boolean",
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (d.theme !== undefined) {
|
|
889
|
+
errors.push(...validateTheme(d.theme, `${prefix}.theme`));
|
|
890
|
+
}
|
|
891
|
+
if (d.sfx !== undefined) {
|
|
892
|
+
errors.push(...validateSfx(d.sfx, `${prefix}.sfx`));
|
|
893
|
+
}
|
|
894
|
+
if (!Array.isArray(d.steps)) {
|
|
895
|
+
errors.push({ path: `${prefix}.steps`, message: "Required, must be an array" });
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
for (let j = 0; j < d.steps.length; j++) {
|
|
899
|
+
errors.push(...validateStep(d.steps[j], j).map((e) => ({
|
|
900
|
+
...e,
|
|
901
|
+
path: `${prefix}.${e.path}`,
|
|
902
|
+
})));
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return errors;
|
|
907
|
+
}
|
|
908
|
+
export function buildLineMap(raw) {
|
|
909
|
+
const lineMap = new Map();
|
|
910
|
+
const tree = parseTree(raw);
|
|
911
|
+
if (!tree)
|
|
912
|
+
return lineMap;
|
|
913
|
+
function walk(node) {
|
|
914
|
+
if (!node)
|
|
915
|
+
return;
|
|
916
|
+
const path = getNodePath(node);
|
|
917
|
+
const jsonPath = path
|
|
918
|
+
.map((seg) => (typeof seg === "number" ? `[${seg}]` : seg))
|
|
919
|
+
.join(".")
|
|
920
|
+
.replace(/\.\[/g, "[");
|
|
921
|
+
const line = raw.substring(0, node.offset).split("\n").length;
|
|
922
|
+
if (jsonPath)
|
|
923
|
+
lineMap.set(jsonPath, line);
|
|
924
|
+
if (node.children) {
|
|
925
|
+
for (const child of node.children) {
|
|
926
|
+
walk(child);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (tree.children) {
|
|
931
|
+
for (const child of tree.children) {
|
|
932
|
+
walk(child);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return lineMap;
|
|
936
|
+
}
|
|
937
|
+
function findLineForPath(lineMap, errorPath) {
|
|
938
|
+
if (lineMap.has(errorPath))
|
|
939
|
+
return lineMap.get(errorPath);
|
|
940
|
+
const parts = errorPath.split(".");
|
|
941
|
+
while (parts.length > 0) {
|
|
942
|
+
parts.pop();
|
|
943
|
+
const parent = parts.join(".");
|
|
944
|
+
if (lineMap.has(parent))
|
|
945
|
+
return lineMap.get(parent);
|
|
946
|
+
}
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
export function formatValidationErrors(filePath, errors, lineMap) {
|
|
950
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
951
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
952
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
953
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
954
|
+
const maxPath = Math.max(...errors.map((e) => e.path.length));
|
|
955
|
+
const lines = errors.map((e) => {
|
|
956
|
+
const paddedPath = e.path.padEnd(maxPath);
|
|
957
|
+
const lineNum = lineMap ? findLineForPath(lineMap, e.path) : undefined;
|
|
958
|
+
const linePrefix = lineNum !== undefined ? yellow(`L${lineNum} `) : "";
|
|
959
|
+
return ` ${linePrefix}${red(paddedPath)} ${dim(e.message)}`;
|
|
960
|
+
});
|
|
961
|
+
return `${bold(red("Error:"))} Invalid config ${bold(filePath)}\n\n${lines.join("\n")}`;
|
|
962
|
+
}
|
|
963
|
+
export function getConfigDir(configPath) {
|
|
964
|
+
return dirname(resolve(configPath));
|
|
965
|
+
}
|
|
966
|
+
export function filterVideosByName(videos, names) {
|
|
967
|
+
if (names.length === 0)
|
|
968
|
+
return videos;
|
|
969
|
+
const filtered = videos.filter((v) => names.includes(v.name));
|
|
970
|
+
const found = new Set(filtered.map((v) => v.name));
|
|
971
|
+
const missing = names.filter((n) => !found.has(n));
|
|
972
|
+
if (missing.length > 0) {
|
|
973
|
+
const available = videos.map((v) => v.name).join(", ");
|
|
974
|
+
throw new Error(`Video(s) not found: ${missing.join(", ")}. Available: ${available}`);
|
|
975
|
+
}
|
|
976
|
+
return filtered;
|
|
977
|
+
}
|
|
978
|
+
export function resolveConfigPath(configPath) {
|
|
979
|
+
if (configPath) {
|
|
980
|
+
const resolved = resolve(configPath);
|
|
981
|
+
if (!existsSync(resolved)) {
|
|
982
|
+
throw new Error(`Config file not found: ${resolved}`);
|
|
983
|
+
}
|
|
984
|
+
return resolved;
|
|
985
|
+
}
|
|
986
|
+
let dir = process.cwd();
|
|
987
|
+
const root = resolve("/");
|
|
988
|
+
while (true) {
|
|
989
|
+
for (const ext of CONFIG_EXTENSIONS) {
|
|
990
|
+
const candidate = resolve(dir, `${DEFAULT_CONFIG_NAME}${ext}`);
|
|
991
|
+
if (existsSync(candidate)) {
|
|
992
|
+
return candidate;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const parent = dirname(dir);
|
|
996
|
+
if (parent === dir || dir === root)
|
|
997
|
+
break;
|
|
998
|
+
dir = parent;
|
|
999
|
+
}
|
|
1000
|
+
throw new Error(`No config file found. Create a ${DEFAULT_CONFIG_FILE} or specify one with --config.`);
|
|
1001
|
+
}
|
|
1002
|
+
//# sourceMappingURL=config.js.map
|