heroshot 0.14.2 → 0.15.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/README.md CHANGED
@@ -30,7 +30,9 @@ npx heroshot
30
30
 
31
31
  First run opens a browser with a visual editor. Pick elements, adjust padding, style borders, edit text, and add annotations (arrows, rectangles, callouts). Screenshots land in `heroshots/`, config saves to `.heroshot/config.json`. Next run regenerates everything headlessly.
32
32
 
33
- https://github.com/user-attachments/assets/1636d404-1e5f-4151-9aba-d5676ed3ff2a
33
+ <p align="center">
34
+ <img src="https://github.com/omachala/heroshot/blob/main/assets/video/demo-v0.14.gif?raw=true" alt="Heroshot demo" width="720">
35
+ </p>
34
36
 
35
37
  ## Use in Your Docs
36
38
 
package/dist/cli/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as intro, D as setVerbose, E as outro, O as spinner, S as error, T as note, _ as loadConfig, a as launchBrowser, c as generateSessionKey, d as loadSession, f as saveLocalKey, g as getConfigPath, h as ensureHeroshotDirectory, k as verbose, l as getSessionPath, m as sessionExists, n as snippetAction, o as DEFAULT_VIEWPORT, p as saveSession, r as sync, s as EDITOR_DIR, u as loadLocalKey, v as saveConfig, w as log, x as generateUid, y as VIEWPORT_PRESETS } from "../snippet-B6Lg_Ant.js";
2
+ import { C as intro, D as setVerbose, E as outro, O as spinner, S as error, T as note, _ as loadConfig, a as launchBrowser, c as generateSessionKey, d as loadSession, f as saveLocalKey, g as getConfigPath, h as ensureHeroshotDirectory, k as verbose, l as getSessionPath, m as sessionExists, n as snippetAction, o as DEFAULT_VIEWPORT, p as saveSession, r as sync, s as EDITOR_DIR, u as loadLocalKey, v as saveConfig, w as log, x as generateUid, y as VIEWPORT_PRESETS } from "../snippet-Fc-PkcTD.js";
3
3
  import { existsSync, readFileSync, rmSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -65,6 +65,151 @@ function dispatchHighlightJob(options) {
65
65
  } }));
66
66
  }
67
67
 
68
+ //#endregion
69
+ //#region src/browser/toConfigScreenshot.ts
70
+ /**
71
+ * Convert ScreenshotData to Screenshot for config.
72
+ * Filename is derived from name at sync time - not stored in config.
73
+ */
74
+ function toConfigScreenshot(data) {
75
+ return {
76
+ id: data.id,
77
+ name: data.name,
78
+ url: data.url,
79
+ selector: data.selector,
80
+ ...data.padding && { padding: {
81
+ top: Math.round(data.padding.top),
82
+ right: Math.round(data.padding.right),
83
+ bottom: Math.round(data.padding.bottom),
84
+ left: Math.round(data.padding.left)
85
+ } },
86
+ ...data.scroll && { scroll: data.scroll },
87
+ ...data.paddingFill && { paddingFill: data.paddingFill },
88
+ ...data.paddingColor && { paddingColor: data.paddingColor },
89
+ ...data.elementFill && { elementFill: data.elementFill },
90
+ ...data.elementColor && { elementColor: data.elementColor },
91
+ ...data.textOverrides && Object.keys(data.textOverrides).length > 0 && { textOverrides: data.textOverrides },
92
+ ...data.annotations && data.annotations.length > 0 && { annotations: data.annotations },
93
+ ...data.borderWidth && { borderWidth: data.borderWidth },
94
+ ...data.borderColor && { borderColor: data.borderColor },
95
+ ...data.borderRadius && { borderRadius: data.borderRadius }
96
+ };
97
+ }
98
+
99
+ //#endregion
100
+ //#region src/browser/saveCurrentConfig.ts
101
+ /**
102
+ * Save current screenshot and browser settings state to config file.
103
+ */
104
+ function saveCurrentConfig(configPath, allScreenshots, browserSettings, hiddenElements) {
105
+ const config = loadConfig(configPath);
106
+ config.screenshots = allScreenshots.map(toConfigScreenshot);
107
+ if (browserSettings) {
108
+ config.browser = {
109
+ ...config.browser,
110
+ viewport: browserSettings.viewport,
111
+ ...browserSettings.colorScheme && { colorScheme: browserSettings.colorScheme },
112
+ ...browserSettings.deviceScaleFactor && { deviceScaleFactor: browserSettings.deviceScaleFactor }
113
+ };
114
+ if (browserSettings.outputDirectory) config.outputDirectory = browserSettings.outputDirectory;
115
+ if (browserSettings.outputFormat) config.outputFormat = browserSettings.outputFormat;
116
+ else delete config.outputFormat;
117
+ if (browserSettings.jpegQuality && browserSettings.outputFormat === "jpeg") config.jpegQuality = browserSettings.jpegQuality;
118
+ if (browserSettings.workers) config.workers = browserSettings.workers;
119
+ else delete config.workers;
120
+ }
121
+ if (hiddenElements && Object.keys(hiddenElements).length > 0) config.hiddenElements = hiddenElements;
122
+ else delete config.hiddenElements;
123
+ saveConfig(configPath, config);
124
+ verbose("Config saved");
125
+ }
126
+
127
+ //#endregion
128
+ //#region src/browser/handleEvent.ts
129
+ function createEventHandler(state, browser, context) {
130
+ const save = () => saveCurrentConfig(state.configPath, state.allScreenshots, state.updatedBrowserSettings, state.hiddenElements);
131
+ return (event) => {
132
+ switch (event.type) {
133
+ case "screenshot-added":
134
+ state.allScreenshots.push(event.data);
135
+ verbose(`Added: ${event.data.name}`);
136
+ save();
137
+ break;
138
+ case "screenshot-updated": {
139
+ const index = state.allScreenshots.findIndex(({ id }) => id === event.data.id);
140
+ if (index !== -1) {
141
+ state.allScreenshots[index] = event.data;
142
+ verbose(`Updated: ${event.data.name}`);
143
+ save();
144
+ }
145
+ break;
146
+ }
147
+ case "screenshot-removed": {
148
+ const index = state.allScreenshots.findIndex(({ id }) => id === event.id);
149
+ if (index !== -1) {
150
+ const [removed] = state.allScreenshots.splice(index, 1);
151
+ verbose(`Removed: ${removed?.name ?? event.id}`);
152
+ save();
153
+ }
154
+ break;
155
+ }
156
+ case "screenshot-selected": {
157
+ const [currentPage] = context.pages();
158
+ if (!currentPage) break;
159
+ state.selectedId = event.id;
160
+ state.sidebarExpanded = true;
161
+ if (currentPage.url() === event.url) {
162
+ state.pendingJob = {
163
+ type: "highlight",
164
+ selector: event.selector,
165
+ screenshotId: event.id
166
+ };
167
+ currentPage.evaluate(dispatchHighlightJob, {
168
+ selector: event.selector,
169
+ screenshotId: event.id
170
+ }).catch(() => {});
171
+ } else {
172
+ state.pendingJob = {
173
+ type: "navigate-and-highlight",
174
+ url: event.url,
175
+ selector: event.selector,
176
+ screenshotId: event.id
177
+ };
178
+ currentPage.goto(event.url, { waitUntil: "domcontentloaded" }).catch(() => {});
179
+ }
180
+ break;
181
+ }
182
+ case "settings-updated":
183
+ state.updatedBrowserSettings = event.data;
184
+ verbose(`Settings updated: ${JSON.stringify(event.data)}`);
185
+ save();
186
+ break;
187
+ case "hidden-elements-updated": {
188
+ const { domain, selectors } = event;
189
+ state.hiddenElements = selectors.length === 0 ? Object.fromEntries(Object.entries(state.hiddenElements).filter(([k]) => k !== domain)) : {
190
+ ...state.hiddenElements,
191
+ [domain]: selectors
192
+ };
193
+ verbose(`Hidden elements updated for ${domain}: ${selectors.length} selectors`);
194
+ save();
195
+ break;
196
+ }
197
+ case "job-complete":
198
+ state.pendingJob = null;
199
+ break;
200
+ case "done":
201
+ (async () => {
202
+ try {
203
+ saveSession(await context.storageState(), state.sessionKey);
204
+ verbose("Session saved");
205
+ } catch {}
206
+ await browser.close();
207
+ })();
208
+ break;
209
+ }
210
+ };
211
+ }
212
+
68
213
  //#endregion
69
214
  //#region src/browser/schema.ts
70
215
  /**
@@ -118,7 +263,11 @@ const browserSettingsSchema = z.object({
118
263
  height: z.number()
119
264
  }),
120
265
  colorScheme: z.enum(["light", "dark"]).optional(),
121
- deviceScaleFactor: z.number().optional()
266
+ deviceScaleFactor: z.number().optional(),
267
+ outputDirectory: z.string().optional(),
268
+ outputFormat: z.enum(["png", "jpeg"]).optional(),
269
+ jpegQuality: z.number().int().min(1).max(100).optional(),
270
+ workers: z.number().int().min(1).optional()
122
271
  });
123
272
  /** Schema for toolbar events (discriminated union) */
124
273
  const toolbarEventSchema = z.discriminatedUnion("type", [
@@ -144,6 +293,11 @@ const toolbarEventSchema = z.discriminatedUnion("type", [
144
293
  type: z.literal("settings-updated"),
145
294
  data: browserSettingsSchema
146
295
  }),
296
+ z.object({
297
+ type: z.literal("hidden-elements-updated"),
298
+ domain: z.string(),
299
+ selectors: z.array(z.string())
300
+ }),
147
301
  z.object({ type: z.literal("job-complete") }),
148
302
  z.object({ type: z.literal("done") })
149
303
  ]);
@@ -202,7 +356,7 @@ async function injectToolbar(page, options) {
202
356
  }
203
357
  }
204
358
  async function doInjectToolbar(page, options) {
205
- const { screenshots, pendingJob, selectedId, sidebarExpanded, onEvent } = options;
359
+ const { screenshots, settings, pendingJob, selectedId, sidebarExpanded, hiddenElements, onEvent } = options;
206
360
  if (!exposedPages.has(page)) {
207
361
  await page.exposeFunction("__heroshotEmit", (eventJson) => {
208
362
  onEvent(toolbarEventSchema.parse(JSON.parse(eventJson)));
@@ -214,14 +368,16 @@ async function doInjectToolbar(page, options) {
214
368
  return;
215
369
  }
216
370
  const scriptContent = readFileSync(`${EDITOR_DIR}/dist/editor.js`, "utf8");
217
- await page.evaluate(`(function(screenshots, pendingJob, selectedId, sidebarExpanded, scriptContent) {
371
+ await page.evaluate(`(function(screenshots, settings, pendingJob, selectedId, sidebarExpanded, hiddenElements, scriptContent) {
218
372
  // Initialize __heroshot global namespace
219
373
  globalThis.__heroshot = {
220
374
  initialized: false,
221
375
  screenshots: screenshots,
376
+ settings: settings,
222
377
  pendingJob: pendingJob,
223
378
  selectedId: selectedId,
224
379
  sidebarExpanded: sidebarExpanded,
380
+ hiddenElements: hiddenElements,
225
381
  // This emit function is why we use string evaluation.
226
382
  // As a nested function property, it would get __name() wrapped
227
383
  // if we used a typed function approach.
@@ -235,56 +391,7 @@ async function doInjectToolbar(page, options) {
235
391
  var script = document.createElement('script');
236
392
  script.textContent = scriptContent;
237
393
  document.body.appendChild(script);
238
- })(${JSON.stringify(screenshots)}, ${JSON.stringify(pendingJob)}, ${JSON.stringify(selectedId)}, ${JSON.stringify(sidebarExpanded)}, ${JSON.stringify(scriptContent)})`);
239
- }
240
-
241
- //#endregion
242
- //#region src/browser/toConfigScreenshot.ts
243
- /**
244
- * Convert ScreenshotData to Screenshot for config.
245
- * Filename is derived from name at sync time - not stored in config.
246
- */
247
- function toConfigScreenshot(data) {
248
- return {
249
- id: data.id,
250
- name: data.name,
251
- url: data.url,
252
- selector: data.selector,
253
- ...data.padding && { padding: {
254
- top: Math.round(data.padding.top),
255
- right: Math.round(data.padding.right),
256
- bottom: Math.round(data.padding.bottom),
257
- left: Math.round(data.padding.left)
258
- } },
259
- ...data.scroll && { scroll: data.scroll },
260
- ...data.paddingFill && { paddingFill: data.paddingFill },
261
- ...data.paddingColor && { paddingColor: data.paddingColor },
262
- ...data.elementFill && { elementFill: data.elementFill },
263
- ...data.elementColor && { elementColor: data.elementColor },
264
- ...data.textOverrides && Object.keys(data.textOverrides).length > 0 && { textOverrides: data.textOverrides },
265
- ...data.annotations && data.annotations.length > 0 && { annotations: data.annotations },
266
- ...data.borderWidth && { borderWidth: data.borderWidth },
267
- ...data.borderColor && { borderColor: data.borderColor },
268
- ...data.borderRadius && { borderRadius: data.borderRadius }
269
- };
270
- }
271
-
272
- //#endregion
273
- //#region src/browser/saveCurrentConfig.ts
274
- /**
275
- * Save current screenshot and browser settings state to config file.
276
- */
277
- function saveCurrentConfig(configPath, allScreenshots, browserSettings) {
278
- const config = loadConfig(configPath);
279
- config.screenshots = allScreenshots.map(toConfigScreenshot);
280
- if (browserSettings) config.browser = {
281
- ...config.browser,
282
- viewport: browserSettings.viewport,
283
- ...browserSettings.colorScheme && { colorScheme: browserSettings.colorScheme },
284
- ...browserSettings.deviceScaleFactor && { deviceScaleFactor: browserSettings.deviceScaleFactor }
285
- };
286
- saveConfig(configPath, config);
287
- verbose("Config saved");
394
+ })(${JSON.stringify(screenshots)}, ${JSON.stringify(settings)}, ${JSON.stringify(pendingJob)}, ${JSON.stringify(selectedId)}, ${JSON.stringify(sidebarExpanded)}, ${JSON.stringify(hiddenElements)}, ${JSON.stringify(scriptContent)})`);
288
395
  }
289
396
 
290
397
  //#endregion
@@ -311,12 +418,24 @@ async function setup(options = {}) {
311
418
  }
312
419
  }
313
420
  const allScreenshots = configToScreenshotData(config.screenshots);
314
- let pendingJob = null;
315
- let selectedId = null;
316
- let sidebarExpanded = false;
317
- let updatedBrowserSettings = null;
318
- const save = () => {
319
- saveCurrentConfig(configPath, allScreenshots, updatedBrowserSettings);
421
+ const initialSettings = {
422
+ viewport,
423
+ ...options.colorScheme && { colorScheme: options.colorScheme },
424
+ ...config.browser?.deviceScaleFactor && { deviceScaleFactor: config.browser.deviceScaleFactor },
425
+ outputDirectory: config.outputDirectory,
426
+ outputFormat: config.outputFormat,
427
+ jpegQuality: config.jpegQuality,
428
+ workers: config.workers
429
+ };
430
+ const browserState = {
431
+ allScreenshots,
432
+ pendingJob: null,
433
+ selectedId: null,
434
+ sidebarExpanded: false,
435
+ updatedBrowserSettings: null,
436
+ hiddenElements: config.hiddenElements ?? {},
437
+ configPath,
438
+ sessionKey
320
439
  };
321
440
  const { browser, context } = await launchBrowser({
322
441
  headless: false,
@@ -325,88 +444,22 @@ async function setup(options = {}) {
325
444
  colorScheme: options.colorScheme
326
445
  });
327
446
  setupSpinner.stop("Browser ready");
328
- const handleEvent = (event) => {
329
- switch (event.type) {
330
- case "screenshot-added":
331
- allScreenshots.push(event.data);
332
- verbose(`Added: ${event.data.name}`);
333
- save();
334
- break;
335
- case "screenshot-updated": {
336
- const index = allScreenshots.findIndex(({ id }) => id === event.data.id);
337
- if (index !== -1) {
338
- allScreenshots[index] = event.data;
339
- verbose(`Updated: ${event.data.name}`);
340
- save();
341
- }
342
- break;
343
- }
344
- case "screenshot-removed": {
345
- const index = allScreenshots.findIndex(({ id }) => id === event.id);
346
- if (index !== -1) {
347
- const [removed] = allScreenshots.splice(index, 1);
348
- verbose(`Removed: ${removed?.name ?? event.id}`);
349
- save();
350
- }
351
- break;
352
- }
353
- case "screenshot-selected": {
354
- const [currentPage] = context.pages();
355
- if (!currentPage) break;
356
- selectedId = event.id;
357
- sidebarExpanded = true;
358
- if (currentPage.url() === event.url) {
359
- pendingJob = {
360
- type: "highlight",
361
- selector: event.selector,
362
- screenshotId: event.id
363
- };
364
- currentPage.evaluate(dispatchHighlightJob, {
365
- selector: event.selector,
366
- screenshotId: event.id
367
- }).catch(() => {});
368
- } else {
369
- pendingJob = {
370
- type: "navigate-and-highlight",
371
- url: event.url,
372
- selector: event.selector,
373
- screenshotId: event.id
374
- };
375
- currentPage.goto(event.url, { waitUntil: "domcontentloaded" }).catch(() => {});
376
- }
377
- break;
378
- }
379
- case "settings-updated":
380
- updatedBrowserSettings = event.data;
381
- verbose(`Settings updated: ${JSON.stringify(event.data)}`);
382
- save();
383
- break;
384
- case "job-complete":
385
- pendingJob = null;
386
- break;
387
- case "done":
388
- (async () => {
389
- try {
390
- saveSession(await context.storageState(), sessionKey);
391
- verbose("Session saved");
392
- } catch {}
393
- await browser.close();
394
- })();
395
- break;
396
- }
397
- };
447
+ const handleEvent = createEventHandler(browserState, browser, context);
448
+ const injectOptions = () => ({
449
+ screenshots: browserState.allScreenshots,
450
+ settings: initialSettings,
451
+ pendingJob: browserState.pendingJob,
452
+ selectedId: browserState.selectedId,
453
+ sidebarExpanded: browserState.sidebarExpanded,
454
+ hiddenElements: browserState.hiddenElements,
455
+ onEvent: handleEvent
456
+ });
398
457
  const setupPage = (page) => {
399
458
  page.on("domcontentloaded", async () => {
400
459
  const url = page.url();
401
460
  if (!url.startsWith("http")) return;
402
461
  try {
403
- await injectToolbar(page, {
404
- screenshots: allScreenshots,
405
- pendingJob,
406
- selectedId,
407
- sidebarExpanded,
408
- onEvent: handleEvent
409
- });
462
+ await injectToolbar(page, injectOptions());
410
463
  verbose(`Toolbar injected on ${url}`);
411
464
  } catch (error) {
412
465
  verbose(`Toolbar injection failed on ${url}: ${error instanceof Error ? error.message : String(error)}`);
@@ -418,22 +471,16 @@ async function setup(options = {}) {
418
471
  if (context.pages().length === 0) browser.close().catch(() => {});
419
472
  });
420
473
  };
421
- context.on("page", (page) => {
474
+ const initPage = (page) => {
422
475
  setupPage(page);
423
476
  handlePageClose(page);
424
- });
477
+ };
478
+ context.on("page", initPage);
425
479
  const page = context.pages()[0] ?? await context.newPage();
426
- setupPage(page);
427
- handlePageClose(page);
480
+ initPage(page);
428
481
  await page.goto("https://heroshot.sh/welcome", { waitUntil: "domcontentloaded" });
429
482
  try {
430
- await injectToolbar(page, {
431
- screenshots: allScreenshots,
432
- pendingJob,
433
- selectedId,
434
- sidebarExpanded,
435
- onEvent: handleEvent
436
- });
483
+ await injectToolbar(page, injectOptions());
437
484
  verbose("Toolbar injected on welcome page");
438
485
  } catch (error) {
439
486
  verbose(`Initial toolbar injection failed: ${error instanceof Error ? error.message : String(error)}`);
package/dist/mcp/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { _ as loadConfig, b as screenshotSchema, g as getConfigPath, i as filterScreenshots, r as sync, t as generateSnippets, v as saveConfig, x as generateUid } from "../snippet-B6Lg_Ant.js";
2
+ import { _ as loadConfig, b as screenshotSchema, g as getConfigPath, i as filterScreenshots, r as sync, t as generateSnippets, v as saveConfig, x as generateUid } from "../snippet-Fc-PkcTD.js";
3
3
  import { z } from "zod";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -190,7 +190,7 @@ const resizeActionSchema = z.object({
190
190
  }).describe("Resize the browser viewport mid-flow.");
191
191
  const hideActionSchema = z.object({
192
192
  type: z.literal("hide"),
193
- selectors: z.array(z.string()).describe("Element selectors to hide (display: none)")
193
+ selectors: z.array(z.string()).describe("Element selectors to hide (visibility: hidden)")
194
194
  }).describe("Hide elements from screenshot. Use to remove cookie banners, chat widgets, ads.");
195
195
  /** Union of all supported action types. Actions execute sequentially before screenshot. */
196
196
  const actionSchema = z.discriminatedUnion("type", [
@@ -370,7 +370,8 @@ const configSchema = z.object({
370
370
  jpegQuality: z.number().int().min(1).max(100).default(80).describe("JPEG compression quality (1-100), only used when outputFormat is \"jpeg\""),
371
371
  browser: browserSchema.optional().describe("Default browser settings applied to all screenshots"),
372
372
  workers: z.number().int().min(1).optional().describe("Number of parallel capture workers (default: 1)"),
373
- screenshots: z.array(screenshotSchema).default([]).describe("Screenshot definitions")
373
+ 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)")
374
375
  });
375
376
 
376
377
  //#endregion
@@ -793,7 +794,8 @@ function buildCaptureOptions(config, viewportOnly) {
793
794
  return {
794
795
  format: config.outputFormat ?? "png",
795
796
  quality: config.jpegQuality,
796
- fullPage: !viewportOnly
797
+ fullPage: !viewportOnly,
798
+ hiddenElements: config.hiddenElements
797
799
  };
798
800
  }
799
801
  /**
@@ -957,11 +959,11 @@ function executeHandleDialog(page, action) {
957
959
 
958
960
  //#endregion
959
961
  //#region src/sync/actions/hide.ts
960
- /** Hide elements by setting display: none */
962
+ /** Hide elements by setting visibility: hidden (preserves layout) */
961
963
  async function executeHide(page, action) {
962
964
  if (action.type !== "hide") return;
963
965
  for (const selector of action.selectors) await page.locator(selector).evaluateAll((elements) => {
964
- for (const element of elements) element.style.display = "none";
966
+ for (const element of elements) element.style.setProperty("visibility", "hidden", "important");
965
967
  });
966
968
  }
967
969
 
@@ -1861,6 +1863,14 @@ async function captureScreenshot(page, screenshot, outputDirectory, captureOptio
1861
1863
  ...navResult,
1862
1864
  filename
1863
1865
  };
1866
+ if (captureOptions.hiddenElements) {
1867
+ const { hiddenElements: hiddenByDomain } = captureOptions;
1868
+ const { hostname } = new URL(url);
1869
+ if (hiddenByDomain[hostname]?.length) await executeHide(page, {
1870
+ type: "hide",
1871
+ selectors: hiddenByDomain[hostname]
1872
+ });
1873
+ }
1864
1874
  if (screenshot.actions && screenshot.actions.length > 0) try {
1865
1875
  await executeActions(page, screenshot.actions);
1866
1876
  await page.waitForTimeout(500);