heroshot 0.16.0 → 0.18.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.
@@ -9,7 +9,6 @@ import { homedir, platform } from "node:os";
9
9
  import { chromium } from "playwright";
10
10
  import { execSync } from "node:child_process";
11
11
  import pLimit from "p-limit";
12
-
13
12
  //#region src/ui.ts
14
13
  /**
15
14
  * Terminal UI using @clack/prompts
@@ -83,7 +82,6 @@ function spinner$1() {
83
82
  function log$1(text) {
84
83
  console.log(text);
85
84
  }
86
-
87
85
  //#endregion
88
86
  //#region src/actionSchema.ts
89
87
  /**
@@ -211,7 +209,6 @@ const actionSchema = z.discriminatedUnion("type", [
211
209
  ]);
212
210
  /** Array of actions to execute sequentially before screenshot capture */
213
211
  const actionsSchema = z.array(actionSchema).describe("Ordered list of actions to execute before capturing. Actions run sequentially.");
214
-
215
212
  //#endregion
216
213
  //#region src/utils/generateUid.ts
217
214
  /**
@@ -221,7 +218,6 @@ const actionsSchema = z.array(actionSchema).describe("Ordered list of actions to
221
218
  function generateUid() {
222
219
  return crypto.randomUUID().slice(0, 8);
223
220
  }
224
-
225
221
  //#endregion
226
222
  //#region src/schema.ts
227
223
  /**
@@ -335,33 +331,8 @@ const browserSchema = z.object({
335
331
  deviceScaleFactor: z.number().min(1).max(3).optional().describe("Device pixel ratio (1 = standard, 2 = retina, 3 = ultra-high DPI)"),
336
332
  bypassCSP: z.boolean().optional().describe("Bypass Content-Security-Policy restrictions. Enabled by default for reliable page.evaluate() calls"),
337
333
  reducedMotion: z.enum(["reduce", "no-preference"]).optional().describe("Emulate prefers-reduced-motion media feature. Use \"reduce\" to disable animations"),
338
- userAgent: z.string().optional().describe("Custom user agent string for the browser")
339
- });
340
- /** Shared CLI options for URL capture */
341
- const shotCliOptionsSchema = z.object({
342
- selector: z.array(z.string()).optional(),
343
- output: z.string().optional(),
344
- padding: z.number().int().min(0).optional(),
345
- width: z.number().int().positive().optional(),
346
- height: z.number().int().positive().optional(),
347
- mobile: z.boolean().optional(),
348
- tablet: z.boolean().optional(),
349
- desktop: z.boolean().optional(),
350
- dark: z.boolean().optional(),
351
- light: z.boolean().optional(),
352
- scale: z.number().min(1).max(3).optional(),
353
- retina: z.boolean().optional(),
354
- quality: z.number().int().min(1).max(100).optional(),
355
- viewportOnly: z.boolean().optional(),
356
- reducedMotion: z.boolean().optional(),
357
- userAgent: z.string().optional()
358
- });
359
- /** CLI command options for URL capture (includes --save and --clean flags) */
360
- const shotCommandOptionsSchema = shotCliOptionsSchema.extend({
361
- save: z.boolean().optional(),
362
- clean: z.boolean().optional(),
363
- workers: z.number().int().min(1).optional(),
364
- headed: z.boolean().optional()
334
+ userAgent: z.string().optional().describe("Custom user agent string for the browser"),
335
+ ignoreHTTPSErrors: z.boolean().optional().describe("Ignore TLS certificate errors (self-signed certs, custom CA). Dev-only setting")
365
336
  });
366
337
  /** Global config */
367
338
  const configSchema = z.object({
@@ -371,9 +342,9 @@ const configSchema = z.object({
371
342
  browser: browserSchema.optional().describe("Default browser settings applied to all screenshots"),
372
343
  workers: z.number().int().min(1).optional().describe("Number of parallel capture workers (default: 1)"),
373
344
  screenshots: z.array(screenshotSchema).default([]).describe("Screenshot definitions"),
374
- hiddenElements: z.record(z.string(), z.array(z.string())).optional().describe("Elements to hide per domain (hostname → CSS selectors)")
345
+ hiddenElements: z.record(z.string(), z.array(z.string())).optional().describe("Elements to hide per domain (hostname → CSS selectors)"),
346
+ locales: z.array(z.string().min(1)).optional().describe("Locale codes (e.g., [\"en\", \"de\"]).")
375
347
  });
376
-
377
348
  //#endregion
378
349
  //#region src/config.ts
379
350
  /**
@@ -384,7 +355,6 @@ const configSchema = z.object({
384
355
  function parseConfig(input) {
385
356
  return configSchema.parse(input);
386
357
  }
387
-
388
358
  //#endregion
389
359
  //#region src/configFile.ts
390
360
  const HEROSHOT_DIRECTORY_NAME = ".heroshot";
@@ -425,7 +395,6 @@ function saveConfig(configPath, config) {
425
395
  if (!existsSync(parentDirectory)) mkdirSync(parentDirectory, { recursive: true });
426
396
  writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
427
397
  }
428
-
429
398
  //#endregion
430
399
  //#region src/session.ts
431
400
  const SESSION_FILENAME = "session.enc";
@@ -433,7 +402,6 @@ const KEYS_DIRECTORY = path.join(homedir(), ".heroshot", "keys");
433
402
  const ALGORITHM = "aes-256-gcm";
434
403
  const KEY_LENGTH = 32;
435
404
  const IV_LENGTH = 16;
436
- const AUTH_TAG_LENGTH = 16;
437
405
  const SALT_LENGTH = 16;
438
406
  const SESSION_KEY_LENGTH = 20;
439
407
  /**
@@ -472,9 +440,9 @@ function encrypt(data, sessionKey) {
472
440
  */
473
441
  function decrypt(encryptedData, sessionKey) {
474
442
  const salt = encryptedData.subarray(0, SALT_LENGTH);
475
- const iv = encryptedData.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
476
- const authTag = encryptedData.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
477
- const ciphertext = encryptedData.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
443
+ const iv = encryptedData.subarray(SALT_LENGTH, 32);
444
+ const authTag = encryptedData.subarray(32, 48);
445
+ const ciphertext = encryptedData.subarray(48);
478
446
  const decipher = createDecipheriv(ALGORITHM, deriveKey(sessionKey, salt), iv);
479
447
  decipher.setAuthTag(authTag);
480
448
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
@@ -558,7 +526,6 @@ function getSessionKey(cliKey, directory = process.cwd()) {
558
526
  if (environmentKey) return environmentKey;
559
527
  return loadLocalKey(directory);
560
528
  }
561
-
562
529
  //#endregion
563
530
  //#region src/browser/constants.ts
564
531
  /** Default viewport dimensions */
@@ -580,7 +547,6 @@ function findPackageRoot(startDirectory) {
580
547
  }
581
548
  /** Path to editor directory */
582
549
  const EDITOR_DIR = path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), "editor");
583
-
584
550
  //#endregion
585
551
  //#region src/browser/browserDetect.ts
586
552
  /**
@@ -660,7 +626,6 @@ function detectSystemBrowsers() {
660
626
  }
661
627
  return detected;
662
628
  }
663
-
664
629
  //#endregion
665
630
  //#region src/browser/noBrowserError.ts
666
631
  /**
@@ -682,7 +647,6 @@ function noBrowserError() {
682
647
  ].join("\n");
683
648
  return new Error(message);
684
649
  }
685
-
686
650
  //#endregion
687
651
  //#region src/browser/launchBrowser.ts
688
652
  /**
@@ -731,14 +695,16 @@ async function launchBrowser(options = {}) {
731
695
  ...options.storageState && { storageState: options.storageState },
732
696
  ...options.colorScheme && { colorScheme: options.colorScheme },
733
697
  ...options.reducedMotion && { reducedMotion: options.reducedMotion },
734
- ...options.userAgent && { userAgent: options.userAgent }
698
+ ...options.userAgent && { userAgent: options.userAgent },
699
+ ...options.ignoreHTTPSErrors && { ignoreHTTPSErrors: true },
700
+ ...options.locale && { locale: options.locale },
701
+ ...options.locale && { extraHTTPHeaders: { "Accept-Language": options.locale } }
735
702
  });
736
703
  return {
737
704
  browser,
738
705
  context
739
706
  };
740
707
  }
741
-
742
708
  //#endregion
743
709
  //#region src/utils/getColorSchemes.ts
744
710
  /**
@@ -752,7 +718,6 @@ function getColorSchemes(setting) {
752
718
  if (setting === "dark") return ["dark"];
753
719
  return ["light", "dark"];
754
720
  }
755
-
756
721
  //#endregion
757
722
  //#region src/sync/configHelpers.ts
758
723
  /**
@@ -778,12 +743,13 @@ function resolveOutputDirectory(configPath, configOutputDirectory, override) {
778
743
  /**
779
744
  * Calculate total number of captures needed.
780
745
  */
781
- function calculateTotalCaptures(screenshots, schemeCount) {
746
+ function calculateTotalCaptures(screenshots, schemeCount, localeCount = 1) {
782
747
  let total = 0;
783
748
  const adjustedSchemeCount = Math.max(1, schemeCount);
749
+ const adjustedLocaleCount = Math.max(1, localeCount);
784
750
  for (const screenshot of screenshots) {
785
751
  const viewportCount = screenshot.viewports?.length ?? 1;
786
- total += viewportCount * adjustedSchemeCount;
752
+ total += viewportCount * adjustedSchemeCount * adjustedLocaleCount;
787
753
  }
788
754
  return total;
789
755
  }
@@ -810,10 +776,26 @@ function buildBrowserOptions(config) {
810
776
  deviceScaleFactor: config.browser?.deviceScaleFactor,
811
777
  bypassCSP: config.browser?.bypassCSP,
812
778
  reducedMotion: config.browser?.reducedMotion,
813
- userAgent: config.browser?.userAgent
779
+ userAgent: config.browser?.userAgent,
780
+ ignoreHTTPSErrors: config.browser?.ignoreHTTPSErrors
814
781
  };
815
782
  }
816
-
783
+ //#endregion
784
+ //#region src/utils/localeUrl.ts
785
+ /**
786
+ * Apply locale to a URL by replacing the {locale} placeholder.
787
+ * If the URL contains no placeholder, it is returned unchanged.
788
+ *
789
+ * @example
790
+ * applyLocale('http://localhost:5173/{locale}/about', 'de')
791
+ * // → 'http://localhost:5173/de/about'
792
+ *
793
+ * applyLocale('http://localhost:5173/about', 'de')
794
+ * // → 'http://localhost:5173/about'
795
+ */
796
+ function applyLocale(url, locale) {
797
+ return url.replaceAll("{locale}", locale);
798
+ }
817
799
  //#endregion
818
800
  //#region src/utils/parseViewport.ts
819
801
  /**
@@ -847,7 +829,6 @@ function parseViewport(variant) {
847
829
  ...VIEWPORT_PRESETS.desktop
848
830
  };
849
831
  }
850
-
851
832
  //#endregion
852
833
  //#region src/utils/screenshotPath.ts
853
834
  /**
@@ -861,7 +842,7 @@ function slugifySegment(text) {
861
842
  * Supports subdirectory paths via forward slashes in the name (e.g., "registry/login-01").
862
843
  */
863
844
  function generateScreenshotFilename(options) {
864
- const { name, viewport, colorScheme, format = "png" } = options;
845
+ const { name, viewport, colorScheme, locale, format = "png" } = options;
865
846
  const segments = name.split("/").map(slugifySegment).filter(Boolean);
866
847
  const directory = segments.length > 1 ? segments.slice(0, -1).join("/") : "";
867
848
  const parts = [segments.at(-1) ?? ""];
@@ -869,9 +850,9 @@ function generateScreenshotFilename(options) {
869
850
  if (colorScheme) parts.push(colorScheme);
870
851
  const extension = format === "jpeg" ? "jpg" : "png";
871
852
  const filename = `${parts.join("-")}.${extension}`;
872
- return directory ? `${directory}/${filename}` : filename;
853
+ const pathWithDirectory = directory ? `${directory}/${filename}` : filename;
854
+ return locale ? `${locale}/${pathWithDirectory}` : pathWithDirectory;
873
855
  }
874
-
875
856
  //#endregion
876
857
  //#region src/sync/actions/click.ts
877
858
  /** MCP: locator.click(options) or locator.dblclick(options) */
@@ -886,7 +867,6 @@ async function executeClick(page, action) {
886
867
  await (action.doubleClick ? locator.dblclick(options) : locator.click(options));
887
868
  } else await (action.doubleClick ? locator.dblclick() : locator.click());
888
869
  }
889
-
890
870
  //#endregion
891
871
  //#region src/sync/actions/drag.ts
892
872
  /** MCP: startLocator.dragTo(endLocator) */
@@ -896,7 +876,6 @@ async function executeDrag(page, action) {
896
876
  const to = page.locator(action.to);
897
877
  await (action.timeout ? from.dragTo(to, { timeout: action.timeout }) : from.dragTo(to));
898
878
  }
899
-
900
879
  //#endregion
901
880
  //#region src/sync/actions/evaluate.ts
902
881
  /**
@@ -911,7 +890,6 @@ async function executeEvaluate(page, action) {
911
890
  await page.evaluate(`(${action.function})(document.querySelector('${escapedSelector}'))`);
912
891
  } else await page.evaluate(`(${action.function})()`);
913
892
  }
914
-
915
893
  //#endregion
916
894
  //#region src/sync/actions/fileUpload.ts
917
895
  /** MCP: locator.setInputFiles(paths) */
@@ -919,7 +897,6 @@ async function executeFileUpload(page, action) {
919
897
  if (action.type !== "file_upload") return;
920
898
  await page.locator(action.selector).setInputFiles(action.paths);
921
899
  }
922
-
923
900
  //#endregion
924
901
  //#region src/sync/actions/fillForm.ts
925
902
  /** MCP: locator.fill / setChecked / check / selectOption per field type */
@@ -945,7 +922,6 @@ async function executeFillForm(page, action) {
945
922
  }[field.fieldType]?.();
946
923
  }
947
924
  }
948
-
949
925
  //#endregion
950
926
  //#region src/sync/actions/handleDialog.ts
951
927
  /** MCP: Sets up a one-time dialog handler for the next dialog */
@@ -956,7 +932,6 @@ function executeHandleDialog(page, action) {
956
932
  await (accept ? dialog.accept(promptText) : dialog.dismiss());
957
933
  });
958
934
  }
959
-
960
935
  //#endregion
961
936
  //#region src/sync/actions/hide.ts
962
937
  /** Hide elements by setting visibility: hidden (preserves layout) */
@@ -966,7 +941,6 @@ async function executeHide(page, action) {
966
941
  for (const element of elements) element.style.setProperty("visibility", "hidden", "important");
967
942
  });
968
943
  }
969
-
970
944
  //#endregion
971
945
  //#region src/sync/actions/hover.ts
972
946
  /** MCP: locator.hover() */
@@ -975,7 +949,6 @@ async function executeHover(page, action) {
975
949
  const locator = page.locator(action.selector);
976
950
  await (action.timeout ? locator.hover({ timeout: action.timeout }) : locator.hover());
977
951
  }
978
-
979
952
  //#endregion
980
953
  //#region src/sync/actions/navigate.ts
981
954
  /** MCP: page.goto(url) or page.goBack() */
@@ -984,7 +957,6 @@ async function executeNavigate(page, action) {
984
957
  if (action.back) await page.goBack();
985
958
  else if (action.url) await page.goto(action.url, { waitUntil: "domcontentloaded" });
986
959
  }
987
-
988
960
  //#endregion
989
961
  //#region src/sync/actions/pressKey.ts
990
962
  /** MCP: page.keyboard.press(key) */
@@ -992,7 +964,6 @@ async function executePressKey(page, action) {
992
964
  if (action.type !== "press_key") return;
993
965
  await page.keyboard.press(action.key);
994
966
  }
995
-
996
967
  //#endregion
997
968
  //#region src/sync/actions/resize.ts
998
969
  /** MCP: page.setViewportSize({ width, height }) */
@@ -1003,7 +974,6 @@ async function executeResize(page, action) {
1003
974
  height: action.height
1004
975
  });
1005
976
  }
1006
-
1007
977
  //#endregion
1008
978
  //#region src/sync/actions/selectOption.ts
1009
979
  /** MCP: locator.selectOption(values) */
@@ -1012,7 +982,6 @@ async function executeSelectOption(page, action) {
1012
982
  const locator = page.locator(action.selector);
1013
983
  await (action.timeout ? locator.selectOption(action.values, { timeout: action.timeout }) : locator.selectOption(action.values));
1014
984
  }
1015
-
1016
985
  //#endregion
1017
986
  //#region src/sync/actions/type.ts
1018
987
  /** MCP: locator.fill(text) or locator.pressSequentially(text) */
@@ -1025,7 +994,6 @@ async function executeType(page, action) {
1025
994
  } else await (action.slowly ? locator.pressSequentially(action.text) : locator.fill(action.text));
1026
995
  if (action.submit) await page.keyboard.press("Enter");
1027
996
  }
1028
-
1029
997
  //#endregion
1030
998
  //#region src/sync/actions/wait.ts
1031
999
  /** MCP: waitForTimeout / getByText().waitFor() */
@@ -1035,7 +1003,6 @@ async function executeWait(page, action) {
1035
1003
  if (action.text) await page.getByText(action.text).first().waitFor({ state: "visible" });
1036
1004
  if (action.textGone) await page.getByText(action.textGone).first().waitFor({ state: "hidden" });
1037
1005
  }
1038
-
1039
1006
  //#endregion
1040
1007
  //#region src/sync/actions/index.ts
1041
1008
  /** Dispatch map: action type -> handler function */
@@ -1065,7 +1032,6 @@ async function executeActions(page, actions) {
1065
1032
  await actionHandlers[action.type](page, action);
1066
1033
  }
1067
1034
  }
1068
-
1069
1035
  //#endregion
1070
1036
  //#region src/sync/annotationOverlay.ts
1071
1037
  const OVERLAY_ID$1 = "heroshot-annotation-overlay";
@@ -1246,7 +1212,6 @@ async function removeAnnotationOverlay(page) {
1246
1212
  if (existing) existing.remove();
1247
1213
  })()`);
1248
1214
  }
1249
-
1250
1215
  //#endregion
1251
1216
  //#region src/sync/borderOverlay.ts
1252
1217
  const OVERLAY_ID = "heroshot-border-overlay";
@@ -1288,7 +1253,6 @@ async function removeBorderOverlay(page) {
1288
1253
  if (el) el.remove();
1289
1254
  })()`);
1290
1255
  }
1291
-
1292
1256
  //#endregion
1293
1257
  //#region src/sync/borderRadiusMask.ts
1294
1258
  /**
@@ -1369,7 +1333,6 @@ async function removeBorderRadiusMask(page) {
1369
1333
  }
1370
1334
  })()`);
1371
1335
  }
1372
-
1373
1336
  //#endregion
1374
1337
  //#region src/sync/elementFinder.ts
1375
1338
  /**
@@ -1399,7 +1362,6 @@ async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
1399
1362
  }
1400
1363
  return null;
1401
1364
  }
1402
-
1403
1365
  //#endregion
1404
1366
  //#region src/sync/paddingMask.ts
1405
1367
  const MASK_ID = "heroshot-padding-mask";
@@ -1464,7 +1426,6 @@ async function removePaddingMask(page) {
1464
1426
  if (existing) existing.remove();
1465
1427
  })()`);
1466
1428
  }
1467
-
1468
1429
  //#endregion
1469
1430
  //#region src/sync/browserFunctions.ts
1470
1431
  /**
@@ -1484,7 +1445,6 @@ async function removePaddingMask(page) {
1484
1445
  function applyColorScheme(isDark) {
1485
1446
  document.documentElement.classList.toggle("dark", isDark);
1486
1447
  }
1487
-
1488
1448
  //#endregion
1489
1449
  //#region src/sync/pageScripts.ts
1490
1450
  /**
@@ -1584,7 +1544,6 @@ async function getElementBackgroundColor(page, selector) {
1584
1544
  async function applyColorSchemeClass(page, colorScheme) {
1585
1545
  await page.evaluate(applyColorScheme, colorScheme === "dark");
1586
1546
  }
1587
-
1588
1547
  //#endregion
1589
1548
  //#region src/sync/screenshot.ts
1590
1549
  /**
@@ -1628,7 +1587,6 @@ async function takeScreenshot(options) {
1628
1587
  omitBackground
1629
1588
  });
1630
1589
  }
1631
-
1632
1590
  //#endregion
1633
1591
  //#region src/sync/elementCapture.ts
1634
1592
  /**
@@ -1740,7 +1698,6 @@ async function captureElementScreenshot(options) {
1740
1698
  if (elementFill === "solid" || elementFill === "transparent") await restoreElementBackground(page, selector);
1741
1699
  return { success: true };
1742
1700
  }
1743
-
1744
1701
  //#endregion
1745
1702
  //#region src/sync/results.ts
1746
1703
  /**
@@ -1749,8 +1706,12 @@ async function captureElementScreenshot(options) {
1749
1706
  /**
1750
1707
  * Build a variant ID suffix.
1751
1708
  */
1752
- function buildVariantSuffix(viewportName, colorScheme) {
1753
- return [viewportName, colorScheme].filter(Boolean).join("-");
1709
+ function buildVariantSuffix(viewportName, colorScheme, locale) {
1710
+ return [
1711
+ locale,
1712
+ viewportName,
1713
+ colorScheme
1714
+ ].filter(Boolean).join("-");
1754
1715
  }
1755
1716
  /**
1756
1717
  * Show capture results and return summary.
@@ -1786,7 +1747,6 @@ function showResults(results, outputDirectory, staleFiles, deletedFiles) {
1786
1747
  deletedFiles: deletedFiles.length > 0 ? deletedFiles : void 0
1787
1748
  };
1788
1749
  }
1789
-
1790
1750
  //#endregion
1791
1751
  //#region src/sync/capture.ts
1792
1752
  /**
@@ -1854,18 +1814,20 @@ async function captureScreenshot(page, screenshot, outputDirectory, captureOptio
1854
1814
  name,
1855
1815
  viewport: variant.viewportName,
1856
1816
  colorScheme: variant.colorScheme,
1817
+ locale: variant.locale,
1857
1818
  format
1858
1819
  });
1859
- const suffix = buildVariantSuffix(variant.viewportName, variant.colorScheme);
1820
+ const suffix = buildVariantSuffix(variant.viewportName, variant.colorScheme, variant.locale);
1860
1821
  verbose(`Capturing: ${name}${suffix ? ` (${suffix})` : ""}`);
1861
- const navResult = await navigateAndPrepare(page, url, variant.colorScheme);
1822
+ const effectiveUrl = variant.localeUrl ?? url;
1823
+ const navResult = await navigateAndPrepare(page, effectiveUrl, variant.colorScheme);
1862
1824
  if (!navResult.success) return {
1863
1825
  ...navResult,
1864
1826
  filename
1865
1827
  };
1866
1828
  if (captureOptions.hiddenElements) {
1867
1829
  const { hiddenElements: hiddenByDomain } = captureOptions;
1868
- const { hostname } = new URL(url);
1830
+ const { hostname } = new URL(effectiveUrl);
1869
1831
  if (hiddenByDomain[hostname]?.length) await executeHide(page, {
1870
1832
  type: "hide",
1871
1833
  selectors: hiddenByDomain[hostname]
@@ -1928,7 +1890,7 @@ async function captureAndLog(page, screenshot, outputDirectory, captureOptions,
1928
1890
  await page.waitForTimeout(delay);
1929
1891
  }
1930
1892
  }
1931
- const suffix = buildVariantSuffix(variant.viewportName, variant.colorScheme);
1893
+ const suffix = buildVariantSuffix(variant.viewportName, variant.colorScheme, variant.locale);
1932
1894
  const displayName = suffix ? `${screenshot.name} (${suffix})` : screenshot.name;
1933
1895
  const idSuffix = suffix ? `-${suffix}` : "";
1934
1896
  return {
@@ -1939,38 +1901,47 @@ async function captureAndLog(page, screenshot, outputDirectory, captureOptions,
1939
1901
  error: result.error
1940
1902
  };
1941
1903
  }
1942
-
1943
1904
  //#endregion
1944
1905
  //#region src/sync/parallelCapture.ts
1945
1906
  /**
1946
1907
  * Parallel screenshot capture with multiple workers.
1947
1908
  */
1948
1909
  /**
1949
- * Build capture jobs from screenshots and schemes.
1910
+ * Build capture jobs from screenshots, schemes, and locales.
1950
1911
  * Each job represents a single screenshot capture task.
1951
1912
  */
1952
- function buildCaptureJobs(screenshots, schemes) {
1913
+ function buildCaptureJobs(screenshots, schemes, locales = []) {
1953
1914
  const jobs = [];
1954
1915
  const hasMultipleSchemes = schemes.length > 1;
1955
1916
  const schemesToCapture = schemes.length === 0 ? [void 0] : schemes;
1917
+ const localesToCapture = locales.length === 0 ? [void 0] : locales;
1956
1918
  for (const screenshot of screenshots) {
1957
1919
  const viewportVariants = screenshot.viewports ?? [];
1958
1920
  const hasMultipleViewports = viewportVariants.length > 1;
1959
- for (const scheme of schemesToCapture) if (viewportVariants.length === 0) jobs.push({
1960
- screenshot,
1961
- colorScheme: scheme,
1962
- hasMultipleSchemes,
1963
- hasMultipleViewports: false
1964
- });
1965
- else for (const viewportVariant of viewportVariants) {
1966
- const parsedViewport = parseViewport(viewportVariant);
1967
- jobs.push({
1921
+ for (const locale of localesToCapture) {
1922
+ const localeUrl = locale && screenshot.url.includes("{locale}") ? applyLocale(screenshot.url, locale) : void 0;
1923
+ for (const scheme of schemesToCapture) if (viewportVariants.length === 0) jobs.push({
1968
1924
  screenshot,
1969
1925
  colorScheme: scheme,
1970
- viewport: parsedViewport,
1971
1926
  hasMultipleSchemes,
1972
- hasMultipleViewports
1927
+ hasMultipleViewports: false,
1928
+ locale,
1929
+ locales,
1930
+ localeUrl
1973
1931
  });
1932
+ else for (const viewportVariant of viewportVariants) {
1933
+ const parsedViewport = parseViewport(viewportVariant);
1934
+ jobs.push({
1935
+ screenshot,
1936
+ colorScheme: scheme,
1937
+ viewport: parsedViewport,
1938
+ hasMultipleSchemes,
1939
+ hasMultipleViewports,
1940
+ locale,
1941
+ locales,
1942
+ localeUrl
1943
+ });
1944
+ }
1974
1945
  }
1975
1946
  }
1976
1947
  return jobs;
@@ -1987,12 +1958,14 @@ async function executeBatch(jobs, outputDirectory, captureOptions, browserOption
1987
1958
  storageState: browserOptions.storageState,
1988
1959
  bypassCSP: browserOptions.bypassCSP,
1989
1960
  reducedMotion: browserOptions.reducedMotion,
1990
- userAgent: browserOptions.userAgent
1961
+ userAgent: browserOptions.userAgent,
1962
+ ignoreHTTPSErrors: browserOptions.ignoreHTTPSErrors,
1963
+ locale: jobs[0]?.locale
1991
1964
  });
1992
1965
  const page = await context.newPage();
1993
1966
  try {
1994
1967
  for (const job of jobs) {
1995
- const { screenshot, colorScheme, viewport, hasMultipleSchemes, hasMultipleViewports } = job;
1968
+ const { screenshot, colorScheme, viewport, hasMultipleSchemes, hasMultipleViewports, locale, locales } = job;
1996
1969
  if (viewport) await page.setViewportSize({
1997
1970
  width: viewport.width,
1998
1971
  height: viewport.height
@@ -2000,7 +1973,9 @@ async function executeBatch(jobs, outputDirectory, captureOptions, browserOption
2000
1973
  if (colorScheme) await page.emulateMedia({ colorScheme });
2001
1974
  const result = await captureAndLog(page, screenshot, outputDirectory, captureOptions, {
2002
1975
  viewportName: hasMultipleViewports ? viewport?.name : void 0,
2003
- colorScheme: hasMultipleSchemes ? colorScheme : void 0
1976
+ colorScheme: hasMultipleSchemes ? colorScheme : void 0,
1977
+ locale: (locales ?? []).length > 1 ? locale : void 0,
1978
+ localeUrl: job.localeUrl
2004
1979
  });
2005
1980
  results.push(result);
2006
1981
  onProgress(result);
@@ -2011,16 +1986,17 @@ async function executeBatch(jobs, outputDirectory, captureOptions, browserOption
2011
1986
  return results;
2012
1987
  }
2013
1988
  /**
2014
- * Group jobs by URL to minimize page navigations.
2015
- * Jobs for the same URL are kept together.
1989
+ * Group jobs by effective URL + locale to minimize page navigations.
1990
+ * Jobs for the same URL AND same locale are kept together.
1991
+ * Locale is a browser context-level setting, so different locales must be separate groups.
2016
1992
  */
2017
1993
  function groupJobsByUrl(jobs) {
2018
1994
  const groups = /* @__PURE__ */ new Map();
2019
1995
  for (const job of jobs) {
2020
- const { url } = job.screenshot;
2021
- const group = groups.get(url) ?? [];
1996
+ const key = `${job.localeUrl ?? job.screenshot.url}::${job.locale ?? ""}`;
1997
+ const group = groups.get(key) ?? [];
2022
1998
  group.push(job);
2023
- groups.set(url, group);
1999
+ groups.set(key, group);
2024
2000
  }
2025
2001
  return groups;
2026
2002
  }
@@ -2061,15 +2037,21 @@ async function captureParallel(options) {
2061
2037
  for (const settled of settledResults) if (settled.status === "fulfilled") allResults.push(...settled.value);
2062
2038
  return allResults;
2063
2039
  }
2064
-
2065
2040
  //#endregion
2066
2041
  //#region src/sync/schemeCapture.ts
2067
2042
  /**
2068
- * Capture screenshots for a single color scheme.
2043
+ * Color scheme + locale screenshot capture orchestration.
2044
+ */
2045
+ /**
2046
+ * Capture screenshots for a single color scheme + locale combination.
2069
2047
  * Launches a browser, captures all screenshots, and closes the browser.
2048
+ *
2049
+ * Locale is a browser context-level setting in Playwright, so each locale
2050
+ * gets its own browser launch. This ensures Accept-Language and JS locale APIs
2051
+ * (Intl, navigator.language) reflect the correct locale for all pages captured.
2070
2052
  */
2071
2053
  async function captureWithScheme(options) {
2072
- const { screenshots, outputDirectory, captureOptions, browserOptions, colorScheme, schemes, captureSpinner, progress } = options;
2054
+ const { screenshots, outputDirectory, captureOptions, browserOptions, colorScheme, schemes, locale, locales, captureSpinner, progress } = options;
2073
2055
  const results = [];
2074
2056
  const { browser, context } = await launchBrowser({
2075
2057
  headless: !browserOptions.headed,
@@ -2079,22 +2061,33 @@ async function captureWithScheme(options) {
2079
2061
  colorScheme,
2080
2062
  bypassCSP: browserOptions.bypassCSP,
2081
2063
  reducedMotion: browserOptions.reducedMotion,
2082
- userAgent: browserOptions.userAgent
2064
+ userAgent: browserOptions.userAgent,
2065
+ ignoreHTTPSErrors: browserOptions.ignoreHTTPSErrors,
2066
+ locale
2083
2067
  });
2084
2068
  const page = await context.newPage();
2085
2069
  if (colorScheme) await page.emulateMedia({ colorScheme });
2086
2070
  const hasMultipleSchemes = schemes.length > 1;
2071
+ const hasMultipleLocales = locales.length > 1;
2087
2072
  for (const screenshot of screenshots) {
2088
2073
  const viewportVariants = screenshot.viewports ?? [];
2089
2074
  const hasMultipleViewports = viewportVariants.length > 1;
2075
+ const localeUrl = locale && screenshot.url.includes("{locale}") ? applyLocale(screenshot.url, locale) : void 0;
2090
2076
  if (viewportVariants.length === 0) {
2091
2077
  progress.captured++;
2092
- const variant = { colorScheme: hasMultipleSchemes ? colorScheme : void 0 };
2093
- const suffix = variant.colorScheme ? ` (${variant.colorScheme})` : "";
2094
- captureSpinner.message(`Capturing ${progress.captured}/${progress.total}: ${screenshot.name}${suffix}`);
2078
+ const variant = {
2079
+ colorScheme: hasMultipleSchemes ? colorScheme : void 0,
2080
+ locale: hasMultipleLocales ? locale : void 0,
2081
+ localeUrl
2082
+ };
2083
+ const suffix = [variant.locale, variant.colorScheme].filter(Boolean).join(", ");
2084
+ const suffixDisplay = suffix ? ` (${suffix})` : "";
2085
+ captureSpinner.message(`Capturing ${progress.captured}/${progress.total}: ${screenshot.name}${suffixDisplay}`);
2095
2086
  const result = await captureAndLog(page, screenshot, outputDirectory, captureOptions, variant);
2096
2087
  results.push(result);
2097
- } else for (const viewportVariant of viewportVariants) {
2088
+ continue;
2089
+ }
2090
+ for (const viewportVariant of viewportVariants) {
2098
2091
  const parsedViewport = parseViewport(viewportVariant);
2099
2092
  await page.setViewportSize({
2100
2093
  width: parsedViewport.width,
@@ -2103,9 +2096,15 @@ async function captureWithScheme(options) {
2103
2096
  progress.captured++;
2104
2097
  const variant = {
2105
2098
  viewportName: hasMultipleViewports ? parsedViewport.name : void 0,
2106
- colorScheme: hasMultipleSchemes ? colorScheme : void 0
2099
+ colorScheme: hasMultipleSchemes ? colorScheme : void 0,
2100
+ locale: hasMultipleLocales ? locale : void 0,
2101
+ localeUrl
2107
2102
  };
2108
- const suffix = [variant.viewportName, variant.colorScheme].filter(Boolean).join(", ");
2103
+ const suffix = [
2104
+ variant.locale,
2105
+ variant.viewportName,
2106
+ variant.colorScheme
2107
+ ].filter(Boolean).join(", ");
2109
2108
  const suffixDisplay = suffix ? ` (${suffix})` : "";
2110
2109
  captureSpinner.message(`Capturing ${progress.captured}/${progress.total}: ${screenshot.name}${suffixDisplay}`);
2111
2110
  const result = await captureAndLog(page, screenshot, outputDirectory, captureOptions, variant);
@@ -2115,7 +2114,6 @@ async function captureWithScheme(options) {
2115
2114
  await browser.close();
2116
2115
  return results;
2117
2116
  }
2118
-
2119
2117
  //#endregion
2120
2118
  //#region src/sync/sessionLoader.ts
2121
2119
  /**
@@ -2132,7 +2130,6 @@ function loadEncryptedSession(sessionKeyOption) {
2132
2130
  }
2133
2131
  verbose("Failed to decrypt session - using fresh browser");
2134
2132
  }
2135
-
2136
2133
  //#endregion
2137
2134
  //#region src/sync/files.ts
2138
2135
  /**
@@ -2170,7 +2167,6 @@ function deleteStaleFiles(outputDirectory, staleFiles) {
2170
2167
  function findStaleFiles(existingFiles, writtenFiles) {
2171
2168
  return existingFiles.filter((file) => !writtenFiles.has(file));
2172
2169
  }
2173
-
2174
2170
  //#endregion
2175
2171
  //#region src/sync/staleFiles.ts
2176
2172
  /**
@@ -2193,7 +2189,6 @@ function handleStaleFiles(outputDirectory, results, options) {
2193
2189
  deleted
2194
2190
  };
2195
2191
  }
2196
-
2197
2192
  //#endregion
2198
2193
  //#region src/sync/sync.ts
2199
2194
  /**
@@ -2204,9 +2199,9 @@ function handleStaleFiles(outputDirectory, results, options) {
2204
2199
  * Execute screenshot capture (parallel or sequential based on workers).
2205
2200
  */
2206
2201
  async function executeCapture(context) {
2207
- const { screenshots, outputDirectory, captureOptions, browserOptions, schemes, workers, captureSpinner, progress } = context;
2202
+ const { screenshots, outputDirectory, captureOptions, browserOptions, schemes, locales, workers, captureSpinner, progress } = context;
2208
2203
  if (workers > 1) return captureParallel({
2209
- jobs: buildCaptureJobs(screenshots, schemes),
2204
+ jobs: buildCaptureJobs(screenshots, schemes, locales),
2210
2205
  outputDirectory,
2211
2206
  captureOptions,
2212
2207
  browserOptions,
@@ -2216,7 +2211,8 @@ async function executeCapture(context) {
2216
2211
  });
2217
2212
  const results = [];
2218
2213
  const schemesToCapture = schemes.length === 0 ? [void 0] : schemes;
2219
- for (const colorScheme of schemesToCapture) {
2214
+ const localesToCapture = locales.length === 0 ? [void 0] : locales;
2215
+ for (const locale of localesToCapture) for (const colorScheme of schemesToCapture) {
2220
2216
  const schemeResults = await captureWithScheme({
2221
2217
  screenshots,
2222
2218
  outputDirectory,
@@ -2224,6 +2220,8 @@ async function executeCapture(context) {
2224
2220
  browserOptions,
2225
2221
  colorScheme,
2226
2222
  schemes,
2223
+ locale,
2224
+ locales,
2227
2225
  captureSpinner,
2228
2226
  progress
2229
2227
  });
@@ -2265,8 +2263,9 @@ async function sync(options = {}) {
2265
2263
  const outputDirectory = resolveOutputDirectory(configPath, config.outputDirectory, options.outputDirectory);
2266
2264
  const storageState = loadEncryptedSession(options.sessionKey);
2267
2265
  const schemes = getColorSchemes(config.browser?.colorScheme);
2266
+ const locales = config.locales ?? [];
2268
2267
  const captureOptions = buildCaptureOptions(config, options.viewportOnly);
2269
- const totalToCapture = calculateTotalCaptures(screenshots, schemes.length);
2268
+ const totalToCapture = calculateTotalCaptures(screenshots, schemes.length, Math.max(1, locales.length));
2270
2269
  const browserOptions = {
2271
2270
  ...buildBrowserOptions(config),
2272
2271
  storageState,
@@ -2284,6 +2283,7 @@ async function sync(options = {}) {
2284
2283
  captureOptions,
2285
2284
  browserOptions,
2286
2285
  schemes,
2286
+ locales,
2287
2287
  workers,
2288
2288
  captureSpinner,
2289
2289
  progress: {
@@ -2310,7 +2310,6 @@ async function sync(options = {}) {
2310
2310
  });
2311
2311
  return showResults(results, outputDirectory, staleFiles, deletedFiles);
2312
2312
  }
2313
-
2314
2313
  //#endregion
2315
2314
  //#region src/cli/snippet.ts
2316
2315
  /**
@@ -2397,6 +2396,5 @@ function snippetAction(pattern, options, configPath) {
2397
2396
  log$1("");
2398
2397
  return true;
2399
2398
  }
2400
-
2401
2399
  //#endregion
2402
- export { intro$1 as C, setVerbose as D, outro$1 as E, spinner$1 as O, error as S, note$1 as T, loadConfig as _, launchBrowser as a, screenshotSchema as b, generateSessionKey as c, loadSession as d, saveLocalKey as f, getConfigPath as g, ensureHeroshotDirectory as h, filterScreenshots as i, verbose as k, getSessionPath as l, sessionExists as m, snippetAction as n, DEFAULT_VIEWPORT as o, saveSession as p, sync as r, EDITOR_DIR as s, generateSnippets as t, loadLocalKey as u, saveConfig as v, log$1 as w, generateUid as x, VIEWPORT_PRESETS as y };
2400
+ export { intro$1 as C, setVerbose as D, outro$1 as E, spinner$1 as O, error as S, note$1 as T, loadConfig as _, launchBrowser as a, screenshotSchema as b, generateSessionKey as c, loadSession as d, saveLocalKey as f, getConfigPath as g, ensureHeroshotDirectory as h, filterScreenshots as i, verbose as k, getSessionPath as l, sessionExists as m, snippetAction as n, DEFAULT_VIEWPORT as o, saveSession as p, sync as r, EDITOR_DIR as s, generateSnippets as t, loadLocalKey as u, saveConfig as v, log$1 as w, generateUid as x, VIEWPORT_PRESETS as y };