@guideshot/core 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1474 @@
1
+ // src/index.ts
2
+ import {
3
+ SCHEMA_VERSION as SCHEMA_VERSION2,
4
+ PublicManifestSchema as PublicManifestSchema2,
5
+ RecipeSchema as RecipeSchema2
6
+ } from "@guideshot/schema";
7
+
8
+ // src/canonical.ts
9
+ import { createHash } from "crypto";
10
+
11
+ // src/diagnostics.ts
12
+ var DIAGNOSTIC_CODES = [
13
+ "RECIPE_SCHEMA_INVALID",
14
+ "EXTENSION_NOT_REGISTERED",
15
+ "VARIABLE_UNRESOLVED",
16
+ "SERVER_NOT_READY",
17
+ "ORIGIN_NOT_ALLOWED",
18
+ "SCENARIO_FAILED",
19
+ "NAVIGATION_FAILED",
20
+ "TARGET_NOT_FOUND",
21
+ "TARGET_NOT_UNIQUE",
22
+ "TARGET_NOT_VISIBLE",
23
+ "EXPECTATION_FAILED",
24
+ "LAYOUT_UNSTABLE",
25
+ "ANNOTATION_LAYOUT_FAILED",
26
+ "PRIVACY_POLICY_FAILED",
27
+ "CAPTURE_FAILED",
28
+ "COMPOSITION_FAILED",
29
+ "OUTPUT_COLLISION",
30
+ "MANIFEST_INVALID",
31
+ "OUTPUT_STALE"
32
+ ];
33
+ var GuideShotError = class extends Error {
34
+ name = "GuideShotError";
35
+ code;
36
+ hint;
37
+ recipeId;
38
+ jobKey;
39
+ location;
40
+ details;
41
+ constructor(code, message, options = {}) {
42
+ super(
43
+ message,
44
+ options.cause === void 0 ? void 0 : { cause: options.cause }
45
+ );
46
+ this.code = code;
47
+ this.hint = options.hint;
48
+ this.recipeId = options.recipeId;
49
+ this.jobKey = options.jobKey;
50
+ this.location = options.location;
51
+ this.details = options.details;
52
+ }
53
+ toDiagnostic() {
54
+ return {
55
+ code: this.code,
56
+ severity: "error",
57
+ message: this.message,
58
+ ...this.hint === void 0 ? {} : { hint: this.hint },
59
+ ...this.recipeId === void 0 ? {} : { recipeId: this.recipeId },
60
+ ...this.jobKey === void 0 ? {} : { jobKey: this.jobKey },
61
+ ...this.location === void 0 ? {} : { location: this.location },
62
+ ...this.details === void 0 ? {} : { details: this.details }
63
+ };
64
+ }
65
+ };
66
+ function isGuideShotError(error) {
67
+ return error instanceof GuideShotError;
68
+ }
69
+ function diagnosticFromUnknown(error, fallbackCode) {
70
+ if (isGuideShotError(error)) {
71
+ return error.toDiagnostic();
72
+ }
73
+ return {
74
+ code: fallbackCode,
75
+ severity: "error",
76
+ message: error instanceof Error ? error.message : String(error)
77
+ };
78
+ }
79
+
80
+ // src/canonical.ts
81
+ function canonicalSerialize(value) {
82
+ return serialize(value, /* @__PURE__ */ new Set());
83
+ }
84
+ function sha256(value) {
85
+ return createHash("sha256").update(value).digest("hex");
86
+ }
87
+ function hashCanonical(value) {
88
+ return sha256(canonicalSerialize(value));
89
+ }
90
+ function createCaptureHash(intent) {
91
+ return hashCanonical({ kind: "capture", version: 1, intent });
92
+ }
93
+ function createCompositionHash(intent) {
94
+ return hashCanonical({ kind: "composition", version: 1, intent });
95
+ }
96
+ function serialize(value, ancestors) {
97
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
98
+ return JSON.stringify(value);
99
+ }
100
+ if (typeof value === "number") {
101
+ if (!Number.isFinite(value)) {
102
+ throw invalidCanonicalValue("non-finite number");
103
+ }
104
+ return JSON.stringify(Object.is(value, -0) ? 0 : value);
105
+ }
106
+ if (Array.isArray(value)) {
107
+ guardCycle(value, ancestors);
108
+ const result = `[${value.map((entry) => serialize(entry, ancestors)).join(",")}]`;
109
+ ancestors.delete(value);
110
+ return result;
111
+ }
112
+ if (isPlainObject(value)) {
113
+ guardCycle(value, ancestors);
114
+ const entries = Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => compareStrings(left, right)).map(
115
+ ([key, entry]) => `${JSON.stringify(key)}:${serialize(entry, ancestors)}`
116
+ );
117
+ ancestors.delete(value);
118
+ return `{${entries.join(",")}}`;
119
+ }
120
+ throw invalidCanonicalValue(
121
+ value === void 0 ? "undefined array/root value" : typeof value
122
+ );
123
+ }
124
+ function isPlainObject(value) {
125
+ if (typeof value !== "object" || value === null) {
126
+ return false;
127
+ }
128
+ const prototype = Object.getPrototypeOf(value);
129
+ return prototype === Object.prototype || prototype === null;
130
+ }
131
+ function guardCycle(value, ancestors) {
132
+ if (ancestors.has(value)) {
133
+ throw invalidCanonicalValue("cyclic object");
134
+ }
135
+ ancestors.add(value);
136
+ }
137
+ function invalidCanonicalValue(kind) {
138
+ return new GuideShotError(
139
+ "RECIPE_SCHEMA_INVALID",
140
+ `Cannot canonically serialize ${kind}.`
141
+ );
142
+ }
143
+ function compareStrings(left, right) {
144
+ return left < right ? -1 : left > right ? 1 : 0;
145
+ }
146
+
147
+ // src/contracts.ts
148
+ function defineConfig(config) {
149
+ return config;
150
+ }
151
+ function defineDimension(definition) {
152
+ return definition;
153
+ }
154
+ function defineScenario(definition) {
155
+ return definition;
156
+ }
157
+
158
+ // src/discovery.ts
159
+ import { readFile } from "fs/promises";
160
+ import path from "path";
161
+ import fg from "fast-glob";
162
+
163
+ // src/validation.ts
164
+ import Ajv2020Import from "ajv/dist/2020.js";
165
+ import addFormatsImport from "ajv-formats";
166
+ import {
167
+ PublicManifestSchema,
168
+ RecipeSchema
169
+ } from "@guideshot/schema";
170
+ import {
171
+ parse as parseJsonc,
172
+ printParseErrorCode
173
+ } from "jsonc-parser";
174
+ var Ajv2020 = Ajv2020Import.default;
175
+ var addFormats = addFormatsImport.default;
176
+ var ajv = addFormats(
177
+ new Ajv2020({
178
+ allErrors: true,
179
+ allowUnionTypes: true,
180
+ strict: true
181
+ })
182
+ );
183
+ var recipeValidator = ajv.compile(RecipeSchema);
184
+ var manifestValidator = ajv.compile(PublicManifestSchema);
185
+ var extensionValidators = /* @__PURE__ */ new WeakMap();
186
+ function parseRecipe(source, options = {}) {
187
+ const errors = [];
188
+ const format = options.format ?? inferFormat(options.file);
189
+ const value = parseJsonc(source, errors, {
190
+ allowEmptyContent: false,
191
+ allowTrailingComma: format === "jsonc",
192
+ disallowComments: format === "json"
193
+ });
194
+ if (errors.length > 0) {
195
+ const first = errors[0];
196
+ if (first === void 0) {
197
+ throw new GuideShotError(
198
+ "RECIPE_SCHEMA_INVALID",
199
+ "Recipe is not valid JSON."
200
+ );
201
+ }
202
+ const position = positionAt(source, first.offset);
203
+ throw new GuideShotError(
204
+ "RECIPE_SCHEMA_INVALID",
205
+ `Recipe contains invalid ${format.toUpperCase()}: ${printParseErrorCode(first.error)}.`,
206
+ {
207
+ location: compactLocation({
208
+ ...options.file === void 0 ? {} : { file: options.file },
209
+ line: position.line,
210
+ column: position.column
211
+ })
212
+ }
213
+ );
214
+ }
215
+ return validateRecipe(value, options.file);
216
+ }
217
+ function validateRecipe(value, file) {
218
+ if (!recipeValidator(value)) {
219
+ throw schemaError("Recipe", recipeValidator.errors, file);
220
+ }
221
+ return value;
222
+ }
223
+ function validateManifest(value, file) {
224
+ if (!manifestValidator(value)) {
225
+ throw new GuideShotError(
226
+ "MANIFEST_INVALID",
227
+ formatValidationMessage("Manifest", manifestValidator.errors),
228
+ {
229
+ ...file === void 0 ? {} : { location: { file } },
230
+ details: validationDetails(manifestValidator.errors)
231
+ }
232
+ );
233
+ }
234
+ return value;
235
+ }
236
+ function validateConfig(config) {
237
+ if (config.recipes.length === 0) {
238
+ throw new GuideShotError(
239
+ "RECIPE_SCHEMA_INVALID",
240
+ "Configuration must include at least one recipe pattern."
241
+ );
242
+ }
243
+ if (Object.keys(config.profiles).length === 0) {
244
+ throw new GuideShotError(
245
+ "EXTENSION_NOT_REGISTERED",
246
+ "Configuration must register at least one capture profile."
247
+ );
248
+ }
249
+ for (const [name, profile] of Object.entries(config.profiles)) {
250
+ if (!Number.isInteger(profile.viewport.width) || profile.viewport.width <= 0 || !Number.isInteger(profile.viewport.height) || profile.viewport.height <= 0) {
251
+ throw new GuideShotError(
252
+ "RECIPE_SCHEMA_INVALID",
253
+ `Profile "${name}" has an invalid viewport.`
254
+ );
255
+ }
256
+ if (profile.pixelRatio !== void 0 && (!Number.isFinite(profile.pixelRatio) || profile.pixelRatio <= 0)) {
257
+ throw new GuideShotError(
258
+ "RECIPE_SCHEMA_INVALID",
259
+ `Profile "${name}" has an invalid pixel ratio.`
260
+ );
261
+ }
262
+ }
263
+ }
264
+ function validateRecipeSemantics(source, config) {
265
+ const { recipe } = source;
266
+ const profile = recipe.profile ?? defaultProfileName(config);
267
+ if (config.profiles[profile] === void 0) {
268
+ throw new GuideShotError(
269
+ "EXTENSION_NOT_REGISTERED",
270
+ `Recipe "${recipe.id}" references unknown profile "${profile}".`,
271
+ recipeOptions(source, { profile })
272
+ );
273
+ }
274
+ validateScenario(recipe, source, config);
275
+ validateMatrix(recipe, source, config);
276
+ validateAnnotationIds(recipe, source);
277
+ }
278
+ function defaultProfileName(config) {
279
+ const names = Object.keys(config.profiles).sort(compareStrings2);
280
+ const name = names[0];
281
+ if (name === void 0) {
282
+ throw new GuideShotError(
283
+ "EXTENSION_NOT_REGISTERED",
284
+ "No capture profile is registered."
285
+ );
286
+ }
287
+ return name;
288
+ }
289
+ function validateScenario(recipe, source, config) {
290
+ if (recipe.scenario === void 0) {
291
+ return;
292
+ }
293
+ const scenario = config.scenarios?.[recipe.scenario.use];
294
+ if (scenario === void 0) {
295
+ throw new GuideShotError(
296
+ "EXTENSION_NOT_REGISTERED",
297
+ `Recipe "${recipe.id}" references unknown scenario "${recipe.scenario.use}".`,
298
+ recipeOptions(source, { scenario: recipe.scenario.use })
299
+ );
300
+ }
301
+ const validator = extensionValidator(scenario.schema);
302
+ const input = recipe.scenario.with ?? {};
303
+ if (!validator(input)) {
304
+ throw new GuideShotError(
305
+ "RECIPE_SCHEMA_INVALID",
306
+ formatValidationMessage(
307
+ `Scenario "${recipe.scenario.use}" parameters`,
308
+ validator.errors
309
+ ),
310
+ {
311
+ ...recipeOptions(source),
312
+ details: validationDetails(validator.errors)
313
+ }
314
+ );
315
+ }
316
+ }
317
+ function extensionValidator(schema) {
318
+ const existing = extensionValidators.get(schema);
319
+ if (existing !== void 0) {
320
+ return existing;
321
+ }
322
+ const validator = ajv.compile(schema);
323
+ extensionValidators.set(schema, validator);
324
+ return validator;
325
+ }
326
+ function validateMatrix(recipe, source, config) {
327
+ for (const [name, values] of Object.entries(
328
+ recipe.matrix?.dimensions ?? {}
329
+ )) {
330
+ const definition = config.dimensions?.[name];
331
+ if (definition === void 0) {
332
+ throw new GuideShotError(
333
+ "EXTENSION_NOT_REGISTERED",
334
+ `Recipe "${recipe.id}" references unknown dimension "${name}".`,
335
+ recipeOptions(source, { dimension: name })
336
+ );
337
+ }
338
+ for (const value of values) {
339
+ if (!definition.values.some((candidate) => Object.is(candidate, value))) {
340
+ throw new GuideShotError(
341
+ "RECIPE_SCHEMA_INVALID",
342
+ `Dimension "${name}" does not allow value "${String(value)}".`,
343
+ recipeOptions(source, { dimension: name })
344
+ );
345
+ }
346
+ }
347
+ }
348
+ }
349
+ function validateAnnotationIds(recipe, source) {
350
+ const ids = /* @__PURE__ */ new Set();
351
+ for (const annotation of recipe.annotations ?? []) {
352
+ if (ids.has(annotation.id)) {
353
+ throw new GuideShotError(
354
+ "RECIPE_SCHEMA_INVALID",
355
+ `Recipe "${recipe.id}" contains duplicate annotation id "${annotation.id}".`,
356
+ recipeOptions(source, { annotationId: annotation.id })
357
+ );
358
+ }
359
+ ids.add(annotation.id);
360
+ }
361
+ }
362
+ function schemaError(subject, errors, file) {
363
+ return new GuideShotError(
364
+ "RECIPE_SCHEMA_INVALID",
365
+ formatValidationMessage(subject, errors),
366
+ {
367
+ ...file === void 0 ? {} : { location: { file } },
368
+ details: validationDetails(errors)
369
+ }
370
+ );
371
+ }
372
+ function formatValidationMessage(subject, errors) {
373
+ const first = errors?.[0];
374
+ if (first === void 0) {
375
+ return `${subject} does not match its schema.`;
376
+ }
377
+ const path4 = first.instancePath === "" ? "/" : first.instancePath;
378
+ return `${subject} is invalid at ${path4}: ${first.message ?? first.keyword}.`;
379
+ }
380
+ function validationDetails(errors) {
381
+ return {
382
+ errors: (errors ?? []).map((error) => ({
383
+ path: error.instancePath,
384
+ keyword: error.keyword,
385
+ message: error.message ?? ""
386
+ }))
387
+ };
388
+ }
389
+ function recipeOptions(source, details) {
390
+ return {
391
+ recipeId: source.recipe.id,
392
+ location: { file: source.file },
393
+ ...details === void 0 ? {} : { details }
394
+ };
395
+ }
396
+ function inferFormat(file) {
397
+ return file?.toLowerCase().endsWith(".jsonc") === true ? "jsonc" : "json";
398
+ }
399
+ function positionAt(source, offset) {
400
+ const prefix = source.slice(0, offset);
401
+ const lines = prefix.split(/\r?\n/);
402
+ return {
403
+ line: lines.length,
404
+ column: (lines.at(-1)?.length ?? 0) + 1
405
+ };
406
+ }
407
+ function compactLocation(location) {
408
+ return location.file === void 0 ? { line: location.line, column: location.column } : { file: location.file, line: location.line, column: location.column };
409
+ }
410
+ function compareStrings2(left, right) {
411
+ return left < right ? -1 : left > right ? 1 : 0;
412
+ }
413
+
414
+ // src/discovery.ts
415
+ async function discoverRecipes(config, projectRoot) {
416
+ const root = path.resolve(projectRoot);
417
+ const files = await fg([...config.recipes], {
418
+ absolute: true,
419
+ cwd: root,
420
+ followSymbolicLinks: false,
421
+ onlyFiles: true,
422
+ unique: true
423
+ });
424
+ files.sort(compareStrings3);
425
+ if (files.length === 0) {
426
+ throw new GuideShotError(
427
+ "RECIPE_SCHEMA_INVALID",
428
+ "No recipe files matched the configured discovery patterns."
429
+ );
430
+ }
431
+ const sources = await Promise.all(
432
+ files.map(async (file) => {
433
+ assertInsideProject(root, file);
434
+ const text = await readFile(file, "utf8");
435
+ return {
436
+ file,
437
+ recipe: parseRecipe(text, {
438
+ file,
439
+ format: file.toLowerCase().endsWith(".jsonc") ? "jsonc" : "json"
440
+ })
441
+ };
442
+ })
443
+ );
444
+ assertUniqueRecipeIds(sources);
445
+ return sources;
446
+ }
447
+ function assertUniqueRecipeIds(sources) {
448
+ const seen = /* @__PURE__ */ new Map();
449
+ for (const source of sources) {
450
+ const previous = seen.get(source.recipe.id);
451
+ if (previous !== void 0) {
452
+ throw new GuideShotError(
453
+ "OUTPUT_COLLISION",
454
+ `Recipe id "${source.recipe.id}" is declared more than once.`,
455
+ {
456
+ recipeId: source.recipe.id,
457
+ location: { file: source.file },
458
+ details: { previousFile: previous }
459
+ }
460
+ );
461
+ }
462
+ seen.set(source.recipe.id, source.file);
463
+ }
464
+ }
465
+ function assertInsideProject(root, file) {
466
+ const relative = path.relative(root, file);
467
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
468
+ throw new GuideShotError(
469
+ "RECIPE_SCHEMA_INVALID",
470
+ "Recipe discovery may not read outside the project root.",
471
+ { location: { file } }
472
+ );
473
+ }
474
+ }
475
+ function compareStrings3(left, right) {
476
+ return left < right ? -1 : left > right ? 1 : 0;
477
+ }
478
+
479
+ // src/interpolate.ts
480
+ var REFERENCE = /\$\{([^}]+)\}/g;
481
+ var FULL_REFERENCE = /^\$\{([^}]+)\}$/;
482
+ var SAFE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;
483
+ var FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
484
+ function interpolate(value, context) {
485
+ return interpolateValue(value, context);
486
+ }
487
+ function interpolateString(value, context) {
488
+ const full = FULL_REFERENCE.exec(value);
489
+ if (full !== null) {
490
+ return resolveReference(requiredGroup(full, 1), context);
491
+ }
492
+ REFERENCE.lastIndex = 0;
493
+ const interpolated = value.replace(
494
+ REFERENCE,
495
+ (_match, rawReference) => {
496
+ const resolved = resolveReference(rawReference, context);
497
+ if (typeof resolved === "object" || resolved === null || Array.isArray(resolved)) {
498
+ throw unresolved(
499
+ rawReference,
500
+ "Embedded references must resolve to a string, number, or boolean."
501
+ );
502
+ }
503
+ return String(resolved);
504
+ }
505
+ );
506
+ if (interpolated.includes("${")) {
507
+ throw unresolved(value, "Reference syntax is incomplete.");
508
+ }
509
+ return interpolated;
510
+ }
511
+ function interpolateValue(value, context) {
512
+ if (typeof value === "string") {
513
+ return interpolateString(value, context);
514
+ }
515
+ if (Array.isArray(value)) {
516
+ return value.map((entry) => interpolateValue(entry, context));
517
+ }
518
+ if (isPlainObject2(value)) {
519
+ return Object.fromEntries(
520
+ Object.entries(value).map(([key, entry]) => [
521
+ key,
522
+ interpolateValue(entry, context)
523
+ ])
524
+ );
525
+ }
526
+ return value;
527
+ }
528
+ function resolveReference(reference, context) {
529
+ const segments = reference.split(".");
530
+ const root = segments.shift();
531
+ if (root !== "scenario" && root !== "variant" || segments.length === 0 || segments.some(
532
+ (segment) => !SAFE_SEGMENT.test(segment) || FORBIDDEN_SEGMENTS.has(segment)
533
+ )) {
534
+ throw unresolved(
535
+ reference,
536
+ "References must use ${scenario.name} or ${variant.name} lookups."
537
+ );
538
+ }
539
+ let current = root === "scenario" ? context.scenario : context.variant;
540
+ for (const segment of segments) {
541
+ if (!isPlainObject2(current) || !Object.hasOwn(current, segment)) {
542
+ throw unresolved(reference, `No value exists for "${reference}".`);
543
+ }
544
+ current = current[segment];
545
+ }
546
+ if (!isJsonValue(current)) {
547
+ throw unresolved(
548
+ reference,
549
+ `The value for "${reference}" is not JSON data.`
550
+ );
551
+ }
552
+ return current;
553
+ }
554
+ function unresolved(reference, message) {
555
+ return new GuideShotError("VARIABLE_UNRESOLVED", message, {
556
+ details: { reference }
557
+ });
558
+ }
559
+ function isJsonValue(value) {
560
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
561
+ return true;
562
+ }
563
+ if (typeof value === "number") {
564
+ return Number.isFinite(value);
565
+ }
566
+ if (Array.isArray(value)) {
567
+ return value.every(isJsonValue);
568
+ }
569
+ return isPlainObject2(value) && Object.values(value).every(isJsonValue);
570
+ }
571
+ function isPlainObject2(value) {
572
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
573
+ return false;
574
+ }
575
+ const prototype = Object.getPrototypeOf(value);
576
+ return prototype === Object.prototype || prototype === null;
577
+ }
578
+ function requiredGroup(match, index) {
579
+ const value = match[index];
580
+ if (value === void 0) {
581
+ throw new GuideShotError(
582
+ "VARIABLE_UNRESOLVED",
583
+ "Reference syntax is incomplete."
584
+ );
585
+ }
586
+ return value;
587
+ }
588
+
589
+ // src/manifest.ts
590
+ import path3 from "path";
591
+ import {
592
+ SCHEMA_VERSION
593
+ } from "@guideshot/schema";
594
+
595
+ // src/safety.ts
596
+ import path2 from "path";
597
+ var HTTP_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
598
+ function assertAllowedOrigin(value, allowedOrigins = []) {
599
+ const url = toUrl(value);
600
+ if (!HTTP_PROTOCOLS.has(url.protocol)) {
601
+ throw originError(url, "Only HTTP and HTTPS origins are supported.");
602
+ }
603
+ if (url.username !== "" || url.password !== "") {
604
+ throw originError(url, "Credentials are not allowed in the server URL.");
605
+ }
606
+ const allowed = new Set(allowedOrigins.map(normalizeAllowedOrigin));
607
+ if (!isLoopbackHostname(url.hostname) && !allowed.has(url.origin)) {
608
+ throw originError(
609
+ url,
610
+ `Origin "${url.origin}" is not loopback and is not explicitly allowed.`
611
+ );
612
+ }
613
+ return url;
614
+ }
615
+ function resolvePageUrl(baseUrl, pagePath) {
616
+ const base = toUrl(baseUrl);
617
+ const resolved = new URL(pagePath, base);
618
+ if (resolved.origin !== base.origin || !HTTP_PROTOCOLS.has(resolved.protocol)) {
619
+ throw originError(
620
+ resolved,
621
+ `Page path "${pagePath}" resolves outside the configured origin.`
622
+ );
623
+ }
624
+ if (resolved.username !== "" || resolved.password !== "") {
625
+ throw originError(resolved, "Credentials are not allowed in page URLs.");
626
+ }
627
+ return resolved;
628
+ }
629
+ function resolveSafeProjectPaths(projectRoot, outputDir, cacheDir) {
630
+ const root = path2.resolve(projectRoot);
631
+ const output = resolveDescendant(root, outputDir, "output");
632
+ const cache = resolveDescendant(root, cacheDir, "cache");
633
+ if (output === cache || isInside(output, cache) || isInside(cache, output)) {
634
+ throw new GuideShotError(
635
+ "OUTPUT_COLLISION",
636
+ "Output and cache directories must not overlap."
637
+ );
638
+ }
639
+ return { outputDir: output, cacheDir: cache };
640
+ }
641
+ function resolveArtifactPath(outputDir, relativePath) {
642
+ if (path2.isAbsolute(relativePath) || relativePath.includes("\0")) {
643
+ throw unsafeOutputPath(relativePath);
644
+ }
645
+ const root = path2.resolve(outputDir);
646
+ const resolved = path2.resolve(root, relativePath);
647
+ if (!isInside(root, resolved)) {
648
+ throw unsafeOutputPath(relativePath);
649
+ }
650
+ return resolved;
651
+ }
652
+ function sanitizeFileSegment(value) {
653
+ const sanitized = value.normalize("NFKD").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
654
+ if (sanitized === "") {
655
+ throw new GuideShotError(
656
+ "OUTPUT_COLLISION",
657
+ `Value "${value}" cannot be represented safely in an output filename.`
658
+ );
659
+ }
660
+ return sanitized;
661
+ }
662
+ function resolveDescendant(root, candidate, label) {
663
+ if (candidate.includes("\0")) {
664
+ throw unsafeOutputPath(candidate);
665
+ }
666
+ const resolved = path2.resolve(root, candidate);
667
+ if (resolved === root || !isInside(root, resolved)) {
668
+ throw new GuideShotError(
669
+ "OUTPUT_COLLISION",
670
+ `Configured ${label} directory must be below the project root.`,
671
+ { details: { directory: candidate } }
672
+ );
673
+ }
674
+ return resolved;
675
+ }
676
+ function isInside(parent, child) {
677
+ const relative = path2.relative(parent, child);
678
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path2.sep}`) && !path2.isAbsolute(relative);
679
+ }
680
+ function isLoopbackHostname(hostname) {
681
+ const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();
682
+ if (normalized === "localhost" || normalized === "::1") {
683
+ return true;
684
+ }
685
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized);
686
+ if (match === null) {
687
+ return false;
688
+ }
689
+ return Number(match[1]) === 127;
690
+ }
691
+ function normalizeAllowedOrigin(value) {
692
+ const url = toUrl(value);
693
+ if (!HTTP_PROTOCOLS.has(url.protocol) || url.username !== "" || url.password !== "") {
694
+ throw originError(url, `Invalid allowed origin "${value}".`);
695
+ }
696
+ return url.origin;
697
+ }
698
+ function toUrl(value) {
699
+ try {
700
+ return value instanceof URL ? new URL(value.href) : new URL(value);
701
+ } catch (cause) {
702
+ throw new GuideShotError(
703
+ "ORIGIN_NOT_ALLOWED",
704
+ `Invalid URL "${String(value)}".`,
705
+ {
706
+ cause
707
+ }
708
+ );
709
+ }
710
+ }
711
+ function originError(url, message) {
712
+ return new GuideShotError("ORIGIN_NOT_ALLOWED", message, {
713
+ details: { origin: url.origin }
714
+ });
715
+ }
716
+ function unsafeOutputPath(value) {
717
+ return new GuideShotError(
718
+ "OUTPUT_COLLISION",
719
+ `Unsafe output path "${value}".`
720
+ );
721
+ }
722
+
723
+ // src/manifest.ts
724
+ function buildPublicManifest(assets) {
725
+ const entries = /* @__PURE__ */ new Map();
726
+ for (const asset of assets) {
727
+ assertPublicAsset(asset);
728
+ const entry = entries.get(asset.recipeId) ?? {
729
+ ...asset.title === void 0 ? {} : { title: asset.title },
730
+ variants: /* @__PURE__ */ new Map()
731
+ };
732
+ if (entry.title !== void 0 && asset.title !== void 0 && entry.title !== asset.title) {
733
+ throw new GuideShotError(
734
+ "OUTPUT_COLLISION",
735
+ `Recipe "${asset.recipeId}" has conflicting manifest titles.`,
736
+ { recipeId: asset.recipeId }
737
+ );
738
+ }
739
+ if (entry.title === void 0 && asset.title !== void 0) {
740
+ entry.title = asset.title;
741
+ }
742
+ if (entry.variants.has(asset.variantKey)) {
743
+ throw new GuideShotError(
744
+ "OUTPUT_COLLISION",
745
+ `Manifest variant "${asset.recipeId}::${asset.variantKey}" is duplicated.`,
746
+ { recipeId: asset.recipeId }
747
+ );
748
+ }
749
+ entry.variants.set(asset.variantKey, {
750
+ src: asset.src,
751
+ width: asset.width,
752
+ height: asset.height,
753
+ format: asset.format,
754
+ hash: asset.hash,
755
+ alt: asset.alt
756
+ });
757
+ entries.set(asset.recipeId, entry);
758
+ }
759
+ const manifestEntries = [...entries.entries()].sort(([left], [right]) => compareStrings4(left, right)).map(([id, entry]) => ({
760
+ id,
761
+ ...entry.title === void 0 ? {} : { title: entry.title },
762
+ variants: Object.fromEntries(
763
+ [...entry.variants.entries()].sort(
764
+ ([left], [right]) => compareStrings4(left, right)
765
+ )
766
+ )
767
+ }));
768
+ return validateManifest({
769
+ version: SCHEMA_VERSION,
770
+ entries: manifestEntries
771
+ });
772
+ }
773
+ function createAssetPath(recipeId, variantKey, hash, format) {
774
+ assertHash(hash);
775
+ const variant = variantKey === "default" ? "default" : variantKey;
776
+ const filename = [
777
+ sanitizeFileSegment(recipeId),
778
+ sanitizeFileSegment(variant),
779
+ hash.slice(0, 12)
780
+ ].join(".");
781
+ return `./assets/${filename}.${format}`;
782
+ }
783
+ function assertPublicAsset(asset) {
784
+ if (!isSafePublicSource(asset.src)) {
785
+ throw new GuideShotError(
786
+ "MANIFEST_INVALID",
787
+ `Manifest source "${asset.src}" must be a relative asset path.`,
788
+ { recipeId: asset.recipeId }
789
+ );
790
+ }
791
+ if (!Number.isInteger(asset.width) || asset.width <= 0 || !Number.isInteger(asset.height) || asset.height <= 0) {
792
+ throw new GuideShotError(
793
+ "MANIFEST_INVALID",
794
+ `Manifest variant "${asset.recipeId}::${asset.variantKey}" has invalid dimensions.`,
795
+ { recipeId: asset.recipeId }
796
+ );
797
+ }
798
+ assertHash(asset.hash);
799
+ }
800
+ function isSafePublicSource(source) {
801
+ if (source === "" || source.includes("\0") || source.includes("\\") || source.includes("?") || source.includes("#") || path3.posix.isAbsolute(source) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(source)) {
802
+ return false;
803
+ }
804
+ const normalized = path3.posix.normalize(source.replace(/^\.\//, ""));
805
+ return normalized.startsWith("assets/") && normalized !== "assets" && !normalized.startsWith("../");
806
+ }
807
+ function assertHash(hash) {
808
+ if (!/^[a-f0-9]{64}$/.test(hash)) {
809
+ throw new GuideShotError(
810
+ "MANIFEST_INVALID",
811
+ "Manifest asset hashes must be lowercase SHA-256 values."
812
+ );
813
+ }
814
+ }
815
+ function compareStrings4(left, right) {
816
+ return left < right ? -1 : left > right ? 1 : 0;
817
+ }
818
+
819
+ // src/matrix.ts
820
+ function expandMatrix(matrix) {
821
+ if (matrix === void 0) {
822
+ return [{ key: "default", values: {} }];
823
+ }
824
+ const dimensions = Object.keys(matrix.dimensions).sort(compareStrings5);
825
+ validateDimensions(dimensions, matrix);
826
+ let rows = [{}];
827
+ for (const dimension of dimensions) {
828
+ const values = matrix.dimensions[dimension] ?? [];
829
+ rows = rows.flatMap(
830
+ (row) => values.map((value) => ({ ...row, [dimension]: value }))
831
+ );
832
+ }
833
+ rows = rows.filter(
834
+ (row) => !(matrix.exclude ?? []).some((rule) => matchesRule(row, rule))
835
+ );
836
+ for (const included of matrix.include ?? []) {
837
+ validateIncludedRow(included, dimensions, matrix);
838
+ if (!rows.some((row) => rowsEqual(row, included))) {
839
+ rows.push(copySorted(included));
840
+ }
841
+ }
842
+ if (dimensions.length === 0 && rows.length === 0) {
843
+ rows.push({});
844
+ }
845
+ const keys = /* @__PURE__ */ new Map();
846
+ const expanded = rows.map((values) => {
847
+ const sorted = copySorted(values);
848
+ const key = createVariantKey(sorted);
849
+ const previous = keys.get(key);
850
+ if (previous !== void 0 && !rowsEqual(previous, sorted)) {
851
+ throw new GuideShotError(
852
+ "OUTPUT_COLLISION",
853
+ `Variant values produce the same key "${key}".`,
854
+ { details: { key } }
855
+ );
856
+ }
857
+ keys.set(key, sorted);
858
+ return { key, values: sorted };
859
+ });
860
+ return expanded;
861
+ }
862
+ function createVariantKey(variants) {
863
+ const entries = Object.entries(variants).sort(
864
+ ([left], [right]) => compareStrings5(left, right)
865
+ );
866
+ if (entries.length === 0) {
867
+ return "default";
868
+ }
869
+ return entries.map(
870
+ ([dimension, value]) => `${encodeURIComponent(dimension)}=${encodeURIComponent(String(value))}`
871
+ ).join(";");
872
+ }
873
+ function matchesVariantFilter(variants, filter) {
874
+ return matchesRule(variants, filter);
875
+ }
876
+ function validateDimensions(dimensions, matrix) {
877
+ for (const dimension of dimensions) {
878
+ if (!isDimensionName(dimension)) {
879
+ throw new GuideShotError(
880
+ "RECIPE_SCHEMA_INVALID",
881
+ `Invalid matrix dimension name "${dimension}".`
882
+ );
883
+ }
884
+ const values = matrix.dimensions[dimension] ?? [];
885
+ if (values.length === 0) {
886
+ throw new GuideShotError(
887
+ "RECIPE_SCHEMA_INVALID",
888
+ `Matrix dimension "${dimension}" must contain at least one value.`
889
+ );
890
+ }
891
+ const encoded = /* @__PURE__ */ new Set();
892
+ for (const value of values) {
893
+ const key = String(value);
894
+ if (encoded.has(key)) {
895
+ throw new GuideShotError(
896
+ "OUTPUT_COLLISION",
897
+ `Matrix dimension "${dimension}" contains colliding value "${key}".`
898
+ );
899
+ }
900
+ encoded.add(key);
901
+ }
902
+ }
903
+ for (const rule of matrix.exclude ?? []) {
904
+ for (const dimension of Object.keys(rule)) {
905
+ if (!dimensions.includes(dimension)) {
906
+ throw new GuideShotError(
907
+ "RECIPE_SCHEMA_INVALID",
908
+ `Matrix exclusion references unknown dimension "${dimension}".`
909
+ );
910
+ }
911
+ }
912
+ }
913
+ }
914
+ function validateIncludedRow(row, dimensions, matrix) {
915
+ const includedDimensions = Object.keys(row).sort(compareStrings5);
916
+ if (includedDimensions.length !== dimensions.length || includedDimensions.some(
917
+ (dimension, index) => dimension !== dimensions[index]
918
+ )) {
919
+ throw new GuideShotError(
920
+ "RECIPE_SCHEMA_INVALID",
921
+ "Every matrix inclusion must specify exactly every declared dimension."
922
+ );
923
+ }
924
+ for (const dimension of dimensions) {
925
+ const value = row[dimension];
926
+ const allowed = matrix.dimensions[dimension] ?? [];
927
+ if (!allowed.some((candidate) => Object.is(candidate, value))) {
928
+ throw new GuideShotError(
929
+ "RECIPE_SCHEMA_INVALID",
930
+ `Matrix inclusion uses an unsupported value for "${dimension}".`
931
+ );
932
+ }
933
+ }
934
+ }
935
+ function matchesRule(row, rule) {
936
+ return Object.entries(rule).every(
937
+ ([dimension, value]) => Object.is(row[dimension], value)
938
+ );
939
+ }
940
+ function rowsEqual(left, right) {
941
+ const dimensions = Object.keys(left);
942
+ return dimensions.length === Object.keys(right).length && dimensions.every(
943
+ (dimension) => Object.is(left[dimension], right[dimension])
944
+ );
945
+ }
946
+ function copySorted(values) {
947
+ return Object.fromEntries(
948
+ Object.entries(values).sort(
949
+ ([left], [right]) => compareStrings5(left, right)
950
+ )
951
+ );
952
+ }
953
+ function isDimensionName(value) {
954
+ return /^[a-z][a-zA-Z0-9_-]*$/.test(value);
955
+ }
956
+ function compareStrings5(left, right) {
957
+ return left < right ? -1 : left > right ? 1 : 0;
958
+ }
959
+
960
+ // src/planner.ts
961
+ async function planProject(config, projectRoot, options = {}) {
962
+ const recipes = await discoverRecipes(config, projectRoot);
963
+ return planRecipes(config, recipes, options);
964
+ }
965
+ function planRecipes(config, sources, options = {}) {
966
+ validateConfig(config);
967
+ assertUniqueRecipeIds(sources);
968
+ const serverUrl = assertAllowedOrigin(
969
+ config.server.url,
970
+ config.safety?.allowedOrigins ?? []
971
+ );
972
+ const jobs = [];
973
+ for (const source of sources) {
974
+ validateRecipeSemantics(source, config);
975
+ resolvePageUrl(serverUrl, source.recipe.page.path);
976
+ if (!recipeSelected(source, options)) {
977
+ continue;
978
+ }
979
+ const recipe = source.recipe;
980
+ const profileName = recipe.profile ?? defaultProfileName(config);
981
+ const profile = resolveProfile(
982
+ config.profiles[profileName],
983
+ recipe.capture?.pixelRatio
984
+ );
985
+ for (const row of expandMatrix(recipe.matrix)) {
986
+ if (options.variants !== void 0 && !matchesVariantFilter(row.values, options.variants)) {
987
+ continue;
988
+ }
989
+ const dimensionVersions = Object.fromEntries(
990
+ Object.keys(row.values).sort(compareStrings6).map((dimension) => [
991
+ dimension,
992
+ requiredDimensionVersion(config, dimension, recipe.id)
993
+ ])
994
+ );
995
+ const scenario = recipe.scenario === void 0 ? void 0 : config.scenarios?.[recipe.scenario.use];
996
+ const captureIntent = {
997
+ recipeId: recipe.id,
998
+ profile: profileName,
999
+ serverUrl: config.server.url,
1000
+ targetAttribute: config.targetAttribute ?? "data-guide-target",
1001
+ variants: row.values,
1002
+ page: recipe.page,
1003
+ profileConfig: profile,
1004
+ dimensionVersions,
1005
+ driver: { name: config.driver.name, version: config.driver.version },
1006
+ ...recipe.scenario === void 0 ? {} : { scenario: recipe.scenario },
1007
+ ...recipe.prepare === void 0 ? {} : { prepare: recipe.prepare },
1008
+ ...recipe.ready === void 0 ? {} : { ready: recipe.ready },
1009
+ ...recipe.capture === void 0 ? {} : { capture: recipe.capture },
1010
+ ...scenario === void 0 ? {} : {
1011
+ scenarioVersion: scenario.version,
1012
+ ...scenario.datasetRevision === void 0 ? {} : { datasetRevision: scenario.datasetRevision }
1013
+ },
1014
+ ...config.translations === void 0 ? {} : { translationVersion: config.translations.version }
1015
+ };
1016
+ const compositionIntent = {
1017
+ accessibility: recipe.accessibility,
1018
+ renderer: {
1019
+ name: config.renderer.name,
1020
+ version: config.renderer.version
1021
+ },
1022
+ ...recipe.annotations === void 0 ? {} : { annotations: recipe.annotations },
1023
+ ...recipe.output === void 0 ? {} : { output: recipe.output },
1024
+ ...config.translations === void 0 ? {} : { translationVersion: config.translations.version }
1025
+ };
1026
+ jobs.push({
1027
+ key: `${recipe.id}::${row.key}`,
1028
+ recipeId: recipe.id,
1029
+ recipeFile: source.file,
1030
+ profile: profileName,
1031
+ variantKey: row.key,
1032
+ variants: row.values,
1033
+ captureKey: createCaptureHash(captureIntent),
1034
+ captureIntent,
1035
+ compositionIntent,
1036
+ recipe
1037
+ });
1038
+ }
1039
+ }
1040
+ jobs.sort((left, right) => compareStrings6(left.key, right.key));
1041
+ assertUniqueJobKeys(jobs);
1042
+ return { jobs, recipes: [...sources] };
1043
+ }
1044
+ function createJobCompositionHash(job, sceneHash) {
1045
+ return createCompositionHash({
1046
+ ...job.compositionIntent,
1047
+ sceneHash
1048
+ });
1049
+ }
1050
+ function recipeSelected(source, options) {
1051
+ if (options.ids !== void 0 && !options.ids.includes(source.recipe.id)) {
1052
+ return false;
1053
+ }
1054
+ if (options.tags !== void 0 && !options.tags.every((tag) => source.recipe.tags?.includes(tag) === true)) {
1055
+ return false;
1056
+ }
1057
+ return true;
1058
+ }
1059
+ function resolveProfile(profile, pixelRatio) {
1060
+ if (profile === void 0) {
1061
+ throw new GuideShotError(
1062
+ "EXTENSION_NOT_REGISTERED",
1063
+ "The selected capture profile is not registered."
1064
+ );
1065
+ }
1066
+ return {
1067
+ ...profile,
1068
+ ...pixelRatio === void 0 ? {} : { pixelRatio }
1069
+ };
1070
+ }
1071
+ function requiredDimensionVersion(config, dimension, recipeId) {
1072
+ const definition = config.dimensions?.[dimension];
1073
+ if (definition === void 0) {
1074
+ throw new GuideShotError(
1075
+ "EXTENSION_NOT_REGISTERED",
1076
+ `Recipe "${recipeId}" references unknown dimension "${dimension}".`,
1077
+ { recipeId, details: { dimension } }
1078
+ );
1079
+ }
1080
+ return definition.version;
1081
+ }
1082
+ function assertUniqueJobKeys(jobs) {
1083
+ const seen = /* @__PURE__ */ new Set();
1084
+ for (const job of jobs) {
1085
+ if (seen.has(job.key)) {
1086
+ throw new GuideShotError(
1087
+ "OUTPUT_COLLISION",
1088
+ `More than one capture job resolves to "${job.key}".`,
1089
+ { recipeId: job.recipeId, jobKey: job.key }
1090
+ );
1091
+ }
1092
+ seen.add(job.key);
1093
+ }
1094
+ }
1095
+ function compareStrings6(left, right) {
1096
+ return left < right ? -1 : left > right ? 1 : 0;
1097
+ }
1098
+
1099
+ // src/text.ts
1100
+ async function resolveLocalizedText(value, provider, context) {
1101
+ if (typeof value === "string") {
1102
+ return value;
1103
+ }
1104
+ const candidate = value;
1105
+ if (typeof candidate.message === "string" && (candidate.args === void 0 || isJsonObject(candidate.args))) {
1106
+ if (provider === void 0) {
1107
+ throw new GuideShotError(
1108
+ "EXTENSION_NOT_REGISTERED",
1109
+ `Translation message "${candidate.message}" requires a translation provider.`
1110
+ );
1111
+ }
1112
+ return provider.resolve(candidate.message, {
1113
+ locale: context.locale,
1114
+ args: candidate.args ?? {},
1115
+ variables: context.variables
1116
+ });
1117
+ }
1118
+ const localeMap = value;
1119
+ const exact = localeMap[context.locale];
1120
+ if (exact !== void 0) {
1121
+ return exact;
1122
+ }
1123
+ const baseLocale = context.locale.split("-")[0];
1124
+ const base = baseLocale === void 0 ? void 0 : localeMap[baseLocale];
1125
+ if (base !== void 0) {
1126
+ return base;
1127
+ }
1128
+ throw new GuideShotError(
1129
+ "VARIABLE_UNRESOLVED",
1130
+ `Localized text has no value for locale "${context.locale}".`,
1131
+ { details: { locale: context.locale } }
1132
+ );
1133
+ }
1134
+ async function resolveRecipeText(recipe, provider, context) {
1135
+ const prepare = await Promise.all(
1136
+ (recipe.prepare ?? []).map(
1137
+ (action) => resolveActionText(action, provider, context)
1138
+ )
1139
+ );
1140
+ const ready = await Promise.all(
1141
+ (recipe.ready ?? []).map(
1142
+ (expectation) => resolveExpectationText(expectation, provider, context)
1143
+ )
1144
+ );
1145
+ const annotations = await Promise.all(
1146
+ (recipe.annotations ?? []).map(
1147
+ (annotation) => resolveAnnotationText(annotation, provider, context)
1148
+ )
1149
+ );
1150
+ const accessibility = "alt" in recipe.accessibility ? {
1151
+ alt: await resolveLocalizedText(
1152
+ recipe.accessibility.alt,
1153
+ provider,
1154
+ context
1155
+ )
1156
+ } : recipe.accessibility;
1157
+ return {
1158
+ ...recipe,
1159
+ ...recipe.prepare === void 0 ? {} : { prepare },
1160
+ ...recipe.ready === void 0 ? {} : { ready },
1161
+ ...recipe.annotations === void 0 ? {} : { annotations },
1162
+ accessibility
1163
+ };
1164
+ }
1165
+ function resolvedAnnotations(recipe) {
1166
+ return (recipe.annotations ?? []).map((definition) => ({
1167
+ definition,
1168
+ ...(definition.kind === "callout" || definition.kind === "label" || definition.kind === "marker") && typeof definition.content === "string" ? { text: definition.content } : {}
1169
+ }));
1170
+ }
1171
+ function resolvedAlt(recipe) {
1172
+ const accessibility = recipe.accessibility;
1173
+ if ("decorative" in accessibility) {
1174
+ return "";
1175
+ }
1176
+ if (typeof accessibility.alt !== "string") {
1177
+ throw new GuideShotError(
1178
+ "VARIABLE_UNRESOLVED",
1179
+ `Alt text for recipe "${recipe.id}" has not been resolved.`,
1180
+ { recipeId: recipe.id }
1181
+ );
1182
+ }
1183
+ return accessibility.alt;
1184
+ }
1185
+ async function resolveActionText(action, provider, context) {
1186
+ if (action.do !== "fill") {
1187
+ return action;
1188
+ }
1189
+ return {
1190
+ ...action,
1191
+ value: await resolveLocalizedText(action.value, provider, context)
1192
+ };
1193
+ }
1194
+ async function resolveExpectationText(expectation, provider, context) {
1195
+ if (expectation.expect !== "text" && expectation.expect !== "value") {
1196
+ return expectation;
1197
+ }
1198
+ return {
1199
+ ...expectation,
1200
+ value: await resolveLocalizedText(expectation.value, provider, context)
1201
+ };
1202
+ }
1203
+ async function resolveAnnotationText(annotation, provider, context) {
1204
+ if (annotation.kind !== "callout" && annotation.kind !== "label" && annotation.kind !== "marker") {
1205
+ return annotation;
1206
+ }
1207
+ const content = annotation.content;
1208
+ if (content === void 0) {
1209
+ return annotation;
1210
+ }
1211
+ return {
1212
+ ...annotation,
1213
+ content: await resolveLocalizedText(content, provider, context)
1214
+ };
1215
+ }
1216
+ function isJsonObject(value) {
1217
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1218
+ }
1219
+
1220
+ // src/resolution.ts
1221
+ async function resolveJob(planned, config, options) {
1222
+ const scenario = await prepareScenario(planned, config, options);
1223
+ try {
1224
+ const dimensionState = await resolveDimensions(
1225
+ planned,
1226
+ config,
1227
+ options.signal
1228
+ );
1229
+ const safeVariables = scenario?.variables ?? {};
1230
+ const browser = mergeBrowserState(
1231
+ profileBrowserState(planned),
1232
+ dimensionState,
1233
+ scenario?.browser ?? {}
1234
+ );
1235
+ const interpolatedRecipe = validateRecipe(
1236
+ interpolate(planned.recipe, {
1237
+ scenario: safeVariables,
1238
+ variant: planned.variants
1239
+ }),
1240
+ planned.recipeFile
1241
+ );
1242
+ const recipe = validateRecipe(
1243
+ await resolveRecipeText(interpolatedRecipe, config.translations, {
1244
+ locale: browser.locale ?? "en",
1245
+ variables: safeVariables,
1246
+ variants: planned.variants
1247
+ }),
1248
+ planned.recipeFile
1249
+ );
1250
+ resolvePageUrl(options.baseUrl, recipe.page.path);
1251
+ return {
1252
+ recipe,
1253
+ capture: {
1254
+ key: planned.key,
1255
+ recipeId: planned.recipeId,
1256
+ variantKey: planned.variantKey,
1257
+ variants: planned.variants,
1258
+ profile: planned.captureIntent.profileConfig,
1259
+ page: recipe.page,
1260
+ prepare: recipe.prepare ?? [],
1261
+ ready: recipe.ready ?? [],
1262
+ capture: recipe.capture ?? {},
1263
+ browser,
1264
+ safeVariables
1265
+ },
1266
+ ...scenario?.cleanup === void 0 ? {} : { cleanup: scenario.cleanup }
1267
+ };
1268
+ } catch (failure) {
1269
+ return cleanupAfterResolutionFailure(scenario?.cleanup, planned, failure);
1270
+ }
1271
+ }
1272
+ async function cleanupAfterResolutionFailure(cleanup, planned, failure) {
1273
+ if (cleanup === void 0) throw failure;
1274
+ try {
1275
+ await cleanup();
1276
+ } catch (cleanupFailure) {
1277
+ throw new GuideShotError(
1278
+ "SCENARIO_FAILED",
1279
+ `Scenario cleanup failed after job resolution failed for "${planned.key}".`,
1280
+ {
1281
+ recipeId: planned.recipeId,
1282
+ jobKey: planned.key,
1283
+ details: {
1284
+ resolutionError: errorMessage(failure),
1285
+ cleanupError: errorMessage(cleanupFailure)
1286
+ },
1287
+ cause: new AggregateError(
1288
+ [failure, cleanupFailure],
1289
+ `Resolution and scenario cleanup both failed for "${planned.key}".`
1290
+ )
1291
+ }
1292
+ );
1293
+ }
1294
+ throw failure;
1295
+ }
1296
+ function errorMessage(error) {
1297
+ return error instanceof Error ? error.message : String(error);
1298
+ }
1299
+ function mergeBrowserState(...patches) {
1300
+ const scalar = {};
1301
+ const cookies = /* @__PURE__ */ new Map();
1302
+ const storage = /* @__PURE__ */ new Map();
1303
+ const headers = {};
1304
+ for (const patch of patches) {
1305
+ assignDefinedScalar(scalar, patch, "locale");
1306
+ assignDefinedScalar(scalar, patch, "timezoneId");
1307
+ assignDefinedScalar(scalar, patch, "colorScheme");
1308
+ assignDefinedScalar(scalar, patch, "reducedMotion");
1309
+ for (const cookie of patch.cookies ?? []) {
1310
+ cookies.set(cookieKey(cookie), cookie);
1311
+ }
1312
+ for (const origin of patch.localStorage ?? []) {
1313
+ const values = storage.get(origin.origin) ?? /* @__PURE__ */ new Map();
1314
+ for (const [key, value] of Object.entries(origin.values)) {
1315
+ values.set(key, value);
1316
+ }
1317
+ storage.set(origin.origin, values);
1318
+ }
1319
+ Object.assign(headers, patch.extraHTTPHeaders);
1320
+ }
1321
+ return {
1322
+ ...scalar,
1323
+ ...cookies.size === 0 ? {} : { cookies: [...cookies.values()] },
1324
+ ...storage.size === 0 ? {} : {
1325
+ localStorage: [...storage.entries()].map(
1326
+ ([origin, values]) => ({
1327
+ origin,
1328
+ values: Object.fromEntries(values)
1329
+ })
1330
+ )
1331
+ },
1332
+ ...Object.keys(headers).length === 0 ? {} : { extraHTTPHeaders: headers }
1333
+ };
1334
+ }
1335
+ async function resolveDimensions(planned, config, signal) {
1336
+ const patches = [];
1337
+ for (const dimension of Object.keys(planned.variants).sort(compareStrings7)) {
1338
+ const definition = config.dimensions?.[dimension];
1339
+ if (definition === void 0) {
1340
+ throw new GuideShotError(
1341
+ "EXTENSION_NOT_REGISTERED",
1342
+ `Dimension "${dimension}" is not registered.`,
1343
+ { recipeId: planned.recipeId, jobKey: planned.key }
1344
+ );
1345
+ }
1346
+ const value = planned.variants[dimension];
1347
+ if (value === void 0) {
1348
+ throw new GuideShotError(
1349
+ "RECIPE_SCHEMA_INVALID",
1350
+ `Dimension "${dimension}" has no resolved value.`,
1351
+ { recipeId: planned.recipeId, jobKey: planned.key }
1352
+ );
1353
+ }
1354
+ patches.push(
1355
+ await definition.resolve(value, {
1356
+ dimension,
1357
+ variants: planned.variants,
1358
+ ...signal === void 0 ? {} : { signal }
1359
+ })
1360
+ );
1361
+ }
1362
+ return mergeBrowserState(...patches);
1363
+ }
1364
+ async function prepareScenario(planned, config, options) {
1365
+ const reference = planned.recipe.scenario;
1366
+ if (reference === void 0) {
1367
+ return void 0;
1368
+ }
1369
+ const definition = config.scenarios?.[reference.use];
1370
+ if (definition === void 0) {
1371
+ throw new GuideShotError(
1372
+ "EXTENSION_NOT_REGISTERED",
1373
+ `Scenario "${reference.use}" is not registered.`,
1374
+ { recipeId: planned.recipeId, jobKey: planned.key }
1375
+ );
1376
+ }
1377
+ const input = interpolate(reference.with ?? {}, {
1378
+ variant: planned.variants
1379
+ });
1380
+ try {
1381
+ return await definition.prepare(
1382
+ {
1383
+ baseUrl: options.baseUrl,
1384
+ recipeId: planned.recipeId,
1385
+ variantKey: planned.variantKey,
1386
+ variants: planned.variants,
1387
+ fetch: options.fetch ?? globalThis.fetch,
1388
+ ...options.signal === void 0 ? {} : { signal: options.signal }
1389
+ },
1390
+ input
1391
+ );
1392
+ } catch (cause) {
1393
+ throw new GuideShotError(
1394
+ "SCENARIO_FAILED",
1395
+ `Scenario "${reference.use}" failed for ${planned.key}.`,
1396
+ {
1397
+ recipeId: planned.recipeId,
1398
+ jobKey: planned.key,
1399
+ cause
1400
+ }
1401
+ );
1402
+ }
1403
+ }
1404
+ function profileBrowserState(planned) {
1405
+ const profile = planned.captureIntent.profileConfig;
1406
+ return {
1407
+ ...profile.locale === void 0 ? {} : { locale: profile.locale },
1408
+ ...profile.timezoneId === void 0 ? {} : { timezoneId: profile.timezoneId },
1409
+ ...profile.colorScheme === void 0 ? {} : { colorScheme: profile.colorScheme },
1410
+ ...profile.reducedMotion === void 0 ? {} : { reducedMotion: profile.reducedMotion }
1411
+ };
1412
+ }
1413
+ function assignDefinedScalar(target, source, key) {
1414
+ const value = source[key];
1415
+ if (value !== void 0) {
1416
+ Object.assign(target, { [key]: value });
1417
+ }
1418
+ }
1419
+ function cookieKey(cookie) {
1420
+ return [
1421
+ cookie.name,
1422
+ cookie.url ?? cookie.domain ?? "",
1423
+ cookie.path ?? "/"
1424
+ ].join("\0");
1425
+ }
1426
+ function compareStrings7(left, right) {
1427
+ return left < right ? -1 : left > right ? 1 : 0;
1428
+ }
1429
+ export {
1430
+ DIAGNOSTIC_CODES,
1431
+ GuideShotError,
1432
+ PublicManifestSchema2 as PublicManifestSchema,
1433
+ RecipeSchema2 as RecipeSchema,
1434
+ SCHEMA_VERSION2 as SCHEMA_VERSION,
1435
+ assertAllowedOrigin,
1436
+ assertUniqueRecipeIds,
1437
+ buildPublicManifest,
1438
+ canonicalSerialize,
1439
+ createAssetPath,
1440
+ createCaptureHash,
1441
+ createCompositionHash,
1442
+ createJobCompositionHash,
1443
+ createVariantKey,
1444
+ defaultProfileName,
1445
+ defineConfig,
1446
+ defineDimension,
1447
+ defineScenario,
1448
+ diagnosticFromUnknown,
1449
+ discoverRecipes,
1450
+ expandMatrix,
1451
+ hashCanonical,
1452
+ interpolate,
1453
+ interpolateString,
1454
+ isGuideShotError,
1455
+ matchesVariantFilter,
1456
+ mergeBrowserState,
1457
+ parseRecipe,
1458
+ planProject,
1459
+ planRecipes,
1460
+ resolveArtifactPath,
1461
+ resolveJob,
1462
+ resolveLocalizedText,
1463
+ resolvePageUrl,
1464
+ resolveRecipeText,
1465
+ resolveSafeProjectPaths,
1466
+ resolvedAlt,
1467
+ resolvedAnnotations,
1468
+ sanitizeFileSegment,
1469
+ sha256,
1470
+ validateConfig,
1471
+ validateManifest,
1472
+ validateRecipe,
1473
+ validateRecipeSemantics
1474
+ };