@reshotdev/screenshot 0.0.1-beta.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 +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- package/web/subtitle-editor/index.html +295 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// image-crop.js - High-quality image cropping with industry best practices
|
|
2
|
+
// Uses Sharp for lossless, high-quality image manipulation
|
|
3
|
+
const sharp = require("sharp");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const fs = require("fs-extra");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Crop configuration schema:
|
|
9
|
+
* {
|
|
10
|
+
* enabled: boolean, // Whether cropping is enabled for this scenario/step
|
|
11
|
+
* region: { // Absolute pixel coordinates (required if enabled)
|
|
12
|
+
* x: number, // X offset from top-left
|
|
13
|
+
* y: number, // Y offset from top-left
|
|
14
|
+
* width: number, // Width of crop region
|
|
15
|
+
* height: number // Height of crop region
|
|
16
|
+
* },
|
|
17
|
+
* scaleMode: 'none' | 'fit' | 'fill', // How to handle the cropped result (default: 'none')
|
|
18
|
+
* targetSize?: { // Optional target dimensions for scaling (only if scaleMode !== 'none')
|
|
19
|
+
* width: number,
|
|
20
|
+
* height: number
|
|
21
|
+
* },
|
|
22
|
+
* padding?: { // Optional padding around the crop region
|
|
23
|
+
* top?: number,
|
|
24
|
+
* right?: number,
|
|
25
|
+
* bottom?: number,
|
|
26
|
+
* left?: number
|
|
27
|
+
* },
|
|
28
|
+
* preserveAspectRatio: boolean // Whether to preserve aspect ratio when scaling (default: true)
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate crop configuration
|
|
34
|
+
* @param {Object} cropConfig - The crop configuration to validate
|
|
35
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
36
|
+
*/
|
|
37
|
+
function validateCropConfig(cropConfig) {
|
|
38
|
+
if (!cropConfig) {
|
|
39
|
+
return { valid: true }; // No crop config means cropping is disabled
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!cropConfig.enabled) {
|
|
43
|
+
return { valid: true }; // Explicitly disabled
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!cropConfig.region) {
|
|
47
|
+
return {
|
|
48
|
+
valid: false,
|
|
49
|
+
error: "Crop region is required when cropping is enabled",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { x, y, width, height } = cropConfig.region;
|
|
54
|
+
|
|
55
|
+
if (typeof x !== "number" || x < 0) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: "Crop region.x must be a non-negative number",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof y !== "number" || y < 0) {
|
|
63
|
+
return {
|
|
64
|
+
valid: false,
|
|
65
|
+
error: "Crop region.y must be a non-negative number",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof width !== "number" || width <= 0) {
|
|
70
|
+
return {
|
|
71
|
+
valid: false,
|
|
72
|
+
error: "Crop region.width must be a positive number",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof height !== "number" || height <= 0) {
|
|
77
|
+
return {
|
|
78
|
+
valid: false,
|
|
79
|
+
error: "Crop region.height must be a positive number",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
cropConfig.scaleMode &&
|
|
85
|
+
!["none", "fit", "fill"].includes(cropConfig.scaleMode)
|
|
86
|
+
) {
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
error: "scaleMode must be 'none', 'fit', or 'fill'",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
cropConfig.scaleMode &&
|
|
95
|
+
cropConfig.scaleMode !== "none" &&
|
|
96
|
+
cropConfig.targetSize
|
|
97
|
+
) {
|
|
98
|
+
const { width: tw, height: th } = cropConfig.targetSize;
|
|
99
|
+
if (
|
|
100
|
+
typeof tw !== "number" ||
|
|
101
|
+
tw <= 0 ||
|
|
102
|
+
typeof th !== "number" ||
|
|
103
|
+
th <= 0
|
|
104
|
+
) {
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
error: "targetSize width and height must be positive numbers",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { valid: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Apply padding to a crop region, adjusting for image boundaries
|
|
117
|
+
* @param {Object} region - { x, y, width, height }
|
|
118
|
+
* @param {Object} padding - { top, right, bottom, left }
|
|
119
|
+
* @param {Object} imageDimensions - { width, height }
|
|
120
|
+
* @returns {Object} Adjusted region with padding applied
|
|
121
|
+
*/
|
|
122
|
+
function applyPaddingToRegion(region, padding = {}, imageDimensions) {
|
|
123
|
+
const { top = 0, right = 0, bottom = 0, left = 0 } = padding;
|
|
124
|
+
|
|
125
|
+
// Expand region by padding
|
|
126
|
+
let newX = Math.max(0, region.x - left);
|
|
127
|
+
let newY = Math.max(0, region.y - top);
|
|
128
|
+
let newWidth = region.width + left + right;
|
|
129
|
+
let newHeight = region.height + top + bottom;
|
|
130
|
+
|
|
131
|
+
// Adjust for image boundaries
|
|
132
|
+
if (imageDimensions) {
|
|
133
|
+
const maxWidth = imageDimensions.width - newX;
|
|
134
|
+
const maxHeight = imageDimensions.height - newY;
|
|
135
|
+
newWidth = Math.min(newWidth, maxWidth);
|
|
136
|
+
newHeight = Math.min(newHeight, maxHeight);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
x: Math.round(newX),
|
|
141
|
+
y: Math.round(newY),
|
|
142
|
+
width: Math.round(newWidth),
|
|
143
|
+
height: Math.round(newHeight),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Scale a crop region by a device pixel ratio
|
|
149
|
+
* This ensures crop coordinates work correctly on high-DPI displays
|
|
150
|
+
* @param {Object} region - { x, y, width, height }
|
|
151
|
+
* @param {number} deviceScaleFactor - The device pixel ratio (e.g., 2 for retina)
|
|
152
|
+
* @returns {Object} Scaled region
|
|
153
|
+
*/
|
|
154
|
+
function scaleRegionByDPR(region, deviceScaleFactor = 1) {
|
|
155
|
+
if (deviceScaleFactor === 1) {
|
|
156
|
+
return region;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
x: Math.round(region.x * deviceScaleFactor),
|
|
161
|
+
y: Math.round(region.y * deviceScaleFactor),
|
|
162
|
+
width: Math.round(region.width * deviceScaleFactor),
|
|
163
|
+
height: Math.round(region.height * deviceScaleFactor),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Crop an image buffer using Sharp with high-quality settings
|
|
169
|
+
* @param {Buffer} imageBuffer - The input image buffer (PNG)
|
|
170
|
+
* @param {Object} cropConfig - The crop configuration
|
|
171
|
+
* @param {Object} options - Additional options
|
|
172
|
+
* @param {number} options.deviceScaleFactor - Device pixel ratio for coordinate scaling
|
|
173
|
+
* @returns {Promise<Buffer>} The cropped image buffer
|
|
174
|
+
*/
|
|
175
|
+
async function cropImageBuffer(imageBuffer, cropConfig, options = {}) {
|
|
176
|
+
const { deviceScaleFactor = 1 } = options;
|
|
177
|
+
|
|
178
|
+
if (!cropConfig || !cropConfig.enabled || !cropConfig.region) {
|
|
179
|
+
return imageBuffer; // Return original if no cropping
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const validation = validateCropConfig(cropConfig);
|
|
183
|
+
if (!validation.valid) {
|
|
184
|
+
throw new Error(`Invalid crop config: ${validation.error}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Get image metadata to validate crop bounds
|
|
188
|
+
const metadata = await sharp(imageBuffer).metadata();
|
|
189
|
+
const imageDimensions = { width: metadata.width, height: metadata.height };
|
|
190
|
+
|
|
191
|
+
// Scale region by device pixel ratio
|
|
192
|
+
let region = scaleRegionByDPR(cropConfig.region, deviceScaleFactor);
|
|
193
|
+
|
|
194
|
+
// Apply padding if specified
|
|
195
|
+
if (cropConfig.padding) {
|
|
196
|
+
const scaledPadding = {
|
|
197
|
+
top: (cropConfig.padding.top || 0) * deviceScaleFactor,
|
|
198
|
+
right: (cropConfig.padding.right || 0) * deviceScaleFactor,
|
|
199
|
+
bottom: (cropConfig.padding.bottom || 0) * deviceScaleFactor,
|
|
200
|
+
left: (cropConfig.padding.left || 0) * deviceScaleFactor,
|
|
201
|
+
};
|
|
202
|
+
region = applyPaddingToRegion(region, scaledPadding, imageDimensions);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Validate that crop region is within image bounds
|
|
206
|
+
if (region.x >= imageDimensions.width || region.y >= imageDimensions.height) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Crop region (${region.x}, ${region.y}) is outside image bounds (${imageDimensions.width}x${imageDimensions.height})`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Clamp region to image bounds (handle edge cases gracefully)
|
|
213
|
+
const clampedWidth = Math.min(region.width, imageDimensions.width - region.x);
|
|
214
|
+
const clampedHeight = Math.min(
|
|
215
|
+
region.height,
|
|
216
|
+
imageDimensions.height - region.y
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (clampedWidth <= 0 || clampedHeight <= 0) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Crop region results in zero-size image after clamping to bounds`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Build Sharp pipeline with high-quality settings
|
|
226
|
+
let pipeline = sharp(imageBuffer, {
|
|
227
|
+
// Disable libvips cache for consistent results
|
|
228
|
+
failOnError: false,
|
|
229
|
+
}).extract({
|
|
230
|
+
left: region.x,
|
|
231
|
+
top: region.y,
|
|
232
|
+
width: clampedWidth,
|
|
233
|
+
height: clampedHeight,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Apply scaling if configured
|
|
237
|
+
if (
|
|
238
|
+
cropConfig.scaleMode &&
|
|
239
|
+
cropConfig.scaleMode !== "none" &&
|
|
240
|
+
cropConfig.targetSize
|
|
241
|
+
) {
|
|
242
|
+
const { width: targetWidth, height: targetHeight } = cropConfig.targetSize;
|
|
243
|
+
const preserveAspectRatio = cropConfig.preserveAspectRatio !== false;
|
|
244
|
+
|
|
245
|
+
if (cropConfig.scaleMode === "fit") {
|
|
246
|
+
pipeline = pipeline.resize(targetWidth, targetHeight, {
|
|
247
|
+
fit: preserveAspectRatio ? "inside" : "fill",
|
|
248
|
+
withoutEnlargement: true, // Don't upscale
|
|
249
|
+
kernel: sharp.kernel.lanczos3, // High-quality downscaling
|
|
250
|
+
});
|
|
251
|
+
} else if (cropConfig.scaleMode === "fill") {
|
|
252
|
+
pipeline = pipeline.resize(targetWidth, targetHeight, {
|
|
253
|
+
fit: preserveAspectRatio ? "cover" : "fill",
|
|
254
|
+
position: "center",
|
|
255
|
+
kernel: sharp.kernel.lanczos3,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Output as PNG with high quality (lossless)
|
|
261
|
+
return pipeline
|
|
262
|
+
.png({
|
|
263
|
+
compressionLevel: 6, // Balanced compression
|
|
264
|
+
adaptiveFiltering: true, // Better compression for photos
|
|
265
|
+
})
|
|
266
|
+
.toBuffer();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Crop an image file and save to the same or different path
|
|
271
|
+
* @param {string} inputPath - Path to the input image
|
|
272
|
+
* @param {string} outputPath - Path for the output image (can be same as input)
|
|
273
|
+
* @param {Object} cropConfig - The crop configuration
|
|
274
|
+
* @param {Object} options - Additional options
|
|
275
|
+
* @returns {Promise<{ success: boolean, originalSize: Object, croppedSize: Object }>}
|
|
276
|
+
*/
|
|
277
|
+
async function cropImageFile(inputPath, outputPath, cropConfig, options = {}) {
|
|
278
|
+
if (!cropConfig || !cropConfig.enabled) {
|
|
279
|
+
return { success: true, skipped: true };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const imageBuffer = await fs.readFile(inputPath);
|
|
283
|
+
const originalMetadata = await sharp(imageBuffer).metadata();
|
|
284
|
+
|
|
285
|
+
const croppedBuffer = await cropImageBuffer(imageBuffer, cropConfig, options);
|
|
286
|
+
const croppedMetadata = await sharp(croppedBuffer).metadata();
|
|
287
|
+
|
|
288
|
+
await fs.writeFile(outputPath, croppedBuffer);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
success: true,
|
|
292
|
+
skipped: false,
|
|
293
|
+
originalSize: {
|
|
294
|
+
width: originalMetadata.width,
|
|
295
|
+
height: originalMetadata.height,
|
|
296
|
+
},
|
|
297
|
+
croppedSize: {
|
|
298
|
+
width: croppedMetadata.width,
|
|
299
|
+
height: croppedMetadata.height,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Create a crop config from a bounding box (e.g., from element.boundingBox())
|
|
306
|
+
* @param {Object} boundingBox - { x, y, width, height }
|
|
307
|
+
* @param {Object} options - Additional options
|
|
308
|
+
* @param {number} options.padding - Uniform padding to add around the box
|
|
309
|
+
* @param {Object} options.customPadding - { top, right, bottom, left }
|
|
310
|
+
* @returns {Object} Crop configuration
|
|
311
|
+
*/
|
|
312
|
+
function createCropConfigFromBoundingBox(boundingBox, options = {}) {
|
|
313
|
+
if (!boundingBox) {
|
|
314
|
+
return { enabled: false };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { padding = 0, customPadding } = options;
|
|
318
|
+
|
|
319
|
+
const cropConfig = {
|
|
320
|
+
enabled: true,
|
|
321
|
+
region: {
|
|
322
|
+
x: Math.round(boundingBox.x),
|
|
323
|
+
y: Math.round(boundingBox.y),
|
|
324
|
+
width: Math.round(boundingBox.width),
|
|
325
|
+
height: Math.round(boundingBox.height),
|
|
326
|
+
},
|
|
327
|
+
scaleMode: "none",
|
|
328
|
+
preserveAspectRatio: true,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
if (customPadding) {
|
|
332
|
+
cropConfig.padding = customPadding;
|
|
333
|
+
} else if (padding > 0) {
|
|
334
|
+
cropConfig.padding = {
|
|
335
|
+
top: padding,
|
|
336
|
+
right: padding,
|
|
337
|
+
bottom: padding,
|
|
338
|
+
left: padding,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return cropConfig;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Merge a base crop config with step-level overrides
|
|
347
|
+
* Step-level config takes precedence over scenario-level config
|
|
348
|
+
* @param {Object} baseCropConfig - Scenario-level crop configuration
|
|
349
|
+
* @param {Object} stepCropConfig - Step-level crop configuration (if any)
|
|
350
|
+
* @returns {Object} Merged crop configuration
|
|
351
|
+
*/
|
|
352
|
+
function mergeCropConfigs(baseCropConfig, stepCropConfig) {
|
|
353
|
+
// If step has its own crop config (even if disabled), use it
|
|
354
|
+
if (stepCropConfig !== undefined) {
|
|
355
|
+
if (stepCropConfig === null || stepCropConfig.enabled === false) {
|
|
356
|
+
return { enabled: false };
|
|
357
|
+
}
|
|
358
|
+
// Merge with base, step takes precedence
|
|
359
|
+
return {
|
|
360
|
+
...baseCropConfig,
|
|
361
|
+
...stepCropConfig,
|
|
362
|
+
region: stepCropConfig.region || baseCropConfig?.region,
|
|
363
|
+
padding:
|
|
364
|
+
stepCropConfig.padding !== undefined
|
|
365
|
+
? stepCropConfig.padding
|
|
366
|
+
: baseCropConfig?.padding,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Use base config
|
|
371
|
+
return baseCropConfig || { enabled: false };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Check if Sharp is available
|
|
376
|
+
* @returns {boolean}
|
|
377
|
+
*/
|
|
378
|
+
function isSharpAvailable() {
|
|
379
|
+
try {
|
|
380
|
+
require("sharp");
|
|
381
|
+
return true;
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
validateCropConfig,
|
|
389
|
+
applyPaddingToRegion,
|
|
390
|
+
scaleRegionByDPR,
|
|
391
|
+
cropImageBuffer,
|
|
392
|
+
cropImageFile,
|
|
393
|
+
createCropConfigFromBoundingBox,
|
|
394
|
+
mergeCropConfigs,
|
|
395
|
+
isSharpAvailable,
|
|
396
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// matrix.js - Matrix expansion and context merging utilities
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Expand matrix definition into all combinations
|
|
5
|
+
* @param {Array<Array<string>>} matrix - Array of axes, each containing context keys
|
|
6
|
+
* @returns {Array<Array<string>>} Array of all combinations
|
|
7
|
+
*/
|
|
8
|
+
function expandMatrix(matrix) {
|
|
9
|
+
if (!matrix || matrix.length === 0) {
|
|
10
|
+
return [[]]; // Single empty variation if no matrix
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Cartesian product of all axes
|
|
14
|
+
const result = [];
|
|
15
|
+
|
|
16
|
+
function generateCombinations(current, depth) {
|
|
17
|
+
if (depth === matrix.length) {
|
|
18
|
+
result.push([...current]);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const item of matrix[depth]) {
|
|
23
|
+
current.push(item);
|
|
24
|
+
generateCombinations(current, depth + 1);
|
|
25
|
+
current.pop();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
generateCombinations([], 0);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Deep merge two objects
|
|
35
|
+
* @param {Object} target - Target object
|
|
36
|
+
* @param {Object} source - Source object
|
|
37
|
+
* @returns {Object} Merged object
|
|
38
|
+
*/
|
|
39
|
+
function deepMerge(target, source) {
|
|
40
|
+
const result = { ...target };
|
|
41
|
+
|
|
42
|
+
for (const key in source) {
|
|
43
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
44
|
+
result[key] = deepMerge(result[key] || {}, source[key]);
|
|
45
|
+
} else {
|
|
46
|
+
result[key] = source[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge contexts based on selected context keys
|
|
55
|
+
* @param {Object} baseContext - Base context object
|
|
56
|
+
* @param {Array<string>} selectedContextKeys - Array of context keys to merge
|
|
57
|
+
* @param {Object} allContexts - Object mapping context keys to context objects
|
|
58
|
+
* @returns {Object} Merged context
|
|
59
|
+
*/
|
|
60
|
+
function mergeContexts(baseContext, selectedContextKeys, allContexts) {
|
|
61
|
+
let result = { ...baseContext };
|
|
62
|
+
|
|
63
|
+
for (const key of selectedContextKeys) {
|
|
64
|
+
if (allContexts[key]) {
|
|
65
|
+
result = deepMerge(result, allContexts[key]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a slug from variation keys
|
|
74
|
+
* @param {Array<string>} variation - Array of context keys
|
|
75
|
+
* @returns {string} Slug for the variation
|
|
76
|
+
*/
|
|
77
|
+
function variationToSlug(variation) {
|
|
78
|
+
if (variation.length === 0) {
|
|
79
|
+
return 'default';
|
|
80
|
+
}
|
|
81
|
+
return variation.join('_');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
expandMatrix,
|
|
86
|
+
mergeContexts,
|
|
87
|
+
variationToSlug
|
|
88
|
+
};
|
|
89
|
+
|