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/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
@@ -1,7 +0,0 @@
1
- import type { ScreenshotDefinition } from './types';
2
-
3
- export async function capture(_definition: ScreenshotDefinition): Promise<Buffer> {
4
- // TODO: Implement capture logic with Playwright
5
- await Promise.resolve();
6
- throw new Error('Not implemented');
7
- }
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
- }