heroshot 0.11.4 → 0.12.1
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 +20 -16
- package/dist/cli/cli.js +5 -3
- package/dist/mcp/index.js +1 -1
- package/dist/{snippet-B1n5AWKH.js → snippet-3pBmpUTB.js} +134 -177
- package/editor/dist/editor.js +860 -77
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -28,22 +28,7 @@ npx heroshot
|
|
|
28
28
|
|
|
29
29
|
First run opens a browser with a visual picker. Click what you want, name it, done. Screenshots land in `heroshots/`, config saves to `.heroshot/config.json`. Next run regenerates everything headlessly.
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
<tr>
|
|
33
|
-
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-light.png?raw=true" alt="Desktop Light"></td>
|
|
34
|
-
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-dark.png?raw=true" alt="Desktop Dark"></td>
|
|
35
|
-
</tr>
|
|
36
|
-
<tr>
|
|
37
|
-
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-light.png?raw=true" alt="Tablet Light"></td>
|
|
38
|
-
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-dark.png?raw=true" alt="Tablet Dark"></td>
|
|
39
|
-
</tr>
|
|
40
|
-
<tr>
|
|
41
|
-
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-light.png?raw=true" alt="Mobile Light"></td>
|
|
42
|
-
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-dark.png?raw=true" alt="Mobile Dark"></td>
|
|
43
|
-
</tr>
|
|
44
|
-
</table>
|
|
45
|
-
|
|
46
|
-
<p align="center"><em>6 screenshots from one config entry - always in sync with the live site.</em></p>
|
|
31
|
+
https://github.com/user-attachments/assets/f35600a6-9220-4bd2-a8c6-a6b4ee8a33d9
|
|
47
32
|
|
|
48
33
|
## Use in Your Docs
|
|
49
34
|
|
|
@@ -90,6 +75,25 @@ plugins:
|
|
|
90
75
|
|
|
91
76
|
One component/macro, all variants - light/dark mode switches automatically, responsive sizes via srcset.
|
|
92
77
|
|
|
78
|
+
## One Screenshot - All Variants
|
|
79
|
+
|
|
80
|
+
<table align="center">
|
|
81
|
+
<tr>
|
|
82
|
+
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-light.png?raw=true" alt="Desktop Light"></td>
|
|
83
|
+
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-dark.png?raw=true" alt="Desktop Dark"></td>
|
|
84
|
+
</tr>
|
|
85
|
+
<tr>
|
|
86
|
+
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-light.png?raw=true" alt="Tablet Light"></td>
|
|
87
|
+
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-dark.png?raw=true" alt="Tablet Dark"></td>
|
|
88
|
+
</tr>
|
|
89
|
+
<tr>
|
|
90
|
+
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-light.png?raw=true" alt="Mobile Light"></td>
|
|
91
|
+
<td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-dark.png?raw=true" alt="Mobile Dark"></td>
|
|
92
|
+
</tr>
|
|
93
|
+
</table>
|
|
94
|
+
|
|
95
|
+
<p align="center"><em>6 screenshots from one config entry - always in sync with the live site.</em></p>
|
|
96
|
+
|
|
93
97
|
## Learn More
|
|
94
98
|
|
|
95
99
|
| | |
|
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-
|
|
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-3pBmpUTB.js";
|
|
3
3
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command } from "commander";
|
|
@@ -518,7 +518,9 @@ function buildShotConfig(url, options, existingConfig) {
|
|
|
518
518
|
browser: {
|
|
519
519
|
viewport: getViewport(options, existingConfig),
|
|
520
520
|
colorScheme: getColorScheme(options, false),
|
|
521
|
-
deviceScaleFactor: getDeviceScaleFactor(options, existingConfig)
|
|
521
|
+
deviceScaleFactor: getDeviceScaleFactor(options, existingConfig),
|
|
522
|
+
reducedMotion: options?.reducedMotion ? "reduce" : existingConfig?.browser?.reducedMotion,
|
|
523
|
+
userAgent: options?.userAgent ?? existingConfig?.browser?.userAgent
|
|
522
524
|
},
|
|
523
525
|
screenshots: [screenshot]
|
|
524
526
|
};
|
|
@@ -703,7 +705,7 @@ program.name("heroshot").description("Define your screenshots once, update them
|
|
|
703
705
|
setVerbose(program.opts().verbose ?? false);
|
|
704
706
|
intro(version);
|
|
705
707
|
});
|
|
706
|
-
program.command("oneshot [url]", { isDefault: true }).description("Capture URL directly, or sync all screenshots from config").option("--selector <selector...>", "CSS selector(s) to capture").option("-o, --output <file>", "Output filename").option("-p, --padding <pixels>", "Padding around element", Number.parseInt).option("-w, --width <pixels>", "Viewport width", Number.parseInt).option("--height <pixels>", "Viewport height", Number.parseInt).option("--mobile", "Use mobile viewport (430x932)").option("--tablet", "Use tablet viewport (768x1024)").option("--desktop", "Use desktop viewport (1280x800)").option("--dark", "Force dark color scheme").option("--light", "Force light color scheme").option("--scale <factor>", "Device scale factor (1, 2, 3)", Number.parseInt).option("--retina", "Use retina scale (2x)").option("-q, --quality <percent>", "JPEG quality (1-100), outputs JPEG", Number.parseInt).option("--viewport-only", "Capture only viewport (not full page)").option("--save", "Save screenshot definition to config").option("--clean", "Delete stale files in output directory").option("--workers <count>", "Number of parallel capture workers", Number.parseInt).option("--headed", "Run browser in headed mode (visible window) for debugging").action(async (url, options) => {
|
|
708
|
+
program.command("oneshot [url]", { isDefault: true }).description("Capture URL directly, or sync all screenshots from config").option("--selector <selector...>", "CSS selector(s) to capture").option("-o, --output <file>", "Output filename").option("-p, --padding <pixels>", "Padding around element", Number.parseInt).option("-w, --width <pixels>", "Viewport width", Number.parseInt).option("--height <pixels>", "Viewport height", Number.parseInt).option("--mobile", "Use mobile viewport (430x932)").option("--tablet", "Use tablet viewport (768x1024)").option("--desktop", "Use desktop viewport (1280x800)").option("--dark", "Force dark color scheme").option("--light", "Force light color scheme").option("--scale <factor>", "Device scale factor (1, 2, 3)", Number.parseInt).option("--retina", "Use retina scale (2x)").option("-q, --quality <percent>", "JPEG quality (1-100), outputs JPEG", Number.parseInt).option("--viewport-only", "Capture only viewport (not full page)").option("--reduced-motion", "Emulate prefers-reduced-motion: reduce (disables animations)").option("--user-agent <string>", "Custom user agent string").option("--save", "Save screenshot definition to config").option("--clean", "Delete stale files in output directory").option("--workers <count>", "Number of parallel capture workers", Number.parseInt).option("--headed", "Run browser in headed mode (visible window) for debugging").action(async (url, options) => {
|
|
707
709
|
if (!await shotAction(url, options, program.opts())) process.exitCode = 1;
|
|
708
710
|
});
|
|
709
711
|
program.command("config").description("Open browser to add/edit screenshot definitions").option("--reset", "Clear existing session and start fresh").option("--only", "Only run config, skip sync afterwards").option("--light", "Force light mode (prefers-color-scheme: light)").option("--dark", "Force dark mode (prefers-color-scheme: dark)").action(async (options) => {
|
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-
|
|
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-3pBmpUTB.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";
|
|
@@ -87,7 +87,7 @@ function log$1(text) {
|
|
|
87
87
|
//#region src/actionSchema.ts
|
|
88
88
|
/**
|
|
89
89
|
* Action Schemas — Pre-screenshot actions that run before capturing.
|
|
90
|
-
*
|
|
90
|
+
* Uses Playwright locator API, supporting all selector formats: CSS, XPath, text, role, shadow DOM.
|
|
91
91
|
* Reference: https://github.com/microsoft/playwright-mcp
|
|
92
92
|
*
|
|
93
93
|
* ROADMAP extensions (not in Playwright MCP):
|
|
@@ -96,7 +96,7 @@ function log$1(text) {
|
|
|
96
96
|
*/
|
|
97
97
|
const clickActionSchema = z.object({
|
|
98
98
|
type: z.literal("click"),
|
|
99
|
-
selector: z.string().describe("
|
|
99
|
+
selector: z.string().describe("Element selector (CSS, XPath, text, role, or shadow DOM)"),
|
|
100
100
|
doubleClick: z.boolean().optional().describe("Whether to perform a double click"),
|
|
101
101
|
button: z.enum([
|
|
102
102
|
"left",
|
|
@@ -114,7 +114,7 @@ const clickActionSchema = z.object({
|
|
|
114
114
|
}).describe("Click an element. Use to dismiss cookie banners, open menus, expand dropdowns.");
|
|
115
115
|
const typeActionSchema = z.object({
|
|
116
116
|
type: z.literal("type"),
|
|
117
|
-
selector: z.string().describe("
|
|
117
|
+
selector: z.string().describe("Input element selector (CSS, XPath, text, role, or shadow DOM)"),
|
|
118
118
|
text: z.string().describe("Text to type into the element"),
|
|
119
119
|
submit: z.boolean().optional().describe("Whether to press Enter after typing (submit form)"),
|
|
120
120
|
slowly: z.boolean().optional().describe("Type one character at a time for key handlers"),
|
|
@@ -122,12 +122,12 @@ const typeActionSchema = z.object({
|
|
|
122
122
|
}).describe("Type text into an input, textarea, or contenteditable element.");
|
|
123
123
|
const hoverActionSchema = z.object({
|
|
124
124
|
type: z.literal("hover"),
|
|
125
|
-
selector: z.string().describe("
|
|
125
|
+
selector: z.string().describe("Element selector (CSS, XPath, text, role, or shadow DOM)"),
|
|
126
126
|
timeout: z.number().int().positive().optional().describe("Timeout in milliseconds")
|
|
127
127
|
}).describe("Hover over an element to trigger :hover states, show tooltips, or reveal menus.");
|
|
128
128
|
const selectOptionActionSchema = z.object({
|
|
129
129
|
type: z.literal("select_option"),
|
|
130
|
-
selector: z.string().describe("
|
|
130
|
+
selector: z.string().describe("Select element selector (CSS, XPath, text, role, or shadow DOM)"),
|
|
131
131
|
values: z.array(z.string()).describe("Option values to select. Supports multiple."),
|
|
132
132
|
timeout: z.number().int().positive().optional().describe("Timeout in milliseconds")
|
|
133
133
|
}).describe("Select one or more options in a native <select> dropdown.");
|
|
@@ -137,8 +137,8 @@ const pressKeyActionSchema = z.object({
|
|
|
137
137
|
}).describe("Press a keyboard key or combination. Use to close modals, submit forms, etc.");
|
|
138
138
|
const dragActionSchema = z.object({
|
|
139
139
|
type: z.literal("drag"),
|
|
140
|
-
from: z.string().describe("
|
|
141
|
-
to: z.string().describe("
|
|
140
|
+
from: z.string().describe("Selector of the element to drag"),
|
|
141
|
+
to: z.string().describe("Selector of the drop target"),
|
|
142
142
|
timeout: z.number().int().positive().optional().describe("Timeout in milliseconds")
|
|
143
143
|
}).describe("Drag an element and drop it onto another.");
|
|
144
144
|
const waitActionSchema = z.object({
|
|
@@ -155,12 +155,12 @@ const navigateActionSchema = z.object({
|
|
|
155
155
|
const evaluateActionSchema = z.object({
|
|
156
156
|
type: z.literal("evaluate"),
|
|
157
157
|
function: z.string().describe("JavaScript function: () => { ... } or (el) => { ... }"),
|
|
158
|
-
selector: z.string().optional().describe("
|
|
158
|
+
selector: z.string().optional().describe("Element selector to pass to function")
|
|
159
159
|
}).describe("Run arbitrary JavaScript in the browser context. Escape hatch for DOM manipulation.");
|
|
160
160
|
const fillFormActionSchema = z.object({
|
|
161
161
|
type: z.literal("fill_form"),
|
|
162
162
|
fields: z.array(z.object({
|
|
163
|
-
selector: z.string().describe("
|
|
163
|
+
selector: z.string().describe("Form field selector"),
|
|
164
164
|
value: z.string().describe("Value to fill. Checkboxes: \"true\"/\"false\""),
|
|
165
165
|
fieldType: z.enum([
|
|
166
166
|
"textbox",
|
|
@@ -179,7 +179,7 @@ const handleDialogActionSchema = z.object({
|
|
|
179
179
|
}).describe("Set up handler for browser dialog. Place BEFORE action that triggers dialog.");
|
|
180
180
|
const fileUploadActionSchema = z.object({
|
|
181
181
|
type: z.literal("file_upload"),
|
|
182
|
-
selector: z.string().describe("
|
|
182
|
+
selector: z.string().describe("File input element selector"),
|
|
183
183
|
paths: z.array(z.string()).describe("File paths to upload (absolute or relative to config)")
|
|
184
184
|
}).describe("Upload one or more files through a file input element.");
|
|
185
185
|
const resizeActionSchema = z.object({
|
|
@@ -189,7 +189,7 @@ const resizeActionSchema = z.object({
|
|
|
189
189
|
}).describe("Resize the browser viewport mid-flow.");
|
|
190
190
|
const hideActionSchema = z.object({
|
|
191
191
|
type: z.literal("hide"),
|
|
192
|
-
selectors: z.array(z.string()).describe("
|
|
192
|
+
selectors: z.array(z.string()).describe("Element selectors to hide (display: none)")
|
|
193
193
|
}).describe("Hide elements from screenshot. Use to remove cookie banners, chat widgets, ads.");
|
|
194
194
|
/** Union of all supported action types. Actions execute sequentially before screenshot. */
|
|
195
195
|
const actionSchema = z.discriminatedUnion("type", [
|
|
@@ -260,7 +260,11 @@ const paddingSchema = z.object({
|
|
|
260
260
|
bottom: z.number().int().min(0).default(0).describe("Bottom padding in pixels"),
|
|
261
261
|
left: z.number().int().min(0).default(0).describe("Left padding in pixels")
|
|
262
262
|
});
|
|
263
|
-
/**
|
|
263
|
+
/**
|
|
264
|
+
* Scroll position saved from editor.
|
|
265
|
+
* NOTE: Currently not used during capture - we use scrollIntoView instead.
|
|
266
|
+
* Kept for potential future use (e.g., precise scroll offset from element).
|
|
267
|
+
*/
|
|
264
268
|
const scrollPositionSchema = z.object({
|
|
265
269
|
x: z.number().int().min(0).default(0).describe("Horizontal scroll offset in pixels"),
|
|
266
270
|
y: z.number().int().min(0).default(0).describe("Vertical scroll offset in pixels")
|
|
@@ -301,9 +305,9 @@ const screenshotSchema = z.object({
|
|
|
301
305
|
id: z.string().min(1).default(generateUid).describe("Unique identifier (auto-generated if omitted)"),
|
|
302
306
|
name: z.string().min(1).describe("Display name, also used to derive the output filename"),
|
|
303
307
|
url: z.url().describe("Full URL of the page to capture"),
|
|
304
|
-
selector: z.string().optional().describe("
|
|
308
|
+
selector: z.string().optional().describe("Element selector for capture (omit for full-page). Supports Playwright selector formats: CSS (.class, #id), shadow DOM (host >> child), XPath (xpath=...), text (text=...), role (role=button[name=\"OK\"]), and chained selectors."),
|
|
305
309
|
padding: paddingSchema.optional().describe("Expand capture area beyond element bounds"),
|
|
306
|
-
scroll: scrollPositionSchema.optional().describe("
|
|
310
|
+
scroll: scrollPositionSchema.optional().describe("Saved scroll position (not used during capture - scrollIntoView is used instead)"),
|
|
307
311
|
paddingFill: paddingFillSchema.optional().describe("Background fill for padding area: \"inherit\" (default) shows page content, \"solid\" fills with detected background color"),
|
|
308
312
|
elementFill: elementFillSchema.optional().describe("Background fill for element area: \"original\" (default) keeps actual background, \"solid\" replaces with detected color"),
|
|
309
313
|
viewports: z.array(viewportVariantSchema).optional().describe("Viewport variants to generate — preset names (\"desktop\", \"tablet\", \"mobile\") or custom \"WIDTHxHEIGHT\""),
|
|
@@ -334,7 +338,9 @@ const shotCliOptionsSchema = z.object({
|
|
|
334
338
|
scale: z.number().min(1).max(3).optional(),
|
|
335
339
|
retina: z.boolean().optional(),
|
|
336
340
|
quality: z.number().int().min(1).max(100).optional(),
|
|
337
|
-
viewportOnly: z.boolean().optional()
|
|
341
|
+
viewportOnly: z.boolean().optional(),
|
|
342
|
+
reducedMotion: z.boolean().optional(),
|
|
343
|
+
userAgent: z.string().optional()
|
|
338
344
|
});
|
|
339
345
|
/** CLI command options for URL capture (includes --save and --clean flags) */
|
|
340
346
|
const shotCommandOptionsSchema = shotCliOptionsSchema.extend({
|
|
@@ -1040,64 +1046,30 @@ async function executeActions(page, actions) {
|
|
|
1040
1046
|
}
|
|
1041
1047
|
|
|
1042
1048
|
//#endregion
|
|
1043
|
-
//#region src/sync/
|
|
1044
|
-
/**
|
|
1045
|
-
* Browser context functions for DOM manipulation.
|
|
1046
|
-
* These functions execute in the browser via page.evaluate(fn, ...args).
|
|
1047
|
-
*
|
|
1048
|
-
* IMPORTANT: These functions run in browser context, not Node.js.
|
|
1049
|
-
* They cannot access Node modules or closures - all data must be passed as arguments.
|
|
1050
|
-
*
|
|
1051
|
-
* NOTE: Most functions that need nested helpers or module-level constants have been
|
|
1052
|
-
* moved to use string-based evaluation in their respective wrapper files to avoid
|
|
1053
|
-
* tsx __name wrapper issues. Only simple functions are exported here.
|
|
1054
|
-
*/
|
|
1055
|
-
/**
|
|
1056
|
-
* Apply or remove dark mode class on document element.
|
|
1057
|
-
*/
|
|
1058
|
-
function applyColorScheme(isDark) {
|
|
1059
|
-
document.documentElement.classList.toggle("dark", isDark);
|
|
1060
|
-
}
|
|
1049
|
+
//#region src/sync/elementFinder.ts
|
|
1061
1050
|
/**
|
|
1062
|
-
*
|
|
1051
|
+
* Normalize selector for Playwright compatibility.
|
|
1052
|
+
* Converts legacy `>>>` shadow-piercing syntax to Playwright's `>>`.
|
|
1063
1053
|
*/
|
|
1064
|
-
function
|
|
1065
|
-
|
|
1054
|
+
function normalizeSelector(selector) {
|
|
1055
|
+
return selector.replaceAll(">>>", ">>").replaceAll(" ", " ").trim();
|
|
1066
1056
|
}
|
|
1067
|
-
|
|
1068
|
-
//#endregion
|
|
1069
|
-
//#region src/sync/elementFinder.ts
|
|
1070
1057
|
/**
|
|
1071
|
-
* Find element using
|
|
1072
|
-
*
|
|
1058
|
+
* Find element using Playwright's locator API with retries.
|
|
1059
|
+
* Supports all Playwright selector formats including shadow DOM piercing.
|
|
1060
|
+
* Automatically scrolls the element into view once found.
|
|
1073
1061
|
*/
|
|
1074
1062
|
async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
|
|
1063
|
+
const normalizedSelector = normalizeSelector(selector);
|
|
1075
1064
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
const root = current instanceof Element
|
|
1086
|
-
? (current.shadowRoot ?? current)
|
|
1087
|
-
: current;
|
|
1088
|
-
|
|
1089
|
-
const found = root.querySelector(part);
|
|
1090
|
-
if (!found) return null;
|
|
1091
|
-
|
|
1092
|
-
current = found;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
return current instanceof Element ? current : null;
|
|
1096
|
-
})()
|
|
1097
|
-
`);
|
|
1098
|
-
const element = handle.asElement();
|
|
1099
|
-
if (element) return element;
|
|
1100
|
-
await handle.dispose();
|
|
1065
|
+
try {
|
|
1066
|
+
const locator = page.locator(normalizedSelector);
|
|
1067
|
+
const element = await locator.elementHandle({ timeout: intervalMs });
|
|
1068
|
+
if (element) {
|
|
1069
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 5e3 });
|
|
1070
|
+
return element;
|
|
1071
|
+
}
|
|
1072
|
+
} catch {}
|
|
1101
1073
|
if (attempt < maxAttempts) await page.waitForTimeout(intervalMs);
|
|
1102
1074
|
}
|
|
1103
1075
|
return null;
|
|
@@ -1169,126 +1141,116 @@ async function removePaddingMask(page) {
|
|
|
1169
1141
|
}
|
|
1170
1142
|
|
|
1171
1143
|
//#endregion
|
|
1172
|
-
//#region src/sync/
|
|
1144
|
+
//#region src/sync/browserFunctions.ts
|
|
1173
1145
|
/**
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1146
|
+
* Browser context functions for DOM manipulation.
|
|
1147
|
+
* These functions execute in the browser via page.evaluate(fn, ...args).
|
|
1148
|
+
*
|
|
1149
|
+
* IMPORTANT: These functions run in browser context, not Node.js.
|
|
1150
|
+
* They cannot access Node modules or closures - all data must be passed as arguments.
|
|
1176
1151
|
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*/
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
if (!found) return null;
|
|
1191
|
-
current = found;
|
|
1192
|
-
}
|
|
1193
|
-
return current instanceof Element ? current : null;
|
|
1194
|
-
}
|
|
1195
|
-
`;
|
|
1152
|
+
* NOTE: Most functions that need nested helpers or module-level constants have been
|
|
1153
|
+
* moved to use string-based evaluation in their respective wrapper files to avoid
|
|
1154
|
+
* tsx __name wrapper issues. Only simple functions are exported here.
|
|
1155
|
+
*/
|
|
1156
|
+
/**
|
|
1157
|
+
* Apply or remove dark mode class on document element.
|
|
1158
|
+
*/
|
|
1159
|
+
function applyColorScheme(isDark) {
|
|
1160
|
+
document.documentElement.classList.toggle("dark", isDark);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region src/sync/pageScripts.ts
|
|
1196
1165
|
/**
|
|
1197
1166
|
* Store original background and apply new background color to element.
|
|
1167
|
+
* Uses Playwright's locator API to support all selector formats.
|
|
1198
1168
|
*/
|
|
1199
1169
|
async function applyElementBackground(page, selector, bgColor) {
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1170
|
+
const locator = page.locator(normalizeSelector(selector));
|
|
1171
|
+
try {
|
|
1172
|
+
await locator.evaluate((element, color) => {
|
|
1173
|
+
if (element instanceof HTMLElement) {
|
|
1174
|
+
element.dataset["heroshotOriginalBg"] = element.style.backgroundColor;
|
|
1175
|
+
element.style.backgroundColor = color;
|
|
1176
|
+
}
|
|
1177
|
+
}, bgColor, { timeout: 5e3 });
|
|
1178
|
+
} catch {}
|
|
1207
1179
|
}
|
|
1208
1180
|
/**
|
|
1209
1181
|
* Restore original background on element.
|
|
1182
|
+
* Uses Playwright's locator API to support all selector formats.
|
|
1210
1183
|
*/
|
|
1211
1184
|
async function restoreElementBackground(page, selector) {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1185
|
+
const locator = page.locator(normalizeSelector(selector));
|
|
1186
|
+
try {
|
|
1187
|
+
await locator.evaluate((element) => {
|
|
1188
|
+
if (element instanceof HTMLElement) {
|
|
1189
|
+
const originalBg = element.dataset["heroshotOriginalBg"];
|
|
1190
|
+
if (originalBg !== void 0) {
|
|
1191
|
+
element.style.backgroundColor = originalBg;
|
|
1192
|
+
delete element.dataset["heroshotOriginalBg"];
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}, { timeout: 5e3 });
|
|
1196
|
+
} catch {}
|
|
1222
1197
|
}
|
|
1223
1198
|
/**
|
|
1224
1199
|
* Apply text overrides to elements on the page.
|
|
1200
|
+
* Uses Playwright's locator API for the container, then CSS selectors for relative paths.
|
|
1225
1201
|
*/
|
|
1226
1202
|
async function applyTextOverrides(page, selector, textOverrides) {
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
if (textElement) {
|
|
1237
|
-
textElement.textContent = newText;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
})(${JSON.stringify(selector)}, ${JSON.stringify(textOverrides)})`);
|
|
1203
|
+
const locator = page.locator(normalizeSelector(selector));
|
|
1204
|
+
try {
|
|
1205
|
+
await locator.evaluate((container, overrides) => {
|
|
1206
|
+
for (const [relativeSelector, newText] of Object.entries(overrides)) {
|
|
1207
|
+
const textElement = container.querySelector(relativeSelector);
|
|
1208
|
+
if (textElement) textElement.textContent = newText;
|
|
1209
|
+
}
|
|
1210
|
+
}, textOverrides, { timeout: 5e3 });
|
|
1211
|
+
} catch {}
|
|
1241
1212
|
await page.waitForTimeout(50);
|
|
1242
1213
|
}
|
|
1243
1214
|
/**
|
|
1244
|
-
* Get background color for an element
|
|
1215
|
+
* Get background color for an element.
|
|
1216
|
+
* Uses Playwright's locator API to support all selector formats.
|
|
1245
1217
|
*/
|
|
1246
1218
|
async function getElementBackgroundColor(page, selector) {
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
if (isOpaqueColor(bodyBg)) return rgbToHex(bodyBg);
|
|
1283
|
-
|
|
1284
|
-
var htmlBg = getComputedStyle(document.documentElement).backgroundColor;
|
|
1285
|
-
if (isOpaqueColor(htmlBg)) return rgbToHex(htmlBg);
|
|
1286
|
-
|
|
1287
|
-
return '#ffffff';
|
|
1288
|
-
})(${JSON.stringify(selector)})`;
|
|
1289
|
-
const bgColor = await page.evaluate(script);
|
|
1290
|
-
verbose(`Detected background color: ${bgColor}`);
|
|
1291
|
-
return bgColor;
|
|
1219
|
+
const locator = page.locator(normalizeSelector(selector));
|
|
1220
|
+
try {
|
|
1221
|
+
const bgColor = await locator.evaluate((element) => {
|
|
1222
|
+
function rgbToHex(color) {
|
|
1223
|
+
const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
|
|
1224
|
+
if (rgbMatch?.[1] && rgbMatch[2] && rgbMatch[3]) {
|
|
1225
|
+
const red = Number.parseInt(rgbMatch[1], 10);
|
|
1226
|
+
const green = Number.parseInt(rgbMatch[2], 10);
|
|
1227
|
+
const blue = Number.parseInt(rgbMatch[3], 10);
|
|
1228
|
+
return "#" + red.toString(16).padStart(2, "0") + green.toString(16).padStart(2, "0") + blue.toString(16).padStart(2, "0");
|
|
1229
|
+
}
|
|
1230
|
+
return color;
|
|
1231
|
+
}
|
|
1232
|
+
function isOpaqueColor(color) {
|
|
1233
|
+
return Boolean(color && color !== "transparent" && !color.startsWith("rgba(0, 0, 0, 0)"));
|
|
1234
|
+
}
|
|
1235
|
+
let current = element;
|
|
1236
|
+
while (current) {
|
|
1237
|
+
const { backgroundColor } = getComputedStyle(current);
|
|
1238
|
+
if (isOpaqueColor(backgroundColor)) return rgbToHex(backgroundColor);
|
|
1239
|
+
const root = current.getRootNode();
|
|
1240
|
+
current = root instanceof ShadowRoot ? root.host : current.parentElement;
|
|
1241
|
+
}
|
|
1242
|
+
const { backgroundColor: bodyBg } = getComputedStyle(document.body);
|
|
1243
|
+
if (isOpaqueColor(bodyBg)) return rgbToHex(bodyBg);
|
|
1244
|
+
const { backgroundColor: htmlBg } = getComputedStyle(document.documentElement);
|
|
1245
|
+
if (isOpaqueColor(htmlBg)) return rgbToHex(htmlBg);
|
|
1246
|
+
return "#ffffff";
|
|
1247
|
+
}, { timeout: 5e3 });
|
|
1248
|
+
verbose(`Detected background color: ${bgColor}`);
|
|
1249
|
+
return bgColor;
|
|
1250
|
+
} catch {
|
|
1251
|
+
verbose("Could not detect background color, using default #ffffff");
|
|
1252
|
+
return "#ffffff";
|
|
1253
|
+
}
|
|
1292
1254
|
}
|
|
1293
1255
|
/**
|
|
1294
1256
|
* Apply dark mode class to document based on color scheme.
|
|
@@ -1494,8 +1456,9 @@ const RETRY_DELAYS = [
|
|
|
1494
1456
|
];
|
|
1495
1457
|
/**
|
|
1496
1458
|
* Navigate to URL and prepare page for screenshot.
|
|
1459
|
+
* Note: Scroll position is handled by findElement which scrolls the element into view.
|
|
1497
1460
|
*/
|
|
1498
|
-
async function navigateAndPrepare(page, url, colorScheme
|
|
1461
|
+
async function navigateAndPrepare(page, url, colorScheme) {
|
|
1499
1462
|
if (colorScheme) await page.emulateMedia({ colorScheme });
|
|
1500
1463
|
try {
|
|
1501
1464
|
await page.goto(url, {
|
|
@@ -1510,13 +1473,6 @@ async function navigateAndPrepare(page, url, colorScheme, scroll) {
|
|
|
1510
1473
|
}
|
|
1511
1474
|
if (colorScheme) await applyColorSchemeClass(page, colorScheme);
|
|
1512
1475
|
await page.waitForTimeout(2e3);
|
|
1513
|
-
if (scroll) {
|
|
1514
|
-
await page.evaluate(scrollTo, {
|
|
1515
|
-
x: scroll.x,
|
|
1516
|
-
y: scroll.y
|
|
1517
|
-
});
|
|
1518
|
-
await page.waitForTimeout(100);
|
|
1519
|
-
}
|
|
1520
1476
|
return { success: true };
|
|
1521
1477
|
}
|
|
1522
1478
|
/**
|
|
@@ -1543,7 +1499,7 @@ async function capturePageScreenshot(page, outputPath, format, quality, fullPage
|
|
|
1543
1499
|
* Capture a single screenshot.
|
|
1544
1500
|
*/
|
|
1545
1501
|
async function captureScreenshot(page, screenshot, outputDirectory, captureOptions, variant = {}) {
|
|
1546
|
-
const { name, url, selector, padding,
|
|
1502
|
+
const { name, url, selector, padding, paddingFill, elementFill, textOverrides } = screenshot;
|
|
1547
1503
|
const { format, quality, fullPage } = captureOptions;
|
|
1548
1504
|
const filename = generateScreenshotFilename({
|
|
1549
1505
|
name,
|
|
@@ -1553,7 +1509,7 @@ async function captureScreenshot(page, screenshot, outputDirectory, captureOptio
|
|
|
1553
1509
|
});
|
|
1554
1510
|
const suffix = buildVariantSuffix(variant.viewportName, variant.colorScheme);
|
|
1555
1511
|
verbose(`Capturing: ${name}${suffix ? ` (${suffix})` : ""}`);
|
|
1556
|
-
const navResult = await navigateAndPrepare(page, url, variant.colorScheme
|
|
1512
|
+
const navResult = await navigateAndPrepare(page, url, variant.colorScheme);
|
|
1557
1513
|
if (!navResult.success) return {
|
|
1558
1514
|
...navResult,
|
|
1559
1515
|
filename
|
|
@@ -1740,8 +1696,8 @@ async function captureParallel(options) {
|
|
|
1740
1696
|
captureSpinner.message(`Capturing ${progress.captured}/${progress.total}: ${result.name}`);
|
|
1741
1697
|
};
|
|
1742
1698
|
const batchPromises = batches.map(async (batch) => limit(async () => executeBatch(batch, outputDirectory, captureOptions, browserOptions, onProgress)));
|
|
1743
|
-
const
|
|
1744
|
-
for (const
|
|
1699
|
+
const settledResults = await Promise.allSettled(batchPromises);
|
|
1700
|
+
for (const settled of settledResults) if (settled.status === "fulfilled") allResults.push(...settled.value);
|
|
1745
1701
|
return allResults;
|
|
1746
1702
|
}
|
|
1747
1703
|
|
|
@@ -1918,6 +1874,7 @@ async function executeCapture(context) {
|
|
|
1918
1874
|
*/
|
|
1919
1875
|
async function sync(options = {}) {
|
|
1920
1876
|
const configPath = options.configPath ?? getConfigPath();
|
|
1877
|
+
if (options.configPath && !existsSync(options.configPath)) throw new Error(`Config file not found: ${options.configPath}`);
|
|
1921
1878
|
const config = options.config ?? loadConfig(configPath);
|
|
1922
1879
|
if (config.screenshots.length === 0) {
|
|
1923
1880
|
warn("No screenshots defined.");
|