heroshot 0.0.2-alpha.1 → 0.0.2-alpha.2
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/dist/cli.js +1 -1
- package/package.json +6 -2
- package/toolbar/dist/toolbar.js +4019 -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/src/browser.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { type BrowserContext, type Page, chromium } from 'playwright';
|
|
5
|
-
import type { Screenshot, Viewport } from './config';
|
|
6
|
-
import { getConfigPath, loadConfig, saveConfig } from './configFile';
|
|
7
|
-
|
|
8
|
-
const PROFILE_DIR = path.join(homedir(), '.heroshot', 'browser-profile');
|
|
9
|
-
const TOOLBAR_DIR = path.join(import.meta.dirname, '..', 'toolbar');
|
|
10
|
-
|
|
11
|
-
export function getProfilePath(): string {
|
|
12
|
-
return PROFILE_DIR;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const DEFAULT_VIEWPORT: Viewport = { width: 1280, height: 800 };
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Browser channels to try in order of preference.
|
|
19
|
-
* System Chrome first (no download needed), then Playwright's bundled Chromium.
|
|
20
|
-
*/
|
|
21
|
-
const BROWSER_CHANNELS: readonly string[] = ['chrome', 'chromium'];
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Launch browser with persistent profile, trying system Chrome first.
|
|
25
|
-
* Falls back to Playwright's bundled Chromium if Chrome isn't installed.
|
|
26
|
-
*/
|
|
27
|
-
export async function launchPersistentBrowser(
|
|
28
|
-
options: { headless?: boolean; viewport?: Viewport } = {}
|
|
29
|
-
): Promise<BrowserContext> {
|
|
30
|
-
const viewport = options.viewport ?? DEFAULT_VIEWPORT;
|
|
31
|
-
const baseOptions = {
|
|
32
|
-
headless: options.headless ?? false,
|
|
33
|
-
viewport,
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// Try each browser channel in order
|
|
37
|
-
for (const channel of BROWSER_CHANNELS) {
|
|
38
|
-
try {
|
|
39
|
-
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
40
|
-
...baseOptions,
|
|
41
|
-
channel,
|
|
42
|
-
});
|
|
43
|
-
return context;
|
|
44
|
-
} catch {
|
|
45
|
-
// This channel failed, try next one
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// All channels failed - throw error with helpful message
|
|
51
|
-
const message = [
|
|
52
|
-
'',
|
|
53
|
-
'Error: No browser found.',
|
|
54
|
-
'',
|
|
55
|
-
'Heroshot needs a browser to capture screenshots. Options:',
|
|
56
|
-
'',
|
|
57
|
-
' 1. Install Chrome (recommended):',
|
|
58
|
-
' https://www.google.com/chrome/',
|
|
59
|
-
'',
|
|
60
|
-
' 2. Or install Playwright browsers:',
|
|
61
|
-
' npx playwright install chromium',
|
|
62
|
-
'',
|
|
63
|
-
].join('\n');
|
|
64
|
-
|
|
65
|
-
throw new Error(message);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface ScreenshotData {
|
|
69
|
-
id: string;
|
|
70
|
-
name: string;
|
|
71
|
-
url: string;
|
|
72
|
-
selector: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Job types that CLI can send to toolbar
|
|
76
|
-
type ToolbarJob =
|
|
77
|
-
| { type: 'highlight'; selector: string }
|
|
78
|
-
| { type: 'navigate-and-highlight'; url: string; selector: string };
|
|
79
|
-
|
|
80
|
-
// Events that toolbar sends to CLI
|
|
81
|
-
type ToolbarEvent =
|
|
82
|
-
| { type: 'screenshot-added'; data: ScreenshotData }
|
|
83
|
-
| { type: 'screenshot-selected'; id: string; url: string; selector: string }
|
|
84
|
-
| { type: 'screenshot-removed'; id: string }
|
|
85
|
-
| { type: 'job-complete' }
|
|
86
|
-
| { type: 'done' };
|
|
87
|
-
|
|
88
|
-
const exposedPages = new WeakSet<Page>();
|
|
89
|
-
|
|
90
|
-
async function injectToolbar(
|
|
91
|
-
page: Page,
|
|
92
|
-
initialScreenshots: ScreenshotData[],
|
|
93
|
-
pendingJob: ToolbarJob | null,
|
|
94
|
-
onEvent: (event: ToolbarEvent) => void
|
|
95
|
-
): Promise<void> {
|
|
96
|
-
// Expose single event handler to page (only once per page)
|
|
97
|
-
// All toolbar events go through this single channel
|
|
98
|
-
if (!exposedPages.has(page)) {
|
|
99
|
-
await page.exposeFunction('__heroshotEmit', (eventJson: string) => {
|
|
100
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- JSON from trusted toolbar
|
|
101
|
-
const event: ToolbarEvent = JSON.parse(eventJson);
|
|
102
|
-
onEvent(event);
|
|
103
|
-
});
|
|
104
|
-
exposedPages.add(page);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Initialize or update __heroshot namespace
|
|
108
|
-
// Using string to avoid tsx transpilation issues (__name helper)
|
|
109
|
-
const screenshotsJson = JSON.stringify(initialScreenshots);
|
|
110
|
-
const pendingJobJson = JSON.stringify(pendingJob);
|
|
111
|
-
|
|
112
|
-
// Check if toolbar is already initialized - if so, just update the job
|
|
113
|
-
const alreadyInitialized = await page.evaluate('globalThis.__heroshot?.initialized === true');
|
|
114
|
-
|
|
115
|
-
if (alreadyInitialized) {
|
|
116
|
-
// Toolbar already running - just update the pending job and trigger execution
|
|
117
|
-
await page.evaluate(`
|
|
118
|
-
globalThis.__heroshot.pendingJob = ${pendingJobJson};
|
|
119
|
-
// Dispatch custom event to notify toolbar of new job
|
|
120
|
-
window.dispatchEvent(new CustomEvent('heroshot-job', { detail: ${pendingJobJson} }));
|
|
121
|
-
`);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
await page.evaluate(`
|
|
126
|
-
globalThis.__heroshot = {
|
|
127
|
-
initialized: false,
|
|
128
|
-
screenshots: ${screenshotsJson},
|
|
129
|
-
pendingJob: ${pendingJobJson},
|
|
130
|
-
emit: function(event) {
|
|
131
|
-
globalThis.__heroshotEmit(JSON.stringify(event));
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
`);
|
|
135
|
-
|
|
136
|
-
// Inject toolbar JS (CSS is bundled via Shadow DOM)
|
|
137
|
-
const scriptPath = path.join(TOOLBAR_DIR, 'dist', 'toolbar.js');
|
|
138
|
-
const script = readFileSync(scriptPath, 'utf8');
|
|
139
|
-
await page.addScriptTag({ content: script });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export async function captureUrl(url: string, output: string): Promise<void> {
|
|
143
|
-
console.log(`Capturing: ${url}`);
|
|
144
|
-
|
|
145
|
-
const context = await launchPersistentBrowser({ headless: true });
|
|
146
|
-
const page = await context.newPage();
|
|
147
|
-
|
|
148
|
-
await page.goto(url, { waitUntil: 'networkidle' });
|
|
149
|
-
await page.screenshot({ path: output, fullPage: false });
|
|
150
|
-
|
|
151
|
-
await context.close();
|
|
152
|
-
|
|
153
|
-
console.log(`Saved: ${output}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function setup(): Promise<void> {
|
|
157
|
-
console.log('Opening browser for setup...');
|
|
158
|
-
console.log(`Profile will be saved to: ${PROFILE_DIR}`);
|
|
159
|
-
console.log('');
|
|
160
|
-
|
|
161
|
-
const configPath = getConfigPath();
|
|
162
|
-
const config = loadConfig(configPath);
|
|
163
|
-
const viewport = config.browser?.viewport ?? DEFAULT_VIEWPORT;
|
|
164
|
-
|
|
165
|
-
// Convert config screenshots to toolbar format - this is the running list
|
|
166
|
-
// that includes both original config items AND newly added items
|
|
167
|
-
const allScreenshots: ScreenshotData[] = config.screenshots.map(screenshot => ({
|
|
168
|
-
id: screenshot.id,
|
|
169
|
-
name: screenshot.name,
|
|
170
|
-
url: screenshot.url,
|
|
171
|
-
selector: screenshot.selector ?? '',
|
|
172
|
-
}));
|
|
173
|
-
|
|
174
|
-
// Track only NEW items added this session (for saving to config at the end)
|
|
175
|
-
const newlyAddedIds = new Set<string>();
|
|
176
|
-
let pendingJob: ToolbarJob | null = null;
|
|
177
|
-
|
|
178
|
-
const context = await launchPersistentBrowser({ headless: false, viewport });
|
|
179
|
-
|
|
180
|
-
// Handle events from toolbar
|
|
181
|
-
const handleEvent = (event: ToolbarEvent) => {
|
|
182
|
-
switch (event.type) {
|
|
183
|
-
case 'screenshot-added': {
|
|
184
|
-
allScreenshots.push(event.data);
|
|
185
|
-
newlyAddedIds.add(event.data.id);
|
|
186
|
-
console.log(`\nAdded: ${event.data.name} (${event.data.selector})`);
|
|
187
|
-
console.log(`URL: ${event.data.url}`);
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
case 'screenshot-selected': {
|
|
192
|
-
// User selected a screenshot - create job to highlight it
|
|
193
|
-
const [currentPage] = context.pages();
|
|
194
|
-
if (!currentPage) break;
|
|
195
|
-
|
|
196
|
-
const currentUrl = currentPage.url();
|
|
197
|
-
|
|
198
|
-
if (currentUrl === event.url) {
|
|
199
|
-
// Already on the right page - send highlight job via event
|
|
200
|
-
pendingJob = { type: 'highlight', selector: event.selector };
|
|
201
|
-
void currentPage.evaluate(`
|
|
202
|
-
window.dispatchEvent(new CustomEvent('heroshot-job', {
|
|
203
|
-
detail: { type: 'highlight', selector: ${JSON.stringify(event.selector)} }
|
|
204
|
-
}));
|
|
205
|
-
`);
|
|
206
|
-
} else {
|
|
207
|
-
// Navigate to the page - toolbar will get job on inject
|
|
208
|
-
pendingJob = { type: 'navigate-and-highlight', url: event.url, selector: event.selector };
|
|
209
|
-
void currentPage.goto(event.url, { waitUntil: 'domcontentloaded' });
|
|
210
|
-
}
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
case 'job-complete': {
|
|
215
|
-
pendingJob = null;
|
|
216
|
-
break;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
case 'done': {
|
|
220
|
-
void context.close();
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
const setupPage = (page: Page) => {
|
|
227
|
-
// Inject on every navigation within the page
|
|
228
|
-
page.on('domcontentloaded', async () => {
|
|
229
|
-
const url = page.url();
|
|
230
|
-
// Skip about:blank and other non-http pages
|
|
231
|
-
if (!url.startsWith('http')) return;
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
await injectToolbar(page, allScreenshots, pendingJob, handleEvent);
|
|
235
|
-
} catch {
|
|
236
|
-
// Toolbar injection can fail on some pages, ignore silently
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
// Handle new pages/tabs
|
|
242
|
-
context.on('page', page => {
|
|
243
|
-
setupPage(page);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// Use existing page from context or create one if none exists
|
|
247
|
-
const existingPages = context.pages();
|
|
248
|
-
const page = existingPages[0] ?? (await context.newPage());
|
|
249
|
-
setupPage(page);
|
|
250
|
-
|
|
251
|
-
console.log('Browser is open.');
|
|
252
|
-
console.log('1. Navigate to any site (e.g., https://example.com)');
|
|
253
|
-
console.log('2. Click the picker icon in the toolbar to select elements');
|
|
254
|
-
console.log('3. Click Done or close the browser when finished');
|
|
255
|
-
console.log('');
|
|
256
|
-
|
|
257
|
-
// Wait for browser to close (either via Done button or manual close)
|
|
258
|
-
await new Promise<void>(resolve => {
|
|
259
|
-
context.once('close', () => resolve());
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
console.log('');
|
|
263
|
-
if (newlyAddedIds.size > 0) {
|
|
264
|
-
// Reload config in case it was modified while browser was open
|
|
265
|
-
const latestConfig = loadConfig(configPath);
|
|
266
|
-
|
|
267
|
-
console.log('Saved screenshots:');
|
|
268
|
-
// Only save newly added items (not items that were already in config)
|
|
269
|
-
for (const element of allScreenshots) {
|
|
270
|
-
if (!newlyAddedIds.has(element.id)) continue;
|
|
271
|
-
|
|
272
|
-
// Slugify name for output filename (just filename, no path)
|
|
273
|
-
const filename =
|
|
274
|
-
element.name
|
|
275
|
-
.toLowerCase()
|
|
276
|
-
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
277
|
-
.replaceAll(/(?:^-|-$)/g, '') + '.png';
|
|
278
|
-
|
|
279
|
-
const screenshot: Screenshot = {
|
|
280
|
-
id: element.id,
|
|
281
|
-
name: element.name,
|
|
282
|
-
url: element.url,
|
|
283
|
-
selector: element.selector,
|
|
284
|
-
filename,
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
// Add or update screenshot
|
|
288
|
-
const existingIndex = latestConfig.screenshots.findIndex(item => item.id === element.id);
|
|
289
|
-
if (existingIndex === -1) {
|
|
290
|
-
latestConfig.screenshots.push(screenshot);
|
|
291
|
-
console.log(` + ${element.name}: ${element.selector}`);
|
|
292
|
-
} else {
|
|
293
|
-
latestConfig.screenshots[existingIndex] = screenshot;
|
|
294
|
-
console.log(` ~ ${element.name}: ${element.selector} (updated)`);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
saveConfig(configPath, latestConfig);
|
|
299
|
-
console.log(`\nConfig saved to: ${configPath}`);
|
|
300
|
-
}
|
|
301
|
-
console.log('You can now use `heroshot sync` to capture screenshots.');
|
|
302
|
-
}
|
package/src/capture.ts
DELETED
package/src/cli.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { existsSync, rmSync } from 'node:fs';
|
|
2
|
-
import { Command } from 'commander';
|
|
3
|
-
import { captureUrl, getProfilePath, setup } from './browser';
|
|
4
|
-
import { sync } from './sync';
|
|
5
|
-
|
|
6
|
-
const program = new Command();
|
|
7
|
-
|
|
8
|
-
program
|
|
9
|
-
.name('heroshot')
|
|
10
|
-
.description('Define your screenshots once, update them forever with one command')
|
|
11
|
-
.version('0.0.2-alpha.1');
|
|
12
|
-
|
|
13
|
-
program
|
|
14
|
-
.command('setup', { isDefault: true })
|
|
15
|
-
.description('Open browser to log in to sites you want to screenshot')
|
|
16
|
-
.option('--reset', 'Clear existing browser profile and start fresh')
|
|
17
|
-
.action(async (options: { reset?: boolean }) => {
|
|
18
|
-
if (options.reset) {
|
|
19
|
-
const profilePath = getProfilePath();
|
|
20
|
-
if (existsSync(profilePath)) {
|
|
21
|
-
rmSync(profilePath, { recursive: true });
|
|
22
|
-
console.log('Browser profile cleared.');
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
await setup();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
program
|
|
29
|
-
.command('capture <url> <output>')
|
|
30
|
-
.description('Capture a screenshot of a URL')
|
|
31
|
-
.action(async (url: string, output: string) => {
|
|
32
|
-
await captureUrl(url, output);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
program
|
|
36
|
-
.command('init')
|
|
37
|
-
.description('Create a heroshot.json config file')
|
|
38
|
-
.action(() => {
|
|
39
|
-
console.log('TODO: Create heroshot.json');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
program
|
|
43
|
-
.command('sync')
|
|
44
|
-
.description('Capture all screenshots defined in config')
|
|
45
|
-
.option('--id <id>', 'Only capture a specific screenshot by ID')
|
|
46
|
-
.action(async (options: { id?: string }) => {
|
|
47
|
-
const result = await sync(options);
|
|
48
|
-
if (result.failed > 0) {
|
|
49
|
-
process.exitCode = 1;
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
program
|
|
54
|
-
.command('check')
|
|
55
|
-
.description('Check if screenshots are up-to-date (for CI)')
|
|
56
|
-
.action(() => {
|
|
57
|
-
console.log('TODO: Check screenshots');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
program.parse();
|
package/src/config.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Generate a simple random UID (8 chars)
|
|
5
|
-
*/
|
|
6
|
-
function generateUid(): string {
|
|
7
|
-
// eslint-disable-next-line sonarjs/pseudo-random -- Not used for security, just unique IDs
|
|
8
|
-
return Math.random().toString(36).slice(2, 10);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Browser/viewport settings
|
|
12
|
-
const viewportSchema = z.object({
|
|
13
|
-
width: z.number().int().positive().default(1280),
|
|
14
|
-
height: z.number().int().positive().default(800),
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
// Beautification options
|
|
18
|
-
const beautifySchema = z.object({
|
|
19
|
-
shadow: z.boolean().default(false),
|
|
20
|
-
radius: z.number().int().nonnegative().default(0),
|
|
21
|
-
background: z.string().default('transparent'),
|
|
22
|
-
padding: z.number().int().nonnegative().default(0),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// Color scheme for light/dark mode
|
|
26
|
-
const colorSchemeSchema = z.enum(['light', 'dark', 'both']);
|
|
27
|
-
|
|
28
|
-
// Single screenshot definition
|
|
29
|
-
const screenshotSchema = z.object({
|
|
30
|
-
id: z.string().min(1).default(generateUid),
|
|
31
|
-
name: z.string().min(1),
|
|
32
|
-
url: z.url(),
|
|
33
|
-
filename: z.string().min(1),
|
|
34
|
-
selector: z.string().optional(),
|
|
35
|
-
viewport: viewportSchema.optional(),
|
|
36
|
-
waitFor: z.string().optional(),
|
|
37
|
-
delay: z.number().int().nonnegative().optional(),
|
|
38
|
-
colorScheme: colorSchemeSchema.optional(),
|
|
39
|
-
beautify: beautifySchema.partial().optional(),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
// Browser settings
|
|
43
|
-
const browserSchema = z.object({
|
|
44
|
-
viewport: viewportSchema.optional(),
|
|
45
|
-
colorScheme: colorSchemeSchema.optional(),
|
|
46
|
-
cdp: z.url().optional(), // Connect to existing Chrome
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
// Global config
|
|
50
|
-
const configSchema = z.object({
|
|
51
|
-
// Output directory for screenshots (relative to config file)
|
|
52
|
-
outputDirectory: z.string().default('.'),
|
|
53
|
-
|
|
54
|
-
// Default browser settings
|
|
55
|
-
browser: browserSchema.optional(),
|
|
56
|
-
|
|
57
|
-
// Default beautification
|
|
58
|
-
beautify: beautifySchema.partial().optional(),
|
|
59
|
-
|
|
60
|
-
// Screenshot definitions
|
|
61
|
-
screenshots: z.array(screenshotSchema).default([]),
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Infer types from schemas
|
|
65
|
-
export type Viewport = z.infer<typeof viewportSchema>;
|
|
66
|
-
export type Beautify = z.infer<typeof beautifySchema>;
|
|
67
|
-
export type ColorScheme = z.infer<typeof colorSchemeSchema>;
|
|
68
|
-
export type Screenshot = z.infer<typeof screenshotSchema>;
|
|
69
|
-
export type Browser = z.infer<typeof browserSchema>;
|
|
70
|
-
export type Config = z.infer<typeof configSchema>;
|
|
71
|
-
|
|
72
|
-
// Export schemas for validation
|
|
73
|
-
export const schemas = {
|
|
74
|
-
viewport: viewportSchema,
|
|
75
|
-
beautify: beautifySchema,
|
|
76
|
-
colorScheme: colorSchemeSchema,
|
|
77
|
-
screenshot: screenshotSchema,
|
|
78
|
-
browser: browserSchema,
|
|
79
|
-
config: configSchema,
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export function parseConfig(input: unknown): Config {
|
|
83
|
-
return configSchema.parse(input);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function validateConfig(
|
|
87
|
-
input: unknown
|
|
88
|
-
): { success: true; data: Config } | { success: false; error: z.ZodError } {
|
|
89
|
-
const result = configSchema.safeParse(input);
|
|
90
|
-
if (result.success) {
|
|
91
|
-
return { success: true, data: result.data };
|
|
92
|
-
}
|
|
93
|
-
return { success: false, error: result.error };
|
|
94
|
-
}
|
package/src/configFile.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { type Config, parseConfig } from './config';
|
|
4
|
-
|
|
5
|
-
const CONFIG_FILENAME = 'heroshot.json';
|
|
6
|
-
|
|
7
|
-
export function getConfigPath(directory: string = process.cwd()): string {
|
|
8
|
-
return path.join(directory, CONFIG_FILENAME);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function loadConfig(configPath: string): Config {
|
|
12
|
-
if (!existsSync(configPath)) {
|
|
13
|
-
// Return default config with Zod defaults applied
|
|
14
|
-
return parseConfig({});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const content = readFileSync(configPath, 'utf8');
|
|
18
|
-
const json: unknown = JSON.parse(content);
|
|
19
|
-
return parseConfig(json);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function saveConfig(configPath: string, config: Config): void {
|
|
23
|
-
const content = JSON.stringify(config, null, 2);
|
|
24
|
-
writeFileSync(configPath, content, 'utf8');
|
|
25
|
-
}
|
package/src/sync.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import type { ElementHandle, Page } from 'playwright';
|
|
4
|
-
import { launchPersistentBrowser } from './browser';
|
|
5
|
-
import type { Config, Screenshot } from './config';
|
|
6
|
-
import { getConfigPath, loadConfig } from './configFile';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Find element using shadow-piercing selector with retries
|
|
10
|
-
* The >>> syntax pierces shadow DOM boundaries
|
|
11
|
-
*/
|
|
12
|
-
async function findElement(
|
|
13
|
-
page: Page,
|
|
14
|
-
selector: string,
|
|
15
|
-
maxAttempts = 10,
|
|
16
|
-
intervalMs = 500
|
|
17
|
-
): Promise<ElementHandle | null> {
|
|
18
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
19
|
-
// Run shadow-piercing query in browser context
|
|
20
|
-
// The function body runs in browser where DOM types exist
|
|
21
|
-
const handle = await page.evaluateHandle(`
|
|
22
|
-
(() => {
|
|
23
|
-
const selector = ${JSON.stringify(selector)};
|
|
24
|
-
const parts = selector.split('>>>').map((part) => part.trim());
|
|
25
|
-
let current = document;
|
|
26
|
-
|
|
27
|
-
for (const part of parts) {
|
|
28
|
-
if (!part) continue;
|
|
29
|
-
|
|
30
|
-
const root = current instanceof Element
|
|
31
|
-
? (current.shadowRoot ?? current)
|
|
32
|
-
: current;
|
|
33
|
-
|
|
34
|
-
const found = root.querySelector(part);
|
|
35
|
-
if (!found) return null;
|
|
36
|
-
|
|
37
|
-
current = found;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return current instanceof Element ? current : null;
|
|
41
|
-
})()
|
|
42
|
-
`);
|
|
43
|
-
|
|
44
|
-
// Check if we got an element (not null/undefined)
|
|
45
|
-
const element = handle.asElement();
|
|
46
|
-
if (element) {
|
|
47
|
-
return element;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Dispose the handle if it's not an element
|
|
51
|
-
await handle.dispose();
|
|
52
|
-
|
|
53
|
-
if (attempt < maxAttempts) {
|
|
54
|
-
await page.waitForTimeout(intervalMs);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Capture a single screenshot
|
|
63
|
-
*/
|
|
64
|
-
async function captureScreenshot(
|
|
65
|
-
page: Page,
|
|
66
|
-
screenshot: Screenshot,
|
|
67
|
-
outputDirectory: string
|
|
68
|
-
): Promise<{ success: boolean; error?: string }> {
|
|
69
|
-
const { name, url, selector, filename } = screenshot;
|
|
70
|
-
|
|
71
|
-
console.log(` ${name}...`);
|
|
72
|
-
|
|
73
|
-
// Navigate to URL and wait for network to settle
|
|
74
|
-
try {
|
|
75
|
-
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
76
|
-
} catch (error) {
|
|
77
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
-
return { success: false, error: `Failed to navigate: ${message}` };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Extra wait for dynamic content (web components, lazy loading)
|
|
82
|
-
await page.waitForTimeout(1000);
|
|
83
|
-
|
|
84
|
-
const outputPath = path.join(outputDirectory, filename);
|
|
85
|
-
|
|
86
|
-
// Ensure output directory exists
|
|
87
|
-
const outputDirectoryPath = path.dirname(outputPath);
|
|
88
|
-
if (!existsSync(outputDirectoryPath)) {
|
|
89
|
-
mkdirSync(outputDirectoryPath, { recursive: true });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (selector) {
|
|
93
|
-
// Find element with shadow-piercing selector
|
|
94
|
-
const element = await findElement(page, selector);
|
|
95
|
-
|
|
96
|
-
if (!element) {
|
|
97
|
-
return {
|
|
98
|
-
success: false,
|
|
99
|
-
error: `Element not found: ${selector}`,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Screenshot the element
|
|
104
|
-
try {
|
|
105
|
-
await element.screenshot({ path: outputPath });
|
|
106
|
-
} catch (error) {
|
|
107
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
-
return { success: false, error: `Screenshot failed: ${message}` };
|
|
109
|
-
}
|
|
110
|
-
} else {
|
|
111
|
-
// Full page screenshot
|
|
112
|
-
try {
|
|
113
|
-
await page.screenshot({ path: outputPath, fullPage: false });
|
|
114
|
-
} catch (error) {
|
|
115
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
-
return { success: false, error: `Screenshot failed: ${message}` };
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return { success: true };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface SyncOptions {
|
|
124
|
-
id?: string;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
interface ScreenshotResult {
|
|
128
|
-
id: string;
|
|
129
|
-
name: string;
|
|
130
|
-
success: boolean;
|
|
131
|
-
error?: string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
interface SyncResult {
|
|
135
|
-
total: number;
|
|
136
|
-
success: number;
|
|
137
|
-
failed: number;
|
|
138
|
-
results: ScreenshotResult[];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Sync all screenshots defined in config
|
|
143
|
-
*/
|
|
144
|
-
export async function sync(options: SyncOptions = {}): Promise<SyncResult> {
|
|
145
|
-
const configPath = getConfigPath();
|
|
146
|
-
const config: Config = loadConfig(configPath);
|
|
147
|
-
|
|
148
|
-
if (config.screenshots.length === 0) {
|
|
149
|
-
console.log('No screenshots defined in config.');
|
|
150
|
-
return { total: 0, success: 0, failed: 0, results: [] };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Filter by ID if specified
|
|
154
|
-
const { id: filterId } = options;
|
|
155
|
-
const screenshots = filterId
|
|
156
|
-
? config.screenshots.filter(screenshot => screenshot.id === filterId)
|
|
157
|
-
: config.screenshots;
|
|
158
|
-
|
|
159
|
-
if (filterId && screenshots.length === 0) {
|
|
160
|
-
console.log(`No screenshot found with ID: ${filterId}`);
|
|
161
|
-
return { total: 0, success: 0, failed: 0, results: [] };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
console.log(`Syncing ${screenshots.length} screenshot(s)...`);
|
|
165
|
-
console.log('');
|
|
166
|
-
|
|
167
|
-
// Get output directory (relative to config file location)
|
|
168
|
-
const configDirectory = path.dirname(configPath);
|
|
169
|
-
const outputDirectory = path.resolve(configDirectory, config.outputDirectory);
|
|
170
|
-
|
|
171
|
-
// Launch browser with persistent profile (reuses auth sessions)
|
|
172
|
-
const viewport = config.browser?.viewport ?? { width: 1280, height: 800 };
|
|
173
|
-
const context = await launchPersistentBrowser({
|
|
174
|
-
headless: true,
|
|
175
|
-
viewport,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const page = await context.newPage();
|
|
179
|
-
const results: SyncResult['results'] = [];
|
|
180
|
-
|
|
181
|
-
for (const screenshot of screenshots) {
|
|
182
|
-
const result = await captureScreenshot(page, screenshot, outputDirectory);
|
|
183
|
-
|
|
184
|
-
results.push({
|
|
185
|
-
id: screenshot.id,
|
|
186
|
-
name: screenshot.name,
|
|
187
|
-
success: result.success,
|
|
188
|
-
error: result.error,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (result.success) {
|
|
192
|
-
console.log(` Saved: ${screenshot.filename}`);
|
|
193
|
-
} else {
|
|
194
|
-
console.log(` Failed: ${result.error ?? 'Unknown error'}`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
await context.close();
|
|
199
|
-
|
|
200
|
-
const { length: totalCount } = results;
|
|
201
|
-
const successfulResults = results.filter(({ success }) => success);
|
|
202
|
-
const { length: successCount } = successfulResults;
|
|
203
|
-
const failedCount = totalCount - successCount;
|
|
204
|
-
|
|
205
|
-
console.log('');
|
|
206
|
-
console.log(`Done: ${successCount} succeeded, ${failedCount} failed`);
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
total: totalCount,
|
|
210
|
-
success: successCount,
|
|
211
|
-
failed: failedCount,
|
|
212
|
-
results,
|
|
213
|
-
};
|
|
214
|
-
}
|