heroshot 0.17.0 → 0.19.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({
@@ -374,7 +345,6 @@ const configSchema = z.object({
374
345
  hiddenElements: z.record(z.string(), z.array(z.string())).optional().describe("Elements to hide per domain (hostname → CSS selectors)"),
375
346
  locales: z.array(z.string().min(1)).optional().describe("Locale codes (e.g., [\"en\", \"de\"]).")
376
347
  });
377
-
378
348
  //#endregion
379
349
  //#region src/config.ts
380
350
  /**
@@ -385,7 +355,6 @@ const configSchema = z.object({
385
355
  function parseConfig(input) {
386
356
  return configSchema.parse(input);
387
357
  }
388
-
389
358
  //#endregion
390
359
  //#region src/configFile.ts
391
360
  const HEROSHOT_DIRECTORY_NAME = ".heroshot";
@@ -426,7 +395,6 @@ function saveConfig(configPath, config) {
426
395
  if (!existsSync(parentDirectory)) mkdirSync(parentDirectory, { recursive: true });
427
396
  writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
428
397
  }
429
-
430
398
  //#endregion
431
399
  //#region src/session.ts
432
400
  const SESSION_FILENAME = "session.enc";
@@ -434,7 +402,6 @@ const KEYS_DIRECTORY = path.join(homedir(), ".heroshot", "keys");
434
402
  const ALGORITHM = "aes-256-gcm";
435
403
  const KEY_LENGTH = 32;
436
404
  const IV_LENGTH = 16;
437
- const AUTH_TAG_LENGTH = 16;
438
405
  const SALT_LENGTH = 16;
439
406
  const SESSION_KEY_LENGTH = 20;
440
407
  /**
@@ -473,9 +440,9 @@ function encrypt(data, sessionKey) {
473
440
  */
474
441
  function decrypt(encryptedData, sessionKey) {
475
442
  const salt = encryptedData.subarray(0, SALT_LENGTH);
476
- const iv = encryptedData.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
477
- const authTag = encryptedData.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
478
- 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);
479
446
  const decipher = createDecipheriv(ALGORITHM, deriveKey(sessionKey, salt), iv);
480
447
  decipher.setAuthTag(authTag);
481
448
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
@@ -559,7 +526,6 @@ function getSessionKey(cliKey, directory = process.cwd()) {
559
526
  if (environmentKey) return environmentKey;
560
527
  return loadLocalKey(directory);
561
528
  }
562
-
563
529
  //#endregion
564
530
  //#region src/browser/constants.ts
565
531
  /** Default viewport dimensions */
@@ -581,7 +547,6 @@ function findPackageRoot(startDirectory) {
581
547
  }
582
548
  /** Path to editor directory */
583
549
  const EDITOR_DIR = path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), "editor");
584
-
585
550
  //#endregion
586
551
  //#region src/browser/browserDetect.ts
587
552
  /**
@@ -661,7 +626,6 @@ function detectSystemBrowsers() {
661
626
  }
662
627
  return detected;
663
628
  }
664
-
665
629
  //#endregion
666
630
  //#region src/browser/noBrowserError.ts
667
631
  /**
@@ -683,7 +647,6 @@ function noBrowserError() {
683
647
  ].join("\n");
684
648
  return new Error(message);
685
649
  }
686
-
687
650
  //#endregion
688
651
  //#region src/browser/launchBrowser.ts
689
652
  /**
@@ -733,6 +696,7 @@ async function launchBrowser(options = {}) {
733
696
  ...options.colorScheme && { colorScheme: options.colorScheme },
734
697
  ...options.reducedMotion && { reducedMotion: options.reducedMotion },
735
698
  ...options.userAgent && { userAgent: options.userAgent },
699
+ ...options.ignoreHTTPSErrors && { ignoreHTTPSErrors: true },
736
700
  ...options.locale && { locale: options.locale },
737
701
  ...options.locale && { extraHTTPHeaders: { "Accept-Language": options.locale } }
738
702
  });
@@ -741,7 +705,6 @@ async function launchBrowser(options = {}) {
741
705
  context
742
706
  };
743
707
  }
744
-
745
708
  //#endregion
746
709
  //#region src/utils/getColorSchemes.ts
747
710
  /**
@@ -755,7 +718,6 @@ function getColorSchemes(setting) {
755
718
  if (setting === "dark") return ["dark"];
756
719
  return ["light", "dark"];
757
720
  }
758
-
759
721
  //#endregion
760
722
  //#region src/sync/configHelpers.ts
761
723
  /**
@@ -814,10 +776,10 @@ function buildBrowserOptions(config) {
814
776
  deviceScaleFactor: config.browser?.deviceScaleFactor,
815
777
  bypassCSP: config.browser?.bypassCSP,
816
778
  reducedMotion: config.browser?.reducedMotion,
817
- userAgent: config.browser?.userAgent
779
+ userAgent: config.browser?.userAgent,
780
+ ignoreHTTPSErrors: config.browser?.ignoreHTTPSErrors
818
781
  };
819
782
  }
820
-
821
783
  //#endregion
822
784
  //#region src/utils/localeUrl.ts
823
785
  /**
@@ -834,7 +796,6 @@ function buildBrowserOptions(config) {
834
796
  function applyLocale(url, locale) {
835
797
  return url.replaceAll("{locale}", locale);
836
798
  }
837
-
838
799
  //#endregion
839
800
  //#region src/utils/parseViewport.ts
840
801
  /**
@@ -868,7 +829,6 @@ function parseViewport(variant) {
868
829
  ...VIEWPORT_PRESETS.desktop
869
830
  };
870
831
  }
871
-
872
832
  //#endregion
873
833
  //#region src/utils/screenshotPath.ts
874
834
  /**
@@ -893,7 +853,6 @@ function generateScreenshotFilename(options) {
893
853
  const pathWithDirectory = directory ? `${directory}/${filename}` : filename;
894
854
  return locale ? `${locale}/${pathWithDirectory}` : pathWithDirectory;
895
855
  }
896
-
897
856
  //#endregion
898
857
  //#region src/sync/actions/click.ts
899
858
  /** MCP: locator.click(options) or locator.dblclick(options) */
@@ -908,7 +867,6 @@ async function executeClick(page, action) {
908
867
  await (action.doubleClick ? locator.dblclick(options) : locator.click(options));
909
868
  } else await (action.doubleClick ? locator.dblclick() : locator.click());
910
869
  }
911
-
912
870
  //#endregion
913
871
  //#region src/sync/actions/drag.ts
914
872
  /** MCP: startLocator.dragTo(endLocator) */
@@ -918,7 +876,6 @@ async function executeDrag(page, action) {
918
876
  const to = page.locator(action.to);
919
877
  await (action.timeout ? from.dragTo(to, { timeout: action.timeout }) : from.dragTo(to));
920
878
  }
921
-
922
879
  //#endregion
923
880
  //#region src/sync/actions/evaluate.ts
924
881
  /**
@@ -933,7 +890,6 @@ async function executeEvaluate(page, action) {
933
890
  await page.evaluate(`(${action.function})(document.querySelector('${escapedSelector}'))`);
934
891
  } else await page.evaluate(`(${action.function})()`);
935
892
  }
936
-
937
893
  //#endregion
938
894
  //#region src/sync/actions/fileUpload.ts
939
895
  /** MCP: locator.setInputFiles(paths) */
@@ -941,7 +897,6 @@ async function executeFileUpload(page, action) {
941
897
  if (action.type !== "file_upload") return;
942
898
  await page.locator(action.selector).setInputFiles(action.paths);
943
899
  }
944
-
945
900
  //#endregion
946
901
  //#region src/sync/actions/fillForm.ts
947
902
  /** MCP: locator.fill / setChecked / check / selectOption per field type */
@@ -967,7 +922,6 @@ async function executeFillForm(page, action) {
967
922
  }[field.fieldType]?.();
968
923
  }
969
924
  }
970
-
971
925
  //#endregion
972
926
  //#region src/sync/actions/handleDialog.ts
973
927
  /** MCP: Sets up a one-time dialog handler for the next dialog */
@@ -978,7 +932,6 @@ function executeHandleDialog(page, action) {
978
932
  await (accept ? dialog.accept(promptText) : dialog.dismiss());
979
933
  });
980
934
  }
981
-
982
935
  //#endregion
983
936
  //#region src/sync/actions/hide.ts
984
937
  /** Hide elements by setting visibility: hidden (preserves layout) */
@@ -988,7 +941,6 @@ async function executeHide(page, action) {
988
941
  for (const element of elements) element.style.setProperty("visibility", "hidden", "important");
989
942
  });
990
943
  }
991
-
992
944
  //#endregion
993
945
  //#region src/sync/actions/hover.ts
994
946
  /** MCP: locator.hover() */
@@ -997,7 +949,6 @@ async function executeHover(page, action) {
997
949
  const locator = page.locator(action.selector);
998
950
  await (action.timeout ? locator.hover({ timeout: action.timeout }) : locator.hover());
999
951
  }
1000
-
1001
952
  //#endregion
1002
953
  //#region src/sync/actions/navigate.ts
1003
954
  /** MCP: page.goto(url) or page.goBack() */
@@ -1006,7 +957,6 @@ async function executeNavigate(page, action) {
1006
957
  if (action.back) await page.goBack();
1007
958
  else if (action.url) await page.goto(action.url, { waitUntil: "domcontentloaded" });
1008
959
  }
1009
-
1010
960
  //#endregion
1011
961
  //#region src/sync/actions/pressKey.ts
1012
962
  /** MCP: page.keyboard.press(key) */
@@ -1014,7 +964,6 @@ async function executePressKey(page, action) {
1014
964
  if (action.type !== "press_key") return;
1015
965
  await page.keyboard.press(action.key);
1016
966
  }
1017
-
1018
967
  //#endregion
1019
968
  //#region src/sync/actions/resize.ts
1020
969
  /** MCP: page.setViewportSize({ width, height }) */
@@ -1025,7 +974,6 @@ async function executeResize(page, action) {
1025
974
  height: action.height
1026
975
  });
1027
976
  }
1028
-
1029
977
  //#endregion
1030
978
  //#region src/sync/actions/selectOption.ts
1031
979
  /** MCP: locator.selectOption(values) */
@@ -1034,7 +982,6 @@ async function executeSelectOption(page, action) {
1034
982
  const locator = page.locator(action.selector);
1035
983
  await (action.timeout ? locator.selectOption(action.values, { timeout: action.timeout }) : locator.selectOption(action.values));
1036
984
  }
1037
-
1038
985
  //#endregion
1039
986
  //#region src/sync/actions/type.ts
1040
987
  /** MCP: locator.fill(text) or locator.pressSequentially(text) */
@@ -1047,7 +994,6 @@ async function executeType(page, action) {
1047
994
  } else await (action.slowly ? locator.pressSequentially(action.text) : locator.fill(action.text));
1048
995
  if (action.submit) await page.keyboard.press("Enter");
1049
996
  }
1050
-
1051
997
  //#endregion
1052
998
  //#region src/sync/actions/wait.ts
1053
999
  /** MCP: waitForTimeout / getByText().waitFor() */
@@ -1057,7 +1003,6 @@ async function executeWait(page, action) {
1057
1003
  if (action.text) await page.getByText(action.text).first().waitFor({ state: "visible" });
1058
1004
  if (action.textGone) await page.getByText(action.textGone).first().waitFor({ state: "hidden" });
1059
1005
  }
1060
-
1061
1006
  //#endregion
1062
1007
  //#region src/sync/actions/index.ts
1063
1008
  /** Dispatch map: action type -> handler function */
@@ -1087,7 +1032,6 @@ async function executeActions(page, actions) {
1087
1032
  await actionHandlers[action.type](page, action);
1088
1033
  }
1089
1034
  }
1090
-
1091
1035
  //#endregion
1092
1036
  //#region src/sync/annotationOverlay.ts
1093
1037
  const OVERLAY_ID$1 = "heroshot-annotation-overlay";
@@ -1268,7 +1212,6 @@ async function removeAnnotationOverlay(page) {
1268
1212
  if (existing) existing.remove();
1269
1213
  })()`);
1270
1214
  }
1271
-
1272
1215
  //#endregion
1273
1216
  //#region src/sync/borderOverlay.ts
1274
1217
  const OVERLAY_ID = "heroshot-border-overlay";
@@ -1310,7 +1253,6 @@ async function removeBorderOverlay(page) {
1310
1253
  if (el) el.remove();
1311
1254
  })()`);
1312
1255
  }
1313
-
1314
1256
  //#endregion
1315
1257
  //#region src/sync/borderRadiusMask.ts
1316
1258
  /**
@@ -1391,7 +1333,6 @@ async function removeBorderRadiusMask(page) {
1391
1333
  }
1392
1334
  })()`);
1393
1335
  }
1394
-
1395
1336
  //#endregion
1396
1337
  //#region src/sync/elementFinder.ts
1397
1338
  /**
@@ -1421,7 +1362,6 @@ async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
1421
1362
  }
1422
1363
  return null;
1423
1364
  }
1424
-
1425
1365
  //#endregion
1426
1366
  //#region src/sync/paddingMask.ts
1427
1367
  const MASK_ID = "heroshot-padding-mask";
@@ -1486,7 +1426,6 @@ async function removePaddingMask(page) {
1486
1426
  if (existing) existing.remove();
1487
1427
  })()`);
1488
1428
  }
1489
-
1490
1429
  //#endregion
1491
1430
  //#region src/sync/browserFunctions.ts
1492
1431
  /**
@@ -1506,7 +1445,6 @@ async function removePaddingMask(page) {
1506
1445
  function applyColorScheme(isDark) {
1507
1446
  document.documentElement.classList.toggle("dark", isDark);
1508
1447
  }
1509
-
1510
1448
  //#endregion
1511
1449
  //#region src/sync/pageScripts.ts
1512
1450
  /**
@@ -1606,7 +1544,6 @@ async function getElementBackgroundColor(page, selector) {
1606
1544
  async function applyColorSchemeClass(page, colorScheme) {
1607
1545
  await page.evaluate(applyColorScheme, colorScheme === "dark");
1608
1546
  }
1609
-
1610
1547
  //#endregion
1611
1548
  //#region src/sync/screenshot.ts
1612
1549
  /**
@@ -1650,7 +1587,6 @@ async function takeScreenshot(options) {
1650
1587
  omitBackground
1651
1588
  });
1652
1589
  }
1653
-
1654
1590
  //#endregion
1655
1591
  //#region src/sync/elementCapture.ts
1656
1592
  /**
@@ -1762,7 +1698,6 @@ async function captureElementScreenshot(options) {
1762
1698
  if (elementFill === "solid" || elementFill === "transparent") await restoreElementBackground(page, selector);
1763
1699
  return { success: true };
1764
1700
  }
1765
-
1766
1701
  //#endregion
1767
1702
  //#region src/sync/results.ts
1768
1703
  /**
@@ -1812,7 +1747,6 @@ function showResults(results, outputDirectory, staleFiles, deletedFiles) {
1812
1747
  deletedFiles: deletedFiles.length > 0 ? deletedFiles : void 0
1813
1748
  };
1814
1749
  }
1815
-
1816
1750
  //#endregion
1817
1751
  //#region src/sync/capture.ts
1818
1752
  /**
@@ -1967,7 +1901,6 @@ async function captureAndLog(page, screenshot, outputDirectory, captureOptions,
1967
1901
  error: result.error
1968
1902
  };
1969
1903
  }
1970
-
1971
1904
  //#endregion
1972
1905
  //#region src/sync/parallelCapture.ts
1973
1906
  /**
@@ -2026,6 +1959,7 @@ async function executeBatch(jobs, outputDirectory, captureOptions, browserOption
2026
1959
  bypassCSP: browserOptions.bypassCSP,
2027
1960
  reducedMotion: browserOptions.reducedMotion,
2028
1961
  userAgent: browserOptions.userAgent,
1962
+ ignoreHTTPSErrors: browserOptions.ignoreHTTPSErrors,
2029
1963
  locale: jobs[0]?.locale
2030
1964
  });
2031
1965
  const page = await context.newPage();
@@ -2103,10 +2037,12 @@ async function captureParallel(options) {
2103
2037
  for (const settled of settledResults) if (settled.status === "fulfilled") allResults.push(...settled.value);
2104
2038
  return allResults;
2105
2039
  }
2106
-
2107
2040
  //#endregion
2108
2041
  //#region src/sync/schemeCapture.ts
2109
2042
  /**
2043
+ * Color scheme + locale screenshot capture orchestration.
2044
+ */
2045
+ /**
2110
2046
  * Capture screenshots for a single color scheme + locale combination.
2111
2047
  * Launches a browser, captures all screenshots, and closes the browser.
2112
2048
  *
@@ -2126,6 +2062,7 @@ async function captureWithScheme(options) {
2126
2062
  bypassCSP: browserOptions.bypassCSP,
2127
2063
  reducedMotion: browserOptions.reducedMotion,
2128
2064
  userAgent: browserOptions.userAgent,
2065
+ ignoreHTTPSErrors: browserOptions.ignoreHTTPSErrors,
2129
2066
  locale
2130
2067
  });
2131
2068
  const page = await context.newPage();
@@ -2177,7 +2114,6 @@ async function captureWithScheme(options) {
2177
2114
  await browser.close();
2178
2115
  return results;
2179
2116
  }
2180
-
2181
2117
  //#endregion
2182
2118
  //#region src/sync/sessionLoader.ts
2183
2119
  /**
@@ -2194,7 +2130,6 @@ function loadEncryptedSession(sessionKeyOption) {
2194
2130
  }
2195
2131
  verbose("Failed to decrypt session - using fresh browser");
2196
2132
  }
2197
-
2198
2133
  //#endregion
2199
2134
  //#region src/sync/files.ts
2200
2135
  /**
@@ -2232,7 +2167,6 @@ function deleteStaleFiles(outputDirectory, staleFiles) {
2232
2167
  function findStaleFiles(existingFiles, writtenFiles) {
2233
2168
  return existingFiles.filter((file) => !writtenFiles.has(file));
2234
2169
  }
2235
-
2236
2170
  //#endregion
2237
2171
  //#region src/sync/staleFiles.ts
2238
2172
  /**
@@ -2255,7 +2189,6 @@ function handleStaleFiles(outputDirectory, results, options) {
2255
2189
  deleted
2256
2190
  };
2257
2191
  }
2258
-
2259
2192
  //#endregion
2260
2193
  //#region src/sync/sync.ts
2261
2194
  /**
@@ -2377,7 +2310,6 @@ async function sync(options = {}) {
2377
2310
  });
2378
2311
  return showResults(results, outputDirectory, staleFiles, deletedFiles);
2379
2312
  }
2380
-
2381
2313
  //#endregion
2382
2314
  //#region src/cli/snippet.ts
2383
2315
  /**
@@ -2464,6 +2396,5 @@ function snippetAction(pattern, options, configPath) {
2464
2396
  log$1("");
2465
2397
  return true;
2466
2398
  }
2467
-
2468
2399
  //#endregion
2469
- 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 };
@@ -24,4 +24,4 @@ To use heroshot in CI, add your session key as a secret:
24
24
 
25
25
  To get your session key, run: `npx heroshot session-key`
26
26
 
27
- Learn more: https://heroshot.sh/docs
27
+ Learn more: https://heroshot.dev/docs