heroshot 0.0.2-alpha.1 → 0.0.2-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -661
- package/README.md +29 -0
- package/dist/cli.js +238 -116
- package/package.json +11 -2
- package/toolbar/dist/toolbar.js +5078 -0
- package/eslint.config.js +0 -327
- package/knip.json +0 -6
- package/scripts/pre-commit.sh +0 -71
- package/src/browser.ts +0 -302
- package/src/capture.ts +0 -7
- package/src/cli.ts +0 -60
- package/src/config.ts +0 -94
- package/src/configFile.ts +0 -25
- package/src/sync.ts +0 -214
- package/src/types.ts +0 -24
- package/tests/types.test.ts +0 -44
- package/toolbar/src/components/ListDialog.svelte +0 -230
- package/toolbar/src/components/NameModal.svelte +0 -186
- package/toolbar/src/components/Toolbar.svelte +0 -540
- package/toolbar/src/lib/dom.ts +0 -178
- package/toolbar/src/lib/tests/dom.test.ts +0 -262
- package/toolbar/src/main.ts +0 -74
- package/toolbar/src/svelte.d.ts +0 -6
- package/toolbar/src/types.ts +0 -43
- package/toolbar/svelte.config.js +0 -8
- package/toolbar/tsconfig.json +0 -9
- package/toolbar/vite.config.ts +0 -52
- package/tsconfig.json +0 -34
- package/tsup.config.ts +0 -12
- package/vitest.config.ts +0 -15
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/icon-128.svg" alt="heroshot logo" width="128" height="128">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">heroshot</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Keep your product screenshots always up to date.</strong><br>
|
|
9
|
+
No more stale images in docs, landing pages, or blog posts.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
**How it works:**
|
|
13
|
+
|
|
14
|
+
1. Run `npx heroshot` - opens a browser with a visual picker
|
|
15
|
+
2. Click on elements you want to screenshot
|
|
16
|
+
3. Close browser - screenshots are captured automatically
|
|
17
|
+
|
|
18
|
+
**Why heroshot?**
|
|
19
|
+
|
|
20
|
+
- **Visual picker** - Point and click to select elements, no DevTools needed
|
|
21
|
+
- **Zero config** - No YAML to write, config is auto-generated
|
|
22
|
+
- **Element-precise** - Capture specific UI components, not just full pages
|
|
23
|
+
- **One command** - Regenerate all screenshots anytime your UI changes
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
[](https://www.npmjs.com/package/heroshot)
|
|
28
|
+
|
|
29
|
+
**Status:** Early alpha. [See releases](https://github.com/omachala/heroshot/releases) for current version.
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { chromium } from "playwright";
|
|
|
14
14
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
15
15
|
import path from "path";
|
|
16
16
|
|
|
17
|
-
// src/
|
|
17
|
+
// src/schema.ts
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
function generateUid() {
|
|
20
20
|
return Math.random().toString(36).slice(2, 10);
|
|
@@ -23,41 +23,47 @@ var viewportSchema = z.object({
|
|
|
23
23
|
width: z.number().int().positive().default(1280),
|
|
24
24
|
height: z.number().int().positive().default(800)
|
|
25
25
|
});
|
|
26
|
-
var beautifySchema = z.object({
|
|
27
|
-
shadow: z.boolean().default(false),
|
|
28
|
-
radius: z.number().int().nonnegative().default(0),
|
|
29
|
-
background: z.string().default("transparent"),
|
|
30
|
-
padding: z.number().int().nonnegative().default(0)
|
|
31
|
-
});
|
|
32
26
|
var colorSchemeSchema = z.enum(["light", "dark", "both"]);
|
|
27
|
+
var outputFormatSchema = z.enum(["png", "jpeg"]).default("png");
|
|
28
|
+
var paddingSchema = z.object({
|
|
29
|
+
top: z.number().int().min(0).default(0),
|
|
30
|
+
right: z.number().int().min(0).default(0),
|
|
31
|
+
bottom: z.number().int().min(0).default(0),
|
|
32
|
+
left: z.number().int().min(0).default(0)
|
|
33
|
+
});
|
|
34
|
+
var scrollPositionSchema = z.object({
|
|
35
|
+
x: z.number().int().min(0).default(0),
|
|
36
|
+
y: z.number().int().min(0).default(0)
|
|
37
|
+
});
|
|
33
38
|
var screenshotSchema = z.object({
|
|
34
39
|
id: z.string().min(1).default(generateUid),
|
|
35
40
|
name: z.string().min(1),
|
|
36
41
|
url: z.url(),
|
|
37
42
|
filename: z.string().min(1),
|
|
38
43
|
selector: z.string().optional(),
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
beautify: beautifySchema.partial().optional()
|
|
44
|
+
/** Padding to expand capture area beyond element bounds */
|
|
45
|
+
padding: paddingSchema.optional(),
|
|
46
|
+
/** Scroll position to restore when capturing */
|
|
47
|
+
scroll: scrollPositionSchema.optional()
|
|
44
48
|
});
|
|
45
49
|
var browserSchema = z.object({
|
|
46
50
|
viewport: viewportSchema.optional(),
|
|
47
|
-
colorScheme: colorSchemeSchema.optional()
|
|
48
|
-
cdp: z.url().optional()
|
|
49
|
-
// Connect to existing Chrome
|
|
51
|
+
colorScheme: colorSchemeSchema.optional()
|
|
50
52
|
});
|
|
51
53
|
var configSchema = z.object({
|
|
52
|
-
|
|
54
|
+
/** Output directory for screenshots (relative to config file) */
|
|
53
55
|
outputDirectory: z.string().default("."),
|
|
54
|
-
|
|
56
|
+
/** Output format for screenshots (png or jpeg) */
|
|
57
|
+
outputFormat: outputFormatSchema.optional(),
|
|
58
|
+
/** JPEG quality (1-100), only used when outputFormat is 'jpeg' */
|
|
59
|
+
jpegQuality: z.number().int().min(1).max(100).default(80),
|
|
60
|
+
/** Browser settings (viewport, colorScheme) */
|
|
55
61
|
browser: browserSchema.optional(),
|
|
56
|
-
|
|
57
|
-
beautify: beautifySchema.partial().optional(),
|
|
58
|
-
// Screenshot definitions
|
|
62
|
+
/** Screenshot definitions */
|
|
59
63
|
screenshots: z.array(screenshotSchema).default([])
|
|
60
64
|
});
|
|
65
|
+
|
|
66
|
+
// src/config.ts
|
|
61
67
|
function parseConfig(input) {
|
|
62
68
|
return configSchema.parse(input);
|
|
63
69
|
}
|
|
@@ -80,6 +86,23 @@ function saveConfig(configPath, config) {
|
|
|
80
86
|
writeFileSync(configPath, content, "utf8");
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
// src/logger.ts
|
|
90
|
+
var verboseEnabled = false;
|
|
91
|
+
function setVerbose(enabled) {
|
|
92
|
+
verboseEnabled = enabled;
|
|
93
|
+
}
|
|
94
|
+
function log(message) {
|
|
95
|
+
console.log(message);
|
|
96
|
+
}
|
|
97
|
+
log.verbose = (message) => {
|
|
98
|
+
if (verboseEnabled) {
|
|
99
|
+
console.log(message);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
log.error = (message) => {
|
|
103
|
+
console.error(message);
|
|
104
|
+
};
|
|
105
|
+
|
|
83
106
|
// src/browser.ts
|
|
84
107
|
var PROFILE_DIR = path2.join(homedir(), ".heroshot", "browser-profile");
|
|
85
108
|
var TOOLBAR_DIR = path2.join(import.meta.dirname, "..", "toolbar");
|
|
@@ -121,7 +144,8 @@ async function launchPersistentBrowser(options = {}) {
|
|
|
121
144
|
throw new Error(message);
|
|
122
145
|
}
|
|
123
146
|
var exposedPages = /* @__PURE__ */ new WeakSet();
|
|
124
|
-
async function injectToolbar(page,
|
|
147
|
+
async function injectToolbar(page, options) {
|
|
148
|
+
const { screenshots, pendingJob, selectedId, sidebarVisible, onEvent } = options;
|
|
125
149
|
if (!exposedPages.has(page)) {
|
|
126
150
|
await page.exposeFunction("__heroshotEmit", (eventJson) => {
|
|
127
151
|
const event = JSON.parse(eventJson);
|
|
@@ -129,8 +153,9 @@ async function injectToolbar(page, initialScreenshots, pendingJob, onEvent) {
|
|
|
129
153
|
});
|
|
130
154
|
exposedPages.add(page);
|
|
131
155
|
}
|
|
132
|
-
const screenshotsJson = JSON.stringify(
|
|
156
|
+
const screenshotsJson = JSON.stringify(screenshots);
|
|
133
157
|
const pendingJobJson = JSON.stringify(pendingJob);
|
|
158
|
+
const selectedIdJson = JSON.stringify(selectedId);
|
|
134
159
|
const alreadyInitialized = await page.evaluate("globalThis.__heroshot?.initialized === true");
|
|
135
160
|
if (alreadyInitialized) {
|
|
136
161
|
await page.evaluate(`
|
|
@@ -145,6 +170,8 @@ async function injectToolbar(page, initialScreenshots, pendingJob, onEvent) {
|
|
|
145
170
|
initialized: false,
|
|
146
171
|
screenshots: ${screenshotsJson},
|
|
147
172
|
pendingJob: ${pendingJobJson},
|
|
173
|
+
selectedId: ${selectedIdJson},
|
|
174
|
+
sidebarVisible: ${sidebarVisible},
|
|
148
175
|
emit: function(event) {
|
|
149
176
|
globalThis.__heroshotEmit(JSON.stringify(event));
|
|
150
177
|
},
|
|
@@ -154,54 +181,64 @@ async function injectToolbar(page, initialScreenshots, pendingJob, onEvent) {
|
|
|
154
181
|
const script = readFileSync2(scriptPath, "utf8");
|
|
155
182
|
await page.addScriptTag({ content: script });
|
|
156
183
|
}
|
|
157
|
-
async function captureUrl(url, output) {
|
|
158
|
-
console.log(`Capturing: ${url}`);
|
|
159
|
-
const context = await launchPersistentBrowser({ headless: true });
|
|
160
|
-
const page = await context.newPage();
|
|
161
|
-
await page.goto(url, { waitUntil: "networkidle" });
|
|
162
|
-
await page.screenshot({ path: output, fullPage: false });
|
|
163
|
-
await context.close();
|
|
164
|
-
console.log(`Saved: ${output}`);
|
|
165
|
-
}
|
|
166
184
|
async function setup() {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
console.log("");
|
|
185
|
+
log.verbose("Opening browser...");
|
|
186
|
+
log.verbose(`Profile: ${PROFILE_DIR}`);
|
|
170
187
|
const configPath = getConfigPath();
|
|
171
188
|
const config = loadConfig(configPath);
|
|
172
189
|
const viewport = config.browser?.viewport ?? DEFAULT_VIEWPORT;
|
|
173
|
-
const allScreenshots = config.screenshots.map((screenshot) => ({
|
|
190
|
+
const allScreenshots = config.screenshots.map((screenshot, index) => ({
|
|
174
191
|
id: screenshot.id,
|
|
175
192
|
name: screenshot.name,
|
|
176
193
|
url: screenshot.url,
|
|
177
|
-
selector: screenshot.selector ?? ""
|
|
194
|
+
selector: screenshot.selector ?? "",
|
|
195
|
+
// Use index as fallback createdAt for existing items (older items first)
|
|
196
|
+
createdAt: index,
|
|
197
|
+
...screenshot.padding && { padding: screenshot.padding },
|
|
198
|
+
...screenshot.scroll && { scroll: screenshot.scroll }
|
|
178
199
|
}));
|
|
179
200
|
const newlyAddedIds = /* @__PURE__ */ new Set();
|
|
180
201
|
let pendingJob = null;
|
|
202
|
+
let selectedId = null;
|
|
203
|
+
let sidebarVisible = false;
|
|
181
204
|
const context = await launchPersistentBrowser({ headless: false, viewport });
|
|
182
205
|
const handleEvent = (event) => {
|
|
183
206
|
switch (event.type) {
|
|
184
207
|
case "screenshot-added": {
|
|
185
208
|
allScreenshots.push(event.data);
|
|
186
209
|
newlyAddedIds.add(event.data.id);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
210
|
+
log.verbose(`Added: ${event.data.name}`);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case "screenshot-updated": {
|
|
214
|
+
const index = allScreenshots.findIndex((item) => item.id === event.data.id);
|
|
215
|
+
if (index !== -1) {
|
|
216
|
+
allScreenshots[index] = event.data;
|
|
217
|
+
newlyAddedIds.add(event.data.id);
|
|
218
|
+
log.verbose(`Renamed: ${event.data.name}`);
|
|
219
|
+
}
|
|
190
220
|
break;
|
|
191
221
|
}
|
|
192
222
|
case "screenshot-selected": {
|
|
193
223
|
const [currentPage] = context.pages();
|
|
194
224
|
if (!currentPage) break;
|
|
225
|
+
selectedId = event.id;
|
|
226
|
+
sidebarVisible = true;
|
|
195
227
|
const currentUrl = currentPage.url();
|
|
196
228
|
if (currentUrl === event.url) {
|
|
197
|
-
pendingJob = { type: "highlight", selector: event.selector };
|
|
229
|
+
pendingJob = { type: "highlight", selector: event.selector, screenshotId: event.id };
|
|
198
230
|
void currentPage.evaluate(`
|
|
199
231
|
window.dispatchEvent(new CustomEvent('heroshot-job', {
|
|
200
|
-
detail: { type: 'highlight', selector: ${JSON.stringify(event.selector)} }
|
|
232
|
+
detail: { type: 'highlight', selector: ${JSON.stringify(event.selector)}, screenshotId: ${JSON.stringify(event.id)} }
|
|
201
233
|
}));
|
|
202
234
|
`);
|
|
203
235
|
} else {
|
|
204
|
-
pendingJob = {
|
|
236
|
+
pendingJob = {
|
|
237
|
+
type: "navigate-and-highlight",
|
|
238
|
+
url: event.url,
|
|
239
|
+
selector: event.selector,
|
|
240
|
+
screenshotId: event.id
|
|
241
|
+
};
|
|
205
242
|
void currentPage.goto(event.url, { waitUntil: "domcontentloaded" });
|
|
206
243
|
}
|
|
207
244
|
break;
|
|
@@ -221,7 +258,13 @@ Added: ${event.data.name} (${event.data.selector})`);
|
|
|
221
258
|
const url = page2.url();
|
|
222
259
|
if (!url.startsWith("http")) return;
|
|
223
260
|
try {
|
|
224
|
-
await injectToolbar(page2,
|
|
261
|
+
await injectToolbar(page2, {
|
|
262
|
+
screenshots: allScreenshots,
|
|
263
|
+
pendingJob,
|
|
264
|
+
selectedId,
|
|
265
|
+
sidebarVisible,
|
|
266
|
+
onEvent: handleEvent
|
|
267
|
+
});
|
|
225
268
|
} catch {
|
|
226
269
|
}
|
|
227
270
|
});
|
|
@@ -232,18 +275,13 @@ Added: ${event.data.name} (${event.data.selector})`);
|
|
|
232
275
|
const existingPages = context.pages();
|
|
233
276
|
const page = existingPages[0] ?? await context.newPage();
|
|
234
277
|
setupPage(page);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
console.log("2. Click the picker icon in the toolbar to select elements");
|
|
238
|
-
console.log("3. Click Done or close the browser when finished");
|
|
239
|
-
console.log("");
|
|
278
|
+
await page.goto("https://heroshot.sh/welcome", { waitUntil: "domcontentloaded" });
|
|
279
|
+
log("Pick elements to screenshot. Close browser or click Done when finished.");
|
|
240
280
|
await new Promise((resolve) => {
|
|
241
281
|
context.once("close", () => resolve());
|
|
242
282
|
});
|
|
243
|
-
console.log("");
|
|
244
283
|
if (newlyAddedIds.size > 0) {
|
|
245
284
|
const latestConfig = loadConfig(configPath);
|
|
246
|
-
console.log("Saved screenshots:");
|
|
247
285
|
for (const element of allScreenshots) {
|
|
248
286
|
if (!newlyAddedIds.has(element.id)) continue;
|
|
249
287
|
const filename = element.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(?:^-|-$)/g, "") + ".png";
|
|
@@ -252,22 +290,23 @@ Added: ${event.data.name} (${event.data.selector})`);
|
|
|
252
290
|
name: element.name,
|
|
253
291
|
url: element.url,
|
|
254
292
|
selector: element.selector,
|
|
255
|
-
filename
|
|
293
|
+
filename,
|
|
294
|
+
...element.padding && { padding: element.padding },
|
|
295
|
+
...element.scroll && { scroll: element.scroll }
|
|
256
296
|
};
|
|
257
297
|
const existingIndex = latestConfig.screenshots.findIndex((item) => item.id === element.id);
|
|
258
298
|
if (existingIndex === -1) {
|
|
259
299
|
latestConfig.screenshots.push(screenshot);
|
|
260
|
-
|
|
300
|
+
log.verbose(`+ ${element.name}`);
|
|
261
301
|
} else {
|
|
262
302
|
latestConfig.screenshots[existingIndex] = screenshot;
|
|
263
|
-
|
|
303
|
+
log.verbose(`~ ${element.name} (updated)`);
|
|
264
304
|
}
|
|
265
305
|
}
|
|
266
306
|
saveConfig(configPath, latestConfig);
|
|
267
|
-
|
|
268
|
-
Config saved to: ${configPath}`);
|
|
307
|
+
log.verbose(`Config saved: ${configPath}`);
|
|
269
308
|
}
|
|
270
|
-
|
|
309
|
+
return { hasScreenshots: allScreenshots.length > 0 };
|
|
271
310
|
}
|
|
272
311
|
|
|
273
312
|
// src/sync.ts
|
|
@@ -308,60 +347,116 @@ async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
|
|
|
308
347
|
}
|
|
309
348
|
return null;
|
|
310
349
|
}
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
350
|
+
function addFilenameSuffix(filename, suffix) {
|
|
351
|
+
const extension = path3.extname(filename);
|
|
352
|
+
const base = path3.basename(filename, extension);
|
|
353
|
+
const directory = path3.dirname(filename);
|
|
354
|
+
return path3.join(directory, `${base}${suffix}${extension}`);
|
|
355
|
+
}
|
|
356
|
+
async function takeScreenshot(target, outputPath, format, quality, clip) {
|
|
357
|
+
const isPage = "goto" in target;
|
|
358
|
+
if (format === "jpeg") {
|
|
359
|
+
if (isPage && clip) {
|
|
360
|
+
await target.screenshot({ path: outputPath, type: "jpeg", quality, clip });
|
|
361
|
+
} else if (isPage) {
|
|
362
|
+
await target.screenshot({ path: outputPath, type: "jpeg", quality, fullPage: false });
|
|
363
|
+
} else {
|
|
364
|
+
await target.screenshot({ path: outputPath, type: "jpeg", quality });
|
|
365
|
+
}
|
|
366
|
+
} else if (isPage && clip) {
|
|
367
|
+
await target.screenshot({ path: outputPath, type: "png", clip });
|
|
368
|
+
} else if (isPage) {
|
|
369
|
+
await target.screenshot({ path: outputPath, type: "png", fullPage: false });
|
|
370
|
+
} else {
|
|
371
|
+
await target.screenshot({ path: outputPath, type: "png" });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function captureScreenshot(page, screenshot, outputDirectory, captureOptions, filenameSuffix = "") {
|
|
375
|
+
const { name, url, selector, filename, padding, scroll } = screenshot;
|
|
376
|
+
const { format, quality } = captureOptions;
|
|
377
|
+
const finalFilename = filenameSuffix ? addFilenameSuffix(filename, filenameSuffix) : filename;
|
|
378
|
+
log.verbose(` ${name}${filenameSuffix}...`);
|
|
314
379
|
try {
|
|
315
|
-
await page.goto(url, { waitUntil: "
|
|
380
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
316
381
|
} catch (error) {
|
|
317
382
|
const message = error instanceof Error ? error.message : String(error);
|
|
318
383
|
return { success: false, error: `Failed to navigate: ${message}` };
|
|
319
384
|
}
|
|
320
|
-
await page.waitForTimeout(
|
|
321
|
-
|
|
385
|
+
await page.waitForTimeout(2e3);
|
|
386
|
+
if (scroll) {
|
|
387
|
+
await page.evaluate(`window.scrollTo(${scroll.x}, ${scroll.y})`);
|
|
388
|
+
await page.waitForTimeout(100);
|
|
389
|
+
}
|
|
390
|
+
const outputPath = path3.join(outputDirectory, finalFilename);
|
|
322
391
|
const outputDirectoryPath = path3.dirname(outputPath);
|
|
323
392
|
if (!existsSync2(outputDirectoryPath)) {
|
|
324
393
|
mkdirSync(outputDirectoryPath, { recursive: true });
|
|
325
394
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
success: false,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
395
|
+
try {
|
|
396
|
+
if (selector) {
|
|
397
|
+
const element = await findElement(page, selector);
|
|
398
|
+
if (!element) {
|
|
399
|
+
return { success: false, error: `Element not found: ${selector}` };
|
|
400
|
+
}
|
|
401
|
+
const hasPadding = padding && (padding.top > 0 || padding.right > 0 || padding.bottom > 0 || padding.left > 0);
|
|
402
|
+
if (hasPadding) {
|
|
403
|
+
const box = await element.boundingBox();
|
|
404
|
+
if (!box) {
|
|
405
|
+
return { success: false, error: "Could not get element bounding box" };
|
|
406
|
+
}
|
|
407
|
+
const clip = {
|
|
408
|
+
x: Math.max(0, box.x - padding.left),
|
|
409
|
+
y: Math.max(0, box.y - padding.top),
|
|
410
|
+
width: box.width + padding.left + padding.right,
|
|
411
|
+
height: box.height + padding.top + padding.bottom
|
|
412
|
+
};
|
|
413
|
+
await takeScreenshot(page, outputPath, format, quality, clip);
|
|
414
|
+
} else {
|
|
415
|
+
await takeScreenshot(element, outputPath, format, quality);
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
await takeScreenshot(page, outputPath, format, quality);
|
|
346
419
|
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
422
|
+
return { success: false, error: `Screenshot failed: ${message}` };
|
|
347
423
|
}
|
|
348
424
|
return { success: true };
|
|
349
425
|
}
|
|
426
|
+
function getColorSchemes(setting) {
|
|
427
|
+
if (setting === "both") return ["light", "dark"];
|
|
428
|
+
if (setting) return [setting];
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
async function captureAndLog(page, screenshot, outputDirectory, captureOptions, suffix) {
|
|
432
|
+
const result = await captureScreenshot(page, screenshot, outputDirectory, captureOptions, suffix);
|
|
433
|
+
const filename = suffix ? addFilenameSuffix(screenshot.filename, suffix) : screenshot.filename;
|
|
434
|
+
if (result.success) {
|
|
435
|
+
log.verbose(` Saved: ${filename}`);
|
|
436
|
+
} else {
|
|
437
|
+
log.error(` ${screenshot.name}${suffix}: ${result.error ?? "Unknown error"}`);
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
id: `${screenshot.id}${suffix}`,
|
|
441
|
+
name: `${screenshot.name}${suffix}`,
|
|
442
|
+
success: result.success,
|
|
443
|
+
error: result.error
|
|
444
|
+
};
|
|
445
|
+
}
|
|
350
446
|
async function sync(options = {}) {
|
|
351
447
|
const configPath = getConfigPath();
|
|
352
448
|
const config = loadConfig(configPath);
|
|
353
449
|
if (config.screenshots.length === 0) {
|
|
354
|
-
|
|
450
|
+
log("No screenshots defined.");
|
|
355
451
|
return { total: 0, success: 0, failed: 0, results: [] };
|
|
356
452
|
}
|
|
357
453
|
const { id: filterId } = options;
|
|
358
454
|
const screenshots = filterId ? config.screenshots.filter((screenshot) => screenshot.id === filterId) : config.screenshots;
|
|
359
455
|
if (filterId && screenshots.length === 0) {
|
|
360
|
-
|
|
456
|
+
log(`No screenshot found with ID: ${filterId}`);
|
|
361
457
|
return { total: 0, success: 0, failed: 0, results: [] };
|
|
362
458
|
}
|
|
363
|
-
|
|
364
|
-
console.log("");
|
|
459
|
+
log.verbose(`Syncing ${screenshots.length} screenshot(s)...`);
|
|
365
460
|
const configDirectory = path3.dirname(configPath);
|
|
366
461
|
const outputDirectory = path3.resolve(configDirectory, config.outputDirectory);
|
|
367
462
|
const viewport = config.browser?.viewport ?? { width: 1280, height: 800 };
|
|
@@ -370,19 +465,30 @@ async function sync(options = {}) {
|
|
|
370
465
|
viewport
|
|
371
466
|
});
|
|
372
467
|
const page = await context.newPage();
|
|
468
|
+
const colorSchemeSetting = config.browser?.colorScheme;
|
|
469
|
+
const schemes = getColorSchemes(colorSchemeSetting);
|
|
470
|
+
const captureOptions = {
|
|
471
|
+
format: config.outputFormat ?? "png",
|
|
472
|
+
quality: config.jpegQuality
|
|
473
|
+
};
|
|
373
474
|
const results = [];
|
|
374
475
|
for (const screenshot of screenshots) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
name: screenshot.name,
|
|
379
|
-
success: result.success,
|
|
380
|
-
error: result.error
|
|
381
|
-
});
|
|
382
|
-
if (result.success) {
|
|
383
|
-
console.log(` Saved: ${screenshot.filename}`);
|
|
476
|
+
if (schemes.length === 0) {
|
|
477
|
+
const result = await captureAndLog(page, screenshot, outputDirectory, captureOptions, "");
|
|
478
|
+
results.push(result);
|
|
384
479
|
} else {
|
|
385
|
-
|
|
480
|
+
for (const scheme of schemes) {
|
|
481
|
+
await page.emulateMedia({ colorScheme: scheme });
|
|
482
|
+
const suffix = schemes.length > 1 ? `-${scheme}` : "";
|
|
483
|
+
const result = await captureAndLog(
|
|
484
|
+
page,
|
|
485
|
+
screenshot,
|
|
486
|
+
outputDirectory,
|
|
487
|
+
captureOptions,
|
|
488
|
+
suffix
|
|
489
|
+
);
|
|
490
|
+
results.push(result);
|
|
491
|
+
}
|
|
386
492
|
}
|
|
387
493
|
}
|
|
388
494
|
await context.close();
|
|
@@ -390,8 +496,11 @@ async function sync(options = {}) {
|
|
|
390
496
|
const successfulResults = results.filter(({ success }) => success);
|
|
391
497
|
const { length: successCount } = successfulResults;
|
|
392
498
|
const failedCount = totalCount - successCount;
|
|
393
|
-
|
|
394
|
-
|
|
499
|
+
if (failedCount > 0) {
|
|
500
|
+
log(`Done: ${successCount}/${totalCount} screenshots (${failedCount} failed)`);
|
|
501
|
+
} else {
|
|
502
|
+
log(`Done: ${successCount} screenshot${successCount === 1 ? "" : "s"} captured`);
|
|
503
|
+
}
|
|
395
504
|
return {
|
|
396
505
|
total: totalCount,
|
|
397
506
|
success: successCount,
|
|
@@ -402,30 +511,43 @@ async function sync(options = {}) {
|
|
|
402
511
|
|
|
403
512
|
// src/cli.ts
|
|
404
513
|
var program = new Command();
|
|
405
|
-
program.name("heroshot").description("Define your screenshots once, update them forever with one command").version("0.0.2-alpha.
|
|
406
|
-
|
|
514
|
+
program.name("heroshot").description("Define your screenshots once, update them forever with one command").version("0.0.2-alpha.2").option("-v, --verbose", "Show detailed output").hook("preAction", () => {
|
|
515
|
+
const options = program.opts();
|
|
516
|
+
setVerbose(options.verbose ?? false);
|
|
517
|
+
});
|
|
518
|
+
program.command("run", { isDefault: true, hidden: true }).description("Run heroshot (setup if no config, otherwise sync)").action(async () => {
|
|
519
|
+
const configPath = getConfigPath();
|
|
520
|
+
if (existsSync3(configPath)) {
|
|
521
|
+
const result = await sync({});
|
|
522
|
+
if (result.failed > 0) {
|
|
523
|
+
process.exitCode = 1;
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
const { hasScreenshots } = await setup();
|
|
527
|
+
if (hasScreenshots) {
|
|
528
|
+
log("");
|
|
529
|
+
const result = await sync({});
|
|
530
|
+
if (result.failed > 0) {
|
|
531
|
+
process.exitCode = 1;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
program.command("config").description("Open browser to add/edit screenshot definitions").option("--reset", "Clear existing browser profile and start fresh").option("--only", "Only run config, skip sync afterwards").action(async (options) => {
|
|
407
537
|
if (options.reset) {
|
|
408
538
|
const profilePath = getProfilePath();
|
|
409
539
|
if (existsSync3(profilePath)) {
|
|
410
540
|
rmSync(profilePath, { recursive: true });
|
|
411
|
-
|
|
541
|
+
log.verbose("Browser profile cleared.");
|
|
412
542
|
}
|
|
413
543
|
}
|
|
414
|
-
await setup();
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
});
|
|
422
|
-
program.command("sync").description("Capture all screenshots defined in config").option("--id <id>", "Only capture a specific screenshot by ID").action(async (options) => {
|
|
423
|
-
const result = await sync(options);
|
|
424
|
-
if (result.failed > 0) {
|
|
425
|
-
process.exitCode = 1;
|
|
544
|
+
const { hasScreenshots } = await setup();
|
|
545
|
+
if (hasScreenshots && !options.only) {
|
|
546
|
+
log("");
|
|
547
|
+
const result = await sync({});
|
|
548
|
+
if (result.failed > 0) {
|
|
549
|
+
process.exitCode = 1;
|
|
550
|
+
}
|
|
426
551
|
}
|
|
427
552
|
});
|
|
428
|
-
program.command("check").description("Check if screenshots are up-to-date (for CI)").action(() => {
|
|
429
|
-
console.log("TODO: Check screenshots");
|
|
430
|
-
});
|
|
431
553
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heroshot",
|
|
3
|
-
"version": "0.0.2-alpha.
|
|
3
|
+
"version": "0.0.2-alpha.3",
|
|
4
4
|
"description": "Define your screenshots once, update them forever with one command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Ondrej Machala",
|
|
7
|
-
"license": "
|
|
7
|
+
"license": "MIT",
|
|
8
8
|
"bin": {
|
|
9
9
|
"heroshot": "dist/cli.js"
|
|
10
10
|
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"toolbar/dist"
|
|
14
|
+
],
|
|
11
15
|
"packageManager": "pnpm@9.15.0",
|
|
12
16
|
"scripts": {
|
|
13
17
|
"dev": "tsx src/cli.ts",
|
|
@@ -17,6 +21,7 @@
|
|
|
17
21
|
"test:run": "vitest run",
|
|
18
22
|
"test:toolbar": "vitest run --config toolbar/vite.config.ts",
|
|
19
23
|
"test:toolbar:coverage": "vitest run --config toolbar/vite.config.ts --coverage",
|
|
24
|
+
"test:toolbar:e2e": "playwright test --config toolbar/playwright.config.ts",
|
|
20
25
|
"typecheck": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/tsbuildinfo",
|
|
21
26
|
"typecheck:toolbar": "tsc --noEmit -p toolbar/tsconfig.json --incremental --tsBuildInfoFile node_modules/.cache/tsbuildinfo-toolbar",
|
|
22
27
|
"lint": "eslint --cache --cache-location node_modules/.cache/eslint src/",
|
|
@@ -43,7 +48,9 @@
|
|
|
43
48
|
"devDependencies": {
|
|
44
49
|
"@eslint/js": "^9.16.0",
|
|
45
50
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
51
|
+
"@tailwindcss/vite": "^4.1.18",
|
|
46
52
|
"@types/node": "^22.10.0",
|
|
53
|
+
"@vitest/browser": "^4.0.16",
|
|
47
54
|
"@vitest/coverage-v8": "^4.0.16",
|
|
48
55
|
"eslint": "^9.16.0",
|
|
49
56
|
"eslint-config-prettier": "^9.1.0",
|
|
@@ -60,10 +67,12 @@
|
|
|
60
67
|
"eslint-plugin-unused-imports": "^4.3.0",
|
|
61
68
|
"jsdom": "^26.0.0",
|
|
62
69
|
"knip": "^5.80.0",
|
|
70
|
+
"postcss": "^8.5.6",
|
|
63
71
|
"prettier": "^3.7.4",
|
|
64
72
|
"simple-git-hooks": "^2.11.1",
|
|
65
73
|
"svelte": "^5.46.1",
|
|
66
74
|
"svelte-eslint-parser": "^1.4.1",
|
|
75
|
+
"tailwindcss": "^4.1.18",
|
|
67
76
|
"tsup": "^8.3.0",
|
|
68
77
|
"tsx": "^4.19.0",
|
|
69
78
|
"typescript": "^5.7.0",
|