mechanica 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -344,19 +344,24 @@ By default all blocks share one `blocks` chunk, cached across pages. Mark heavy,
344
344
  ```bash
345
345
  npx mechanica shot hero # screenshot one block, from its previewData
346
346
  npx mechanica shot /about --width 1440,390 # a whole page, at two widths
347
+ npx mechanica shot design/landing/index.html --width 1440,390 # a design handoff, the same way
348
+ npx mechanica shot /about --section "#pricing" --section "footer" # one PNG per section
349
+ npx mechanica shot /about --scroll 800,1600 # the viewport after scrolling (sticky, scroll-driven states)
347
350
  npx mechanica thumbs # page thumbnails for the editor's page browser
348
351
  npx mechanica thumbs --blocks # block thumbnails for the palette
349
352
  ```
350
353
 
351
354
  These drive a locally installed Chrome or Edge (or `--browser <path>`) and need Node.js 22+ or Bun. They reuse a dev server running on port 5173 and start one if there isn't. Screenshots go to `.mech/shots/`, which is handy for reviewing a block or letting an AI assistant check its own work.
352
355
 
356
+ `shot` also takes any `http(s)://` URL, a `file://` URL or a path to a local `.html` file, and renders it with the same browser, widths, waits and animation freeze as your own pages, without a dev server. That lets you compare a design handoff with the page you built from it, section by section: run both with the same `--width` and `--section` flags. `--scroll <px>` scrolls first and captures what is on screen, so sticky stages and scroll-revealed content show their later states. `--no-freeze` leaves animations and transitions running. For your own scripts (for example, measuring computed styles in the design and on the site), `mechanica/browser` exposes the same headless browser (see [API](#api)).
357
+
353
358
  ## CLI
354
359
 
355
360
  | Command | What it does |
356
361
  | --- | --- |
357
362
  | `mechanica export` | Build, then render every page to static HTML in `export/`. Flags: `--site-url`, `--site-name`, `--assets-url` (serve `/assets` and `/media` from a CDN) |
358
363
  | `mechanica build` | Build the client and server bundles into `dist/` (`export` runs this for you) |
359
- | `mechanica shot <blockId \| /path>` | Screenshot a block or a page. Flags: `--width`, `--data`, `--out`, `--full`, `--server`, `--browser` |
364
+ | `mechanica shot <blockId \| /path \| url \| file.html>` | Screenshot a block, a page, any URL or a local HTML file (a design handoff; no dev server needed). Flags: `--width`, `--section <css>` (repeatable, one PNG each), `--scroll <px[,px…]>` (the viewport after scrolling), `--no-freeze`, `--full`, `--data` (blocks), `--out`, `--server`, `--browser` |
360
365
  | `mechanica thumbs [--blocks]` | Regenerate page or block thumbnails for the editor |
361
366
  | `mechanica images` | Generate image dimensions and blur-up previews for `.mech/assets` (needs `sharp`; `--force` redoes all) |
362
367
  | `mechanica login` | Sign in to the Mechanica platform: it prints a link to open and approve in the browser (signing in or registering on the way) and keeps the access token for this machine. In a terminal it waits for the approval; run from a coding agent, it prints the link and returns, and the next `link` or `push` finishes the sign-in (`--wait`, `--no-wait`, `--host`, or `--token` to store a token made in the dashboard) |
@@ -394,6 +399,21 @@ From `mechanica`:
394
399
 
395
400
  From `mechanica/plugin`: `mechanica` and the `PageProvider` type. From `mechanica/widgets`: `defineWidget` and `useWidgetServices` (uploads and the image picker inside widget editors). `mechanica/editor` and `mechanica/composer` are loaded by the plugin in development; you don't import them. From `mechanica/export`: `exportBuilt(projectDir, options)`, which statically renders an already built project (`dist/` + `.mech/`) without running a build and resolves to `{ pages, warnings }` — for render services that receive a prebuilt bundle; `options.outDir` sets where the site is written, and `options.fetchJson` replaces how `useFetch` requests are made.
396
401
 
402
+ From `mechanica/browser`: the headless browser behind `mechanica shot`, for your own Node 22+ or Bun scripts. It needs no dev server and no Vite. `openPage(url, { width, height, browser })` launches a local Chrome or Edge and resolves to a page with `evaluate(expression)`, `setViewport(width, height)`, `scrollTo(y)`, `settle()` (fonts and images), `freezeAnimations()`, `sectionClip(selector)`, `screenshot({ clip, section, full, scale })` (a PNG `Buffer`; with no options, what is in the viewport), `goto(url)`, `messages` (console errors and page errors) and `close()`. `findDevServer()` returns the origin of a running dev server, or `null`.
403
+
404
+ ```js
405
+ import { openPage } from 'mechanica/browser'
406
+
407
+ const page = await openPage(new URL('design/landing/index.html', import.meta.url).href, { width: 390 })
408
+ try {
409
+ const size = await page.evaluate(`getComputedStyle(document.querySelector('#hero h1')).fontSize`)
410
+ await page.scrollTo(1200)
411
+ const png = await page.screenshot({ section: '#hero' })
412
+ } finally {
413
+ await page.close()
414
+ }
415
+ ```
416
+
397
417
  ## Upgrading from 2.0
398
418
 
399
419
  - Uploaded files are addressed as `/media/<file>` in page files, shared data and rich text; 2.0 wrote `/@mechanica/assets/<file>`. The old address still works everywhere, so nothing breaks — run `npx mechanica migrate` once to rewrite it, and don't serve anything of your own under `/media/`.
@@ -0,0 +1,2 @@
1
+ import { a as MAX_SHOT_HEIGHT, i as openPage, n as PAGE_LOADED, o as findDevServer, r as describeSectionError, t as NavigationError } from "./chunks/browser-BCfAv8bW.js";
2
+ export { MAX_SHOT_HEIGHT, NavigationError, PAGE_LOADED, describeSectionError, findDevServer, openPage };
@@ -0,0 +1,558 @@
1
+ import fs from "node:fs";
2
+ import { join } from "node:path";
3
+ import os from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ //#region src/cli/headless.ts
6
+ /**
7
+ * Headless-browser plumbing shared by `mechanica shot`, `mechanica thumbs` and
8
+ * the public `mechanica/browser` helper: browser discovery/spawn, a minimal
9
+ * CDP client, page-session helpers, and probing for a running dev server.
10
+ *
11
+ * Talks raw CDP over the platform `WebSocket` instead of using an automation
12
+ * library: Playwright/Puppeteer launch handshakes rely on Node-only fd pipes
13
+ * that hang under Bun, and screenshots need only a handful of CDP calls.
14
+ *
15
+ * Never imports Vite: booting an ephemeral dev server lives in `dev-server.ts`,
16
+ * so `mechanica/browser` stays usable from plain Node scripts.
17
+ */
18
+ /** Where `mechanica shot`/`thumbs` look for a running dev server first. */
19
+ var DEV_SERVER_ORIGINS = ["http://127.0.0.1:5173", "http://localhost:5173"];
20
+ /** The origin of a running Mechanica dev server on the default port, or null. */
21
+ async function findDevServer(origins = DEV_SERVER_ORIGINS) {
22
+ for (const origin of origins) if (await isMechanicaServer(origin)) return origin;
23
+ return null;
24
+ }
25
+ async function isMechanicaServer(origin) {
26
+ try {
27
+ return (await fetch(`${origin}/@mechanica/folders`, { signal: AbortSignal.timeout(1e3) })).ok;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+ async function setViewport(cdp, sessionId, width, height = 900) {
33
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
34
+ width,
35
+ height,
36
+ deviceScaleFactor: 1,
37
+ mobile: false
38
+ }, sessionId);
39
+ }
40
+ async function evaluateValue(cdp, sessionId, expression) {
41
+ return (await cdp.send("Runtime.evaluate", {
42
+ expression,
43
+ returnByValue: true
44
+ }, sessionId)).result?.value;
45
+ }
46
+ /**
47
+ * Poll an expression on the page until it is `true` or the timeout elapses.
48
+ * Evaluate errors (execution context destroyed mid-navigation) count as "not
49
+ * ready yet" rather than failing the wait.
50
+ */
51
+ async function waitForCondition(cdp, sessionId, expression, timeout) {
52
+ const deadline = Date.now() + timeout;
53
+ while (Date.now() < deadline) {
54
+ try {
55
+ if (await evaluateValue(cdp, sessionId, expression)) return;
56
+ } catch {}
57
+ await new Promise((resolve) => setTimeout(resolve, 100));
58
+ }
59
+ throw new Error(`Timed out after ${timeout}ms waiting for the page to become ready`);
60
+ }
61
+ /** Upper bound for {@link settlePageMedia}: a stalled image must not hang a shot. */
62
+ var SETTLE_TIMEOUT = 1e4;
63
+ /**
64
+ * Wait for web fonts and all images — normal pages have no injected ready flag.
65
+ * Lazy images are switched to eager first: below the fold they would never
66
+ * start loading in a headless tab (the wait used to hang on them), and a
67
+ * full-page capture should show them anyway. Gives up after `timeout` ms.
68
+ */
69
+ async function settlePageMedia(cdp, sessionId, timeout = SETTLE_TIMEOUT) {
70
+ await cdp.send("Runtime.evaluate", {
71
+ expression: `(() => {
72
+ for (const img of document.images) if (img.loading === 'lazy') img.loading = 'eager'
73
+ const media = Promise.all([
74
+ document.fonts ? document.fonts.ready : null,
75
+ ...[...document.images].map((img) =>
76
+ img.complete ? null : new Promise((done) => {
77
+ img.addEventListener('load', done, { once: true })
78
+ img.addEventListener('error', done, { once: true })
79
+ })),
80
+ ].filter(Boolean))
81
+ return Promise.race([media, new Promise((done) => setTimeout(done, ${timeout}))]).then(() => true)
82
+ })()`,
83
+ awaitPromise: true,
84
+ returnByValue: true
85
+ }, sessionId);
86
+ }
87
+ var FREEZE_STYLE_ID = "mechanica-freeze-animations";
88
+ /** Freeze animations/transitions so captures are deterministic across runs. Idempotent. */
89
+ async function freezeAnimations(cdp, sessionId) {
90
+ await cdp.send("Runtime.evaluate", { expression: `{
91
+ if (!document.getElementById('${FREEZE_STYLE_ID}')) {
92
+ const style = document.createElement('style')
93
+ style.id = '${FREEZE_STYLE_ID}'
94
+ style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }'
95
+ document.head.appendChild(style)
96
+ }
97
+ }` }, sessionId);
98
+ }
99
+ /** Undo {@link freezeAnimations}: transitions and animations run again. */
100
+ async function unfreezeAnimations(cdp, sessionId) {
101
+ await cdp.send("Runtime.evaluate", { expression: `document.getElementById('${FREEZE_STYLE_ID}')?.remove()` }, sessionId);
102
+ }
103
+ /** Wait two animation frames: a full style → layout → paint cycle has run. */
104
+ async function nextFrames(cdp, sessionId) {
105
+ await cdp.send("Runtime.evaluate", {
106
+ expression: "new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))",
107
+ awaitPromise: true
108
+ }, sessionId);
109
+ }
110
+ /** Chromium refuses to encode arbitrarily tall captures — cap and say so. */
111
+ var MAX_SHOT_HEIGHT = 12e3;
112
+ /** The full document size as a screenshot clip. */
113
+ async function fullPageClip(cdp, sessionId) {
114
+ const metrics = await cdp.send("Page.getLayoutMetrics", {}, sessionId);
115
+ const size = metrics.cssContentSize ?? metrics.contentSize;
116
+ const height = Math.min(size.height, MAX_SHOT_HEIGHT);
117
+ if (height < size.height) console.info(`(page is ${Math.round(size.height)}px tall — clipped to ${MAX_SHOT_HEIGHT}px)`);
118
+ return {
119
+ x: 0,
120
+ y: 0,
121
+ width: size.width,
122
+ height
123
+ };
124
+ }
125
+ /** What is on screen right now (the visual viewport at its scroll offset), as a clip. */
126
+ async function viewportClip(cdp, sessionId) {
127
+ const metrics = await cdp.send("Page.getLayoutMetrics", {}, sessionId);
128
+ const viewport = metrics.cssVisualViewport ?? metrics.visualViewport;
129
+ return {
130
+ x: viewport.pageX,
131
+ y: viewport.pageY,
132
+ width: viewport.clientWidth,
133
+ height: viewport.clientHeight
134
+ };
135
+ }
136
+ /**
137
+ * Spawn a headless Chromium-based browser with a DevTools endpoint and connect
138
+ * to it. Prefers browsers already on the machine (Edge ships with Windows,
139
+ * Chrome is everywhere), so nothing needs downloading.
140
+ */
141
+ async function launchBrowser(explicitPath) {
142
+ const executable = findBrowserExecutable(explicitPath);
143
+ const userDataDir = fs.mkdtempSync(join(os.tmpdir(), "mechanica-shot-"));
144
+ const child = spawn(executable.path, [
145
+ "--headless=new",
146
+ "--remote-debugging-port=0",
147
+ `--user-data-dir=${userDataDir}`,
148
+ "--no-first-run",
149
+ "--no-default-browser-check",
150
+ "--disable-extensions",
151
+ "--hide-scrollbars",
152
+ "about:blank"
153
+ ], { stdio: [
154
+ "ignore",
155
+ "ignore",
156
+ "pipe"
157
+ ] });
158
+ const cleanup = async () => {
159
+ const exited = new Promise((resolve) => {
160
+ if (child.exitCode != null) return resolve();
161
+ child.once("exit", () => resolve());
162
+ setTimeout(resolve, 3e3);
163
+ });
164
+ child.kill();
165
+ await exited;
166
+ for (let attempt = 0; attempt < 3; attempt++) try {
167
+ fs.rmSync(userDataDir, {
168
+ recursive: true,
169
+ force: true
170
+ });
171
+ return;
172
+ } catch {
173
+ await new Promise((resolve) => setTimeout(resolve, 300));
174
+ }
175
+ };
176
+ let endpoint;
177
+ try {
178
+ endpoint = await waitForDevtoolsEndpoint(child, 2e4);
179
+ } catch (error) {
180
+ await cleanup();
181
+ throw new Error(`Failed to start ${executable.label} (${executable.path}): ${error instanceof Error ? error.message : error}`);
182
+ }
183
+ const client = await CdpClient.connect(endpoint);
184
+ return {
185
+ client,
186
+ close: async () => {
187
+ await client.send("Browser.close").catch(() => {});
188
+ client.dispose();
189
+ await cleanup();
190
+ }
191
+ };
192
+ }
193
+ /** Read the spawned browser's stderr until it prints its DevTools WebSocket URL. */
194
+ function waitForDevtoolsEndpoint(child, timeout) {
195
+ return new Promise((resolve, reject) => {
196
+ let buffer = "";
197
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`no DevTools endpoint within ${timeout}ms`)), timeout);
198
+ child.stderr.on("data", (chunk) => {
199
+ buffer += chunk.toString();
200
+ const match = buffer.match(/DevTools listening on (ws:\/\/\S+)/);
201
+ if (match) {
202
+ clearTimeout(timer);
203
+ resolve(match[1]);
204
+ }
205
+ });
206
+ child.on("error", (error) => {
207
+ clearTimeout(timer);
208
+ reject(error);
209
+ });
210
+ child.on("exit", (code) => {
211
+ clearTimeout(timer);
212
+ reject(/* @__PURE__ */ new Error(`browser exited early (code ${code})`));
213
+ });
214
+ });
215
+ }
216
+ /** Find a Chromium-based executable: an explicit path, or a known install location. */
217
+ function findBrowserExecutable(explicitPath) {
218
+ if (explicitPath) {
219
+ if (!fs.existsSync(explicitPath)) throw new Error(`--browser not found: ${explicitPath}`);
220
+ return {
221
+ label: "browser from --browser",
222
+ path: explicitPath
223
+ };
224
+ }
225
+ const candidates = platformBrowserCandidates();
226
+ const found = candidates.find((candidate) => fs.existsSync(candidate.path));
227
+ if (!found) {
228
+ const looked = candidates.map((c) => ` ${c.label}: ${c.path}`).join("\n");
229
+ throw new Error(`No Chromium-based browser found. Looked for:\n${looked}\nInstall Chrome or Edge, or pass --browser <path-to-executable>.`);
230
+ }
231
+ return found;
232
+ }
233
+ function platformBrowserCandidates() {
234
+ if (process.platform === "win32") {
235
+ const programFiles = process.env["ProgramFiles"] ?? "C:\\Program Files";
236
+ const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
237
+ const localAppData = process.env["LOCALAPPDATA"] ?? join(os.homedir(), "AppData", "Local");
238
+ return [
239
+ {
240
+ label: "Microsoft Edge",
241
+ path: join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe")
242
+ },
243
+ {
244
+ label: "Microsoft Edge",
245
+ path: join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe")
246
+ },
247
+ {
248
+ label: "Google Chrome",
249
+ path: join(programFiles, "Google", "Chrome", "Application", "chrome.exe")
250
+ },
251
+ {
252
+ label: "Google Chrome",
253
+ path: join(localAppData, "Google", "Chrome", "Application", "chrome.exe")
254
+ }
255
+ ];
256
+ }
257
+ if (process.platform === "darwin") return [
258
+ {
259
+ label: "Google Chrome",
260
+ path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
261
+ },
262
+ {
263
+ label: "Microsoft Edge",
264
+ path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
265
+ },
266
+ {
267
+ label: "Chromium",
268
+ path: "/Applications/Chromium.app/Contents/MacOS/Chromium"
269
+ }
270
+ ];
271
+ return [
272
+ {
273
+ label: "Google Chrome",
274
+ path: "/usr/bin/google-chrome"
275
+ },
276
+ {
277
+ label: "Chromium",
278
+ path: "/usr/bin/chromium"
279
+ },
280
+ {
281
+ label: "Chromium",
282
+ path: "/usr/bin/chromium-browser"
283
+ },
284
+ {
285
+ label: "Microsoft Edge",
286
+ path: "/usr/bin/microsoft-edge"
287
+ }
288
+ ];
289
+ }
290
+ /**
291
+ * A minimal Chrome DevTools Protocol client over the platform `WebSocket`
292
+ * (native in Bun and Node ≥ 22): request/response matching by id, plus event
293
+ * fan-out. Flat session mode — pass a `sessionId` to address a page target.
294
+ */
295
+ var CdpClient = class CdpClient {
296
+ ws;
297
+ nextId = 1;
298
+ pending = /* @__PURE__ */ new Map();
299
+ listeners = [];
300
+ constructor(ws) {
301
+ this.ws = ws;
302
+ ws.onmessage = (event) => this.onMessage(String(event.data));
303
+ ws.onclose = () => {
304
+ for (const { reject } of this.pending.values()) reject(/* @__PURE__ */ new Error("CDP connection closed"));
305
+ this.pending.clear();
306
+ };
307
+ }
308
+ static connect(url) {
309
+ return new Promise((resolve, reject) => {
310
+ const ws = new WebSocket(url);
311
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`CDP connect timeout: ${url}`)), 1e4);
312
+ ws.onopen = () => {
313
+ clearTimeout(timer);
314
+ resolve(new CdpClient(ws));
315
+ };
316
+ ws.onerror = () => {
317
+ clearTimeout(timer);
318
+ reject(/* @__PURE__ */ new Error(`CDP connection failed: ${url}`));
319
+ };
320
+ });
321
+ }
322
+ send(method, params = {}, sessionId) {
323
+ const id = this.nextId++;
324
+ this.ws.send(JSON.stringify({
325
+ id,
326
+ method,
327
+ params,
328
+ ...sessionId ? { sessionId } : {}
329
+ }));
330
+ return new Promise((resolve, reject) => this.pending.set(id, {
331
+ resolve,
332
+ reject
333
+ }));
334
+ }
335
+ onEvent(listener) {
336
+ this.listeners.push(listener);
337
+ }
338
+ dispose() {
339
+ try {
340
+ this.ws.close();
341
+ } catch {}
342
+ }
343
+ onMessage(raw) {
344
+ const message = JSON.parse(raw);
345
+ if (typeof message.id === "number") {
346
+ const pending = this.pending.get(message.id);
347
+ if (!pending) return;
348
+ this.pending.delete(message.id);
349
+ if (message.error) pending.reject(/* @__PURE__ */ new Error(`${message.error.message ?? "CDP error"}`));
350
+ else pending.resolve(message.result ?? {});
351
+ return;
352
+ }
353
+ if (typeof message.method === "string") for (const listener of this.listeners) listener(message.method, message.params ?? {}, message.sessionId);
354
+ }
355
+ };
356
+ //#endregion
357
+ //#region src/cli/browser.ts
358
+ /**
359
+ * `mechanica/browser` — the headless browser behind `mechanica shot` and
360
+ * `mechanica thumbs`, for a site's own scripts: open a page (a dev-server URL,
361
+ * a design handoff's `file://` URL, any site), resize it, scroll it, measure it
362
+ * with `evaluate`, and screenshot it or one section of it.
363
+ *
364
+ * Drives a Chrome/Edge already installed on the machine over raw CDP
365
+ * (`headless.ts`), so there is nothing to download. Needs a global `WebSocket`
366
+ * (Node 22+ or Bun) and never imports Vite.
367
+ */
368
+ /** Readiness of an ordinary page: the load event has fired. */
369
+ var PAGE_LOADED = "document.readyState === 'complete'";
370
+ var DEFAULT_WIDTH = 1440;
371
+ var DEFAULT_HEIGHT = 900;
372
+ var DEFAULT_TIMEOUT = 15e3;
373
+ /** `goto` could not open the URL at all (DNS, refused connection, missing file…). */
374
+ var NavigationError = class extends Error {
375
+ url;
376
+ reason;
377
+ constructor(url, reason) {
378
+ super(`Failed to open ${url}: ${reason}`);
379
+ this.url = url;
380
+ this.reason = reason;
381
+ this.name = "NavigationError";
382
+ }
383
+ };
384
+ /**
385
+ * Launch a headless browser with one tab, sized and media-emulated like
386
+ * `mechanica shot`, and open `url` in it (omit `url` for a blank tab to
387
+ * `goto` later). Always `close()` the page — it owns the browser process.
388
+ *
389
+ * ```ts
390
+ * import { openPage } from 'mechanica/browser'
391
+ *
392
+ * const page = await openPage('file:///…/design/index.html', { width: 390 })
393
+ * try {
394
+ * const size = await page.evaluate<string>(`getComputedStyle(document.querySelector('h1')).fontSize`)
395
+ * fs.writeFileSync('hero.png', await page.screenshot({ section: '#hero' }))
396
+ * } finally {
397
+ * await page.close()
398
+ * }
399
+ * ```
400
+ */
401
+ async function openPage(url, options = {}) {
402
+ const browser = await launchBrowser(options.browser);
403
+ try {
404
+ const page = await HeadlessPage.create(browser, options);
405
+ if (url) await page.goto(url, options);
406
+ return page;
407
+ } catch (error) {
408
+ await browser.close();
409
+ throw error;
410
+ }
411
+ }
412
+ /** The message for a `sectionClip` that found nothing usable. */
413
+ function describeSectionError(selector, box) {
414
+ switch (box.error) {
415
+ case "invalid": return `"${selector}" is not a valid CSS selector (${box.message})`;
416
+ case "missing": return `No element matches "${selector}"` + (box.ids.length ? `. Ids on the page: ${box.ids.join(", ")}` : "");
417
+ case "empty": return `"${selector}" matched an element with an empty box (${Math.round(box.width)}×${Math.round(box.height)}) — is it hidden at this width?`;
418
+ }
419
+ }
420
+ var HeadlessPage = class HeadlessPage {
421
+ browser;
422
+ sessionId;
423
+ messages = [];
424
+ height;
425
+ constructor(browser, sessionId, height) {
426
+ this.browser = browser;
427
+ this.sessionId = sessionId;
428
+ this.height = height;
429
+ }
430
+ static async create(browser, options) {
431
+ const cdp = browser.client;
432
+ const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
433
+ const { sessionId } = await cdp.send("Target.attachToTarget", {
434
+ targetId,
435
+ flatten: true
436
+ });
437
+ const page = new HeadlessPage(browser, sessionId, options.height ?? DEFAULT_HEIGHT);
438
+ cdp.onEvent((method, params, eventSession) => {
439
+ if (eventSession !== sessionId) return;
440
+ if (method === "Runtime.consoleAPICalled" && (params.type === "error" || params.type === "warning")) {
441
+ const text = (params.args ?? []).map((arg) => arg.value ?? arg.description ?? "").join(" ");
442
+ page.messages.push(`[console.${params.type}] ${text}`);
443
+ }
444
+ if (method === "Runtime.exceptionThrown") {
445
+ const details = params.exceptionDetails;
446
+ page.messages.push(`[pageerror] ${details?.exception?.description ?? details?.text ?? "unknown error"}`);
447
+ }
448
+ });
449
+ await cdp.send("Runtime.enable", {}, sessionId);
450
+ await cdp.send("Page.enable", {}, sessionId);
451
+ await setViewport(cdp, sessionId, options.width ?? DEFAULT_WIDTH, page.height);
452
+ if (options.reducedMotion !== false) await cdp.send("Emulation.setEmulatedMedia", { features: [{
453
+ name: "prefers-reduced-motion",
454
+ value: "reduce"
455
+ }] }, sessionId);
456
+ return page;
457
+ }
458
+ send(method, params = {}) {
459
+ return this.browser.client.send(method, params, this.sessionId);
460
+ }
461
+ async goto(url, options = {}) {
462
+ const navigation = await this.send("Page.navigate", { url });
463
+ if (navigation.errorText) throw new NavigationError(url, navigation.errorText);
464
+ await this.waitFor(options.ready ?? "document.readyState === 'complete'", options.timeout ?? DEFAULT_TIMEOUT);
465
+ if (options.settle !== false) await this.settle();
466
+ }
467
+ async evaluate(expression) {
468
+ const reply = await this.send("Runtime.evaluate", {
469
+ expression,
470
+ returnByValue: true,
471
+ awaitPromise: true
472
+ });
473
+ if (reply.exceptionDetails) {
474
+ const details = reply.exceptionDetails;
475
+ throw new Error(`Evaluation failed: ${details.exception?.description ?? details.text ?? "unknown error"}`);
476
+ }
477
+ return reply.result?.value;
478
+ }
479
+ waitFor(expression, timeout = DEFAULT_TIMEOUT) {
480
+ return waitForCondition(this.browser.client, this.sessionId, expression, timeout);
481
+ }
482
+ async setViewport(width, height = this.height) {
483
+ this.height = height;
484
+ await setViewport(this.browser.client, this.sessionId, width, height);
485
+ }
486
+ async scrollTo(y) {
487
+ await this.evaluate(`window.scrollTo({ top: ${Number(y)}, left: 0, behavior: 'instant' })`);
488
+ await this.nextFrame();
489
+ return this.evaluate("scrollY");
490
+ }
491
+ nextFrame() {
492
+ return nextFrames(this.browser.client, this.sessionId);
493
+ }
494
+ settle() {
495
+ return settlePageMedia(this.browser.client, this.sessionId, SETTLE_TIMEOUT);
496
+ }
497
+ freezeAnimations() {
498
+ return freezeAnimations(this.browser.client, this.sessionId);
499
+ }
500
+ unfreezeAnimations() {
501
+ return unfreezeAnimations(this.browser.client, this.sessionId);
502
+ }
503
+ async sectionClip(selector) {
504
+ const box = await this.evaluate(`((selector) => {
505
+ let element
506
+ try {
507
+ element = document.querySelector(selector)
508
+ } catch (error) {
509
+ return { error: 'invalid', message: String((error && error.message) || error) }
510
+ }
511
+ if (!element) {
512
+ // A hint for the next try: readable ids of page elements (not SVG internals or generated ids).
513
+ const ids = [...document.querySelectorAll('body [id]')]
514
+ .filter((el) => !el.closest('svg'))
515
+ .map((el) => el.id)
516
+ .filter((id) => /^[A-Za-z][\\w-]{0,39}$/.test(id) && !/^w-node-|[0-9a-f]{8}/i.test(id))
517
+ return { error: 'missing', ids: [...new Set(ids)].slice(0, 20).map((id) => '#' + id) }
518
+ }
519
+ const rect = element.getBoundingClientRect()
520
+ if (rect.width < 1 || rect.height < 1) return { error: 'empty', width: rect.width, height: rect.height }
521
+ return { x: rect.x + scrollX, y: rect.y + scrollY, width: rect.width, height: rect.height }
522
+ })(${JSON.stringify(selector)})`);
523
+ if ("error" in box) throw new Error(describeSectionError(selector, box));
524
+ return box;
525
+ }
526
+ async screenshot(options = {}) {
527
+ const cdp = this.browser.client;
528
+ let clip;
529
+ let beyondViewport = true;
530
+ if (options.clip) clip = options.clip;
531
+ else if (options.section) {
532
+ const box = await this.sectionClip(options.section);
533
+ clip = {
534
+ ...box,
535
+ height: Math.min(box.height, MAX_SHOT_HEIGHT)
536
+ };
537
+ if (clip.height < box.height) console.info(`("${options.section}" is ${Math.round(box.height)}px tall — clipped to ${MAX_SHOT_HEIGHT}px)`);
538
+ } else if (options.full) clip = await fullPageClip(cdp, this.sessionId);
539
+ else {
540
+ clip = await viewportClip(cdp, this.sessionId);
541
+ beyondViewport = false;
542
+ }
543
+ const shot = await this.send("Page.captureScreenshot", {
544
+ format: "png",
545
+ captureBeyondViewport: beyondViewport,
546
+ clip: {
547
+ ...clip,
548
+ scale: options.scale ?? 1
549
+ }
550
+ });
551
+ return Buffer.from(shot.data, "base64");
552
+ }
553
+ close() {
554
+ return this.browser.close();
555
+ }
556
+ };
557
+ //#endregion
558
+ export { MAX_SHOT_HEIGHT as a, openPage as i, PAGE_LOADED as n, findDevServer as o, describeSectionError as r, NavigationError as t };