mechanica 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,810 @@
1
+ import fs from "node:fs";
2
+ import { join, win32 } from "node:path";
3
+ import os from "node:os";
4
+ import { execFile, 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
+ /** How long a spawned browser may take to print its DevTools endpoint. */
137
+ var STARTUP_TIMEOUT = 2e4;
138
+ /**
139
+ * Spawn a headless Chromium-based browser with a DevTools endpoint and connect
140
+ * to it. With `explicitPath` (`--browser`) only that executable is tried.
141
+ * Otherwise the installed browsers of {@link platformBrowserCandidates} are
142
+ * tried in order until one starts — a browser already on the machine, so
143
+ * nothing needs downloading — and the error names each one that failed.
144
+ */
145
+ async function launchBrowser(explicitPath) {
146
+ if (explicitPath) {
147
+ if (!fs.existsSync(explicitPath)) throw new Error(`--browser not found: ${explicitPath}`);
148
+ const executable = {
149
+ label: "browser from --browser",
150
+ path: explicitPath
151
+ };
152
+ try {
153
+ return await startBrowser(executable);
154
+ } catch (error) {
155
+ throw new Error(`Failed to start ${executable.label} (${executable.path}): ${errorMessage(error)}`);
156
+ }
157
+ }
158
+ const candidates = platformBrowserCandidates();
159
+ const installed = candidates.filter((candidate, index) => candidates.findIndex((other) => other.path === candidate.path) === index && fs.existsSync(candidate.path));
160
+ if (!installed.length) {
161
+ const looked = candidates.map((c) => ` ${c.label}: ${c.path}`).join("\n");
162
+ throw new Error(`No Chromium-based browser found. Looked for:\n${looked}\nInstall Chrome or Edge, or pass --browser <path-to-executable>.`);
163
+ }
164
+ return startFirstBrowser(installed, startBrowser);
165
+ }
166
+ /**
167
+ * Try `start` on each candidate in order and return the first browser that
168
+ * comes up. When none does, the error lists every candidate and why it failed.
169
+ */
170
+ async function startFirstBrowser(candidates, start) {
171
+ const failures = [];
172
+ for (const candidate of candidates) try {
173
+ return await start(candidate);
174
+ } catch (error) {
175
+ failures.push(` ${candidate.label} (${candidate.path}): ${errorMessage(error)}`);
176
+ }
177
+ throw new Error(`No browser could be started. Tried:\n${failures.join("\n")}\nPass --browser <path-to-executable> to use another Chromium-based browser.`);
178
+ }
179
+ /**
180
+ * Spawn one browser on a fresh temporary profile and connect to it. When it
181
+ * does not come up (exits early, never prints its endpoint, refuses the
182
+ * connection), everything it started is ended and the profile removed before
183
+ * the error is rethrown.
184
+ */
185
+ async function startBrowser(executable) {
186
+ const userDataDir = fs.mkdtempSync(join(os.tmpdir(), "mechanica-shot-"));
187
+ const child = spawn(executable.path, [
188
+ "--headless=new",
189
+ "--remote-debugging-port=0",
190
+ `--user-data-dir=${userDataDir}`,
191
+ "--no-first-run",
192
+ "--no-default-browser-check",
193
+ "--disable-extensions",
194
+ "--hide-scrollbars",
195
+ "about:blank"
196
+ ], {
197
+ stdio: [
198
+ "ignore",
199
+ "ignore",
200
+ "pipe"
201
+ ],
202
+ env: browserEnv()
203
+ });
204
+ let client;
205
+ try {
206
+ const endpoint = await waitForDevtoolsEndpoint(child, STARTUP_TIMEOUT);
207
+ client = await CdpClient.connect(endpoint);
208
+ } catch (error) {
209
+ await killBrowserTree(child, userDataDir);
210
+ await removeProfile(userDataDir);
211
+ throw error;
212
+ }
213
+ return {
214
+ client,
215
+ close: async () => {
216
+ await client.send("Browser.close").catch(() => {});
217
+ client.dispose();
218
+ await stopProcess(child);
219
+ await removeProfile(userDataDir);
220
+ }
221
+ };
222
+ }
223
+ /**
224
+ * The environment a browser starts with. On Windows it leaves out
225
+ * `__COMPAT_LAYER` (Git Bash and some tool runners set it to `RunAsInvoker`):
226
+ * under a compatibility layer Edge relaunches itself without it and exits at
227
+ * once, so the process we spawned never prints its endpoint while the
228
+ * relaunched one keeps running.
229
+ */
230
+ function browserEnv(env = process.env, platform = process.platform) {
231
+ if (platform !== "win32") return env;
232
+ const result = { ...env };
233
+ for (const key of Object.keys(result)) if (key.toUpperCase() === "__COMPAT_LAYER") delete result[key];
234
+ return result;
235
+ }
236
+ /**
237
+ * The Windows commands that end a browser which failed to start, best effort:
238
+ * `taskkill /T /F` on its process tree while the spawned process still runs
239
+ * (`pid`), then — because a browser that relaunched itself has outlived the
240
+ * process we spawned — every process still running on its temporary profile,
241
+ * found by the profile's folder name in the command line.
242
+ */
243
+ function windowsKillCommands(pid, userDataDir) {
244
+ const commands = [];
245
+ if (pid) commands.push(["taskkill.exe", [
246
+ "/T",
247
+ "/F",
248
+ "/PID",
249
+ String(pid)
250
+ ]]);
251
+ const profile = win32.basename(userDataDir);
252
+ if (/^[\w.-]+$/.test(profile)) {
253
+ const script = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${profile}%'" | Where-Object { \$_.ProcessId -ne \$PID } | ForEach-Object { Stop-Process -Id \$_.ProcessId -Force -ErrorAction SilentlyContinue }`;
254
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
255
+ commands.push(["powershell.exe", [
256
+ "-NoProfile",
257
+ "-NonInteractive",
258
+ "-EncodedCommand",
259
+ encoded
260
+ ]]);
261
+ }
262
+ return commands;
263
+ }
264
+ /** End a browser that failed to start, with every process it spawned (best effort). */
265
+ async function killBrowserTree(child, userDataDir) {
266
+ if (process.platform === "win32") for (const [command, args] of windowsKillCommands(isRunning(child) ? child.pid : void 0, userDataDir)) await runQuietly(command, args);
267
+ await stopProcess(child);
268
+ }
269
+ function isRunning(child) {
270
+ return child.exitCode === null && child.signalCode === null;
271
+ }
272
+ /** Kill the spawned process (if it still runs) and wait up to 3 s for it to exit. */
273
+ async function stopProcess(child) {
274
+ if (!isRunning(child)) return;
275
+ const exited = new Promise((resolve) => {
276
+ child.once("exit", () => resolve());
277
+ setTimeout(resolve, 3e3);
278
+ });
279
+ child.kill();
280
+ await exited;
281
+ }
282
+ /** Remove a temporary profile; it can stay locked for a moment after exit on Windows. */
283
+ async function removeProfile(userDataDir) {
284
+ for (let attempt = 0; attempt < 5; attempt++) try {
285
+ fs.rmSync(userDataDir, {
286
+ recursive: true,
287
+ force: true
288
+ });
289
+ return;
290
+ } catch {
291
+ await new Promise((resolve) => setTimeout(resolve, 300));
292
+ }
293
+ }
294
+ /** Run a command, ignoring its output and any failure. */
295
+ function runQuietly(command, args) {
296
+ return new Promise((resolve) => {
297
+ execFile(command, args, {
298
+ timeout: 15e3,
299
+ windowsHide: true
300
+ }, () => resolve());
301
+ });
302
+ }
303
+ function errorMessage(error) {
304
+ return error instanceof Error ? error.message : String(error);
305
+ }
306
+ /** Read the spawned browser's stderr until it prints its DevTools WebSocket URL. */
307
+ function waitForDevtoolsEndpoint(child, timeout) {
308
+ return new Promise((resolve, reject) => {
309
+ let buffer = "";
310
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`no DevTools endpoint within ${timeout}ms`)), timeout);
311
+ let found = false;
312
+ child.stderr.on("data", (chunk) => {
313
+ if (found) return;
314
+ buffer += chunk.toString();
315
+ const match = buffer.match(/DevTools listening on (ws:\/\/\S+)/);
316
+ if (match) {
317
+ found = true;
318
+ clearTimeout(timer);
319
+ resolve(match[1]);
320
+ }
321
+ });
322
+ child.on("error", (error) => {
323
+ clearTimeout(timer);
324
+ reject(error);
325
+ });
326
+ child.on("exit", (code, signal) => {
327
+ clearTimeout(timer);
328
+ const said = buffer.trim().split(/\r?\n/).pop()?.trim().slice(0, 200);
329
+ reject(/* @__PURE__ */ new Error(`browser exited early (${code !== null ? `code ${code}` : signal})${said ? `: ${said}` : ""}`));
330
+ });
331
+ });
332
+ }
333
+ /**
334
+ * Known install locations of Chromium-based browsers, in the order they are
335
+ * tried. On Windows Chrome comes first: Edge ships with Windows, so it is the
336
+ * fallback, and it relaunches itself under a compatibility layer (see
337
+ * {@link browserEnv}).
338
+ */
339
+ function platformBrowserCandidates(platform = process.platform, env = process.env, home = os.homedir()) {
340
+ if (platform === "win32") {
341
+ const programFiles = env["ProgramFiles"] ?? "C:\\Program Files";
342
+ const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
343
+ const localAppData = env["LOCALAPPDATA"] ?? win32.join(home, "AppData", "Local");
344
+ const chrome = [
345
+ "Google",
346
+ "Chrome",
347
+ "Application",
348
+ "chrome.exe"
349
+ ];
350
+ const edge = [
351
+ "Microsoft",
352
+ "Edge",
353
+ "Application",
354
+ "msedge.exe"
355
+ ];
356
+ return [
357
+ {
358
+ label: "Google Chrome",
359
+ path: win32.join(programFiles, ...chrome)
360
+ },
361
+ {
362
+ label: "Google Chrome",
363
+ path: win32.join(programFilesX86, ...chrome)
364
+ },
365
+ {
366
+ label: "Google Chrome",
367
+ path: win32.join(localAppData, ...chrome)
368
+ },
369
+ {
370
+ label: "Microsoft Edge",
371
+ path: win32.join(programFilesX86, ...edge)
372
+ },
373
+ {
374
+ label: "Microsoft Edge",
375
+ path: win32.join(programFiles, ...edge)
376
+ }
377
+ ];
378
+ }
379
+ if (platform === "darwin") return [
380
+ {
381
+ label: "Google Chrome",
382
+ path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
383
+ },
384
+ {
385
+ label: "Microsoft Edge",
386
+ path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
387
+ },
388
+ {
389
+ label: "Chromium",
390
+ path: "/Applications/Chromium.app/Contents/MacOS/Chromium"
391
+ }
392
+ ];
393
+ return [
394
+ {
395
+ label: "Google Chrome",
396
+ path: "/usr/bin/google-chrome"
397
+ },
398
+ {
399
+ label: "Chromium",
400
+ path: "/usr/bin/chromium"
401
+ },
402
+ {
403
+ label: "Chromium",
404
+ path: "/usr/bin/chromium-browser"
405
+ },
406
+ {
407
+ label: "Microsoft Edge",
408
+ path: "/usr/bin/microsoft-edge"
409
+ }
410
+ ];
411
+ }
412
+ /**
413
+ * A minimal Chrome DevTools Protocol client over the platform `WebSocket`
414
+ * (native in Bun and Node ≥ 22): request/response matching by id, plus event
415
+ * fan-out. Flat session mode — pass a `sessionId` to address a page target.
416
+ */
417
+ var CdpClient = class CdpClient {
418
+ ws;
419
+ nextId = 1;
420
+ pending = /* @__PURE__ */ new Map();
421
+ listeners = [];
422
+ constructor(ws) {
423
+ this.ws = ws;
424
+ ws.onmessage = (event) => this.onMessage(String(event.data));
425
+ ws.onclose = () => {
426
+ for (const { reject } of this.pending.values()) reject(/* @__PURE__ */ new Error("CDP connection closed"));
427
+ this.pending.clear();
428
+ };
429
+ }
430
+ static connect(url) {
431
+ return new Promise((resolve, reject) => {
432
+ const ws = new WebSocket(url);
433
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`CDP connect timeout: ${url}`)), 1e4);
434
+ ws.onopen = () => {
435
+ clearTimeout(timer);
436
+ resolve(new CdpClient(ws));
437
+ };
438
+ ws.onerror = () => {
439
+ clearTimeout(timer);
440
+ reject(/* @__PURE__ */ new Error(`CDP connection failed: ${url}`));
441
+ };
442
+ });
443
+ }
444
+ send(method, params = {}, sessionId) {
445
+ const id = this.nextId++;
446
+ this.ws.send(JSON.stringify({
447
+ id,
448
+ method,
449
+ params,
450
+ ...sessionId ? { sessionId } : {}
451
+ }));
452
+ return new Promise((resolve, reject) => this.pending.set(id, {
453
+ resolve,
454
+ reject
455
+ }));
456
+ }
457
+ onEvent(listener) {
458
+ this.listeners.push(listener);
459
+ }
460
+ dispose() {
461
+ try {
462
+ this.ws.close();
463
+ } catch {}
464
+ }
465
+ onMessage(raw) {
466
+ const message = JSON.parse(raw);
467
+ if (typeof message.id === "number") {
468
+ const pending = this.pending.get(message.id);
469
+ if (!pending) return;
470
+ this.pending.delete(message.id);
471
+ if (message.error) pending.reject(/* @__PURE__ */ new Error(`${message.error.message ?? "CDP error"}`));
472
+ else pending.resolve(message.result ?? {});
473
+ return;
474
+ }
475
+ if (typeof message.method === "string") for (const listener of this.listeners) listener(message.method, message.params ?? {}, message.sessionId);
476
+ }
477
+ };
478
+ /**
479
+ * Split a selector at `>>>` into trimmed parts (one part when there is none).
480
+ * A `>>>` inside quotes, brackets or parentheses, or after a backslash, is
481
+ * part of the selector. Throws when a part is empty (`>>> .a`, `.a >>>`).
482
+ */
483
+ function splitShadowSelector(selector) {
484
+ const parts = [];
485
+ let start = 0;
486
+ let depth = 0;
487
+ let quote = "";
488
+ for (let i = 0; i < selector.length; i++) {
489
+ const char = selector[i];
490
+ if (char === "\\") {
491
+ i++;
492
+ continue;
493
+ }
494
+ if (quote) {
495
+ if (char === quote) quote = "";
496
+ continue;
497
+ }
498
+ if (char === "\"" || char === "'") quote = char;
499
+ else if (char === "[" || char === "(") depth++;
500
+ else if ((char === "]" || char === ")") && depth > 0) depth--;
501
+ else if (depth === 0 && selector.startsWith(">>>", i)) {
502
+ parts.push(selector.slice(start, i).trim());
503
+ i += 2;
504
+ start = i + 1;
505
+ }
506
+ }
507
+ parts.push(selector.slice(start).trim());
508
+ if (parts.some((part) => !part)) throw new Error(`empty selector around ">>>"`);
509
+ return parts;
510
+ }
511
+ /**
512
+ * The block-id selector to try when a bare `#name` or `name` matches nothing:
513
+ * `[data-block-id="name"]`. Null for any other selector. A name that is not a
514
+ * valid CSS id (`#1hero`) still gets one, so block ids starting with a digit
515
+ * are reachable too.
516
+ */
517
+ function blockIdFallback(selector) {
518
+ const match = /^#?([\p{L}\p{N}_-]+)$/u.exec(selector.trim());
519
+ return match ? `[data-block-id="${match[1]}"]` : null;
520
+ }
521
+ /**
522
+ * In-page source of `(parts, fallback) => result`, where `parts` is a selector
523
+ * split by {@link splitShadowSelector} and `fallback` is
524
+ * {@link blockIdFallback}'s result (or null). It finds the first element
525
+ * matching `parts`, else the first matching `fallback`, in the document and
526
+ * in every open shadow root, and returns:
527
+ *
528
+ * - `{ element }`;
529
+ * - `{ error: 'invalid', message }` when the selector does not parse (and the
530
+ * fallback finds nothing);
531
+ * - `{ error: 'missing', ids, blockIds, fallback }`: the readable ids
532
+ * (`#name`) and the `data-block-id` values on the page, shadow roots
533
+ * included, for the error message.
534
+ *
535
+ * Plain JS with no outside references: it is sent to the page as text.
536
+ */
537
+ var FIND_SECTION_SOURCE = String.raw`(parts, fallback) => {
538
+ // Every open shadow root under scope, depth first in tree order.
539
+ const shadowRoots = (scope) => {
540
+ const roots = []
541
+ const visit = (root) => {
542
+ for (const el of root.querySelectorAll('*')) {
543
+ if (el.shadowRoot) {
544
+ roots.push(el.shadowRoot)
545
+ visit(el.shadowRoot)
546
+ }
547
+ }
548
+ }
549
+ visit(scope)
550
+ return roots
551
+ }
552
+ // The first match in scope's own tree, else in the shadow roots under it.
553
+ const first = (scope, selector) => {
554
+ const own = scope.querySelector(selector)
555
+ if (own) return own
556
+ for (const root of shadowRoots(scope)) {
557
+ const hit = root.querySelector(selector)
558
+ if (hit) return hit
559
+ }
560
+ return null
561
+ }
562
+ const all = (scope, selector) =>
563
+ [scope, ...shadowRoots(scope)].flatMap((root) => [...root.querySelectorAll(selector)])
564
+ const query = () => {
565
+ let scopes = [document]
566
+ for (const part of parts.slice(0, -1)) {
567
+ const hosts = new Set(scopes.flatMap((scope) => all(scope, part)))
568
+ scopes = [...hosts].map((host) => host.shadowRoot || host)
569
+ }
570
+ for (const scope of scopes) {
571
+ const hit = first(scope, parts[parts.length - 1])
572
+ if (hit) return hit
573
+ }
574
+ return null
575
+ }
576
+
577
+ let element = null
578
+ let invalid = null
579
+ try {
580
+ element = query()
581
+ } catch (error) {
582
+ invalid = String((error && error.message) || error)
583
+ }
584
+ if (!element && fallback) element = first(document, fallback)
585
+ if (element) return { element }
586
+ if (invalid) return { error: 'invalid', message: invalid }
587
+
588
+ // A hint for the next try: readable ids (not SVG internals or generated
589
+ // ids) and block ids, in the document and its shadow roots.
590
+ const ids = new Set()
591
+ const blockIds = new Set()
592
+ for (const root of [document, ...shadowRoots(document)]) {
593
+ for (const el of root.querySelectorAll(root === document ? 'body [id]' : '[id]')) {
594
+ const id = el.id
595
+ if (el.closest('svg') || !/^[A-Za-z][\w-]{0,39}$/.test(id) || /^w-node-|[0-9a-f]{8}/i.test(id)) continue
596
+ ids.add('#' + id)
597
+ }
598
+ for (const el of root.querySelectorAll('[data-block-id]')) {
599
+ const id = el.getAttribute('data-block-id')
600
+ if (id) blockIds.add(id)
601
+ }
602
+ }
603
+ return {
604
+ error: 'missing',
605
+ ids: [...ids].slice(0, ${20}),
606
+ blockIds: [...blockIds].slice(0, ${20}),
607
+ fallback,
608
+ }
609
+ }`;
610
+ //#endregion
611
+ //#region src/cli/browser.ts
612
+ /**
613
+ * `mechanica/browser` — the headless browser behind `mechanica shot` and
614
+ * `mechanica thumbs`, for a site's own scripts: open a page (a dev-server URL,
615
+ * a design handoff's `file://` URL, any site), resize it, scroll it, measure it
616
+ * with `evaluate`, and screenshot it or one section of it.
617
+ *
618
+ * Drives a Chrome/Edge already installed on the machine over raw CDP
619
+ * (`headless.ts`), so there is nothing to download. Needs a global `WebSocket`
620
+ * (Node 22+ or Bun) and never imports Vite.
621
+ */
622
+ /** Readiness of an ordinary page: the load event has fired. */
623
+ var PAGE_LOADED = "document.readyState === 'complete'";
624
+ var DEFAULT_WIDTH = 1440;
625
+ var DEFAULT_HEIGHT = 900;
626
+ var DEFAULT_TIMEOUT = 15e3;
627
+ /** `goto` could not open the URL at all (DNS, refused connection, missing file…). */
628
+ var NavigationError = class extends Error {
629
+ url;
630
+ reason;
631
+ constructor(url, reason) {
632
+ super(`Failed to open ${url}: ${reason}`);
633
+ this.url = url;
634
+ this.reason = reason;
635
+ this.name = "NavigationError";
636
+ }
637
+ };
638
+ /**
639
+ * Launch a headless browser with one tab, sized and media-emulated like
640
+ * `mechanica shot`, and open `url` in it (omit `url` for a blank tab to
641
+ * `goto` later). Always `close()` the page — it owns the browser process.
642
+ *
643
+ * ```ts
644
+ * import { openPage } from 'mechanica/browser'
645
+ *
646
+ * const page = await openPage('file:///…/design/index.html', { width: 390 })
647
+ * try {
648
+ * const size = await page.evaluate<string>(`getComputedStyle(document.querySelector('h1')).fontSize`)
649
+ * fs.writeFileSync('hero.png', await page.screenshot({ section: '#hero' }))
650
+ * } finally {
651
+ * await page.close()
652
+ * }
653
+ * ```
654
+ */
655
+ async function openPage(url, options = {}) {
656
+ const browser = await launchBrowser(options.browser);
657
+ try {
658
+ const page = await HeadlessPage.create(browser, options);
659
+ if (url) await page.goto(url, options);
660
+ return page;
661
+ } catch (error) {
662
+ await browser.close();
663
+ throw error;
664
+ }
665
+ }
666
+ /** The message for a `sectionClip` that found nothing usable. */
667
+ function describeSectionError(selector, box) {
668
+ switch (box.error) {
669
+ case "invalid": return `"${selector}" is not a valid CSS selector (${box.message})`;
670
+ case "missing": return `No element matches "${selector}"` + (box.fallback ? ` (nor ${box.fallback})` : "") + (box.ids.length ? `. Ids on the page: ${box.ids.join(", ")}` : "") + (box.blockIds?.length ? `. Block ids (data-block-id): ${box.blockIds.join(", ")}` : "");
671
+ 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?`;
672
+ }
673
+ }
674
+ var HeadlessPage = class HeadlessPage {
675
+ browser;
676
+ sessionId;
677
+ messages = [];
678
+ height;
679
+ constructor(browser, sessionId, height) {
680
+ this.browser = browser;
681
+ this.sessionId = sessionId;
682
+ this.height = height;
683
+ }
684
+ static async create(browser, options) {
685
+ const cdp = browser.client;
686
+ const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
687
+ const { sessionId } = await cdp.send("Target.attachToTarget", {
688
+ targetId,
689
+ flatten: true
690
+ });
691
+ const page = new HeadlessPage(browser, sessionId, options.height ?? DEFAULT_HEIGHT);
692
+ cdp.onEvent((method, params, eventSession) => {
693
+ if (eventSession !== sessionId) return;
694
+ if (method === "Runtime.consoleAPICalled" && (params.type === "error" || params.type === "warning")) {
695
+ const text = (params.args ?? []).map((arg) => arg.value ?? arg.description ?? "").join(" ");
696
+ page.messages.push(`[console.${params.type}] ${text}`);
697
+ }
698
+ if (method === "Runtime.exceptionThrown") {
699
+ const details = params.exceptionDetails;
700
+ page.messages.push(`[pageerror] ${details?.exception?.description ?? details?.text ?? "unknown error"}`);
701
+ }
702
+ });
703
+ await cdp.send("Runtime.enable", {}, sessionId);
704
+ await cdp.send("Page.enable", {}, sessionId);
705
+ await setViewport(cdp, sessionId, options.width ?? DEFAULT_WIDTH, page.height);
706
+ if (options.reducedMotion !== false) await cdp.send("Emulation.setEmulatedMedia", { features: [{
707
+ name: "prefers-reduced-motion",
708
+ value: "reduce"
709
+ }] }, sessionId);
710
+ return page;
711
+ }
712
+ send(method, params = {}) {
713
+ return this.browser.client.send(method, params, this.sessionId);
714
+ }
715
+ async goto(url, options = {}) {
716
+ const navigation = await this.send("Page.navigate", { url });
717
+ if (navigation.errorText) throw new NavigationError(url, navigation.errorText);
718
+ await this.waitFor(options.ready ?? "document.readyState === 'complete'", options.timeout ?? DEFAULT_TIMEOUT);
719
+ if (options.settle !== false) await this.settle();
720
+ }
721
+ async evaluate(expression) {
722
+ const reply = await this.send("Runtime.evaluate", {
723
+ expression,
724
+ returnByValue: true,
725
+ awaitPromise: true
726
+ });
727
+ if (reply.exceptionDetails) {
728
+ const details = reply.exceptionDetails;
729
+ throw new Error(`Evaluation failed: ${details.exception?.description ?? details.text ?? "unknown error"}`);
730
+ }
731
+ return reply.result?.value;
732
+ }
733
+ waitFor(expression, timeout = DEFAULT_TIMEOUT) {
734
+ return waitForCondition(this.browser.client, this.sessionId, expression, timeout);
735
+ }
736
+ async setViewport(width, height = this.height) {
737
+ this.height = height;
738
+ await setViewport(this.browser.client, this.sessionId, width, height);
739
+ }
740
+ async scrollTo(y) {
741
+ await this.evaluate(`window.scrollTo({ top: ${Number(y)}, left: 0, behavior: 'instant' })`);
742
+ await this.nextFrame();
743
+ return this.evaluate("scrollY");
744
+ }
745
+ nextFrame() {
746
+ return nextFrames(this.browser.client, this.sessionId);
747
+ }
748
+ settle() {
749
+ return settlePageMedia(this.browser.client, this.sessionId, SETTLE_TIMEOUT);
750
+ }
751
+ freezeAnimations() {
752
+ return freezeAnimations(this.browser.client, this.sessionId);
753
+ }
754
+ unfreezeAnimations() {
755
+ return unfreezeAnimations(this.browser.client, this.sessionId);
756
+ }
757
+ async sectionClip(selector) {
758
+ let parts;
759
+ try {
760
+ parts = splitShadowSelector(selector);
761
+ } catch (error) {
762
+ throw new Error(describeSectionError(selector, {
763
+ error: "invalid",
764
+ message: error.message
765
+ }));
766
+ }
767
+ const fallback = parts.length === 1 ? blockIdFallback(selector) : null;
768
+ const box = await this.evaluate(`((find, parts, fallback) => {
769
+ const found = find(parts, fallback)
770
+ if (!found.element) return found
771
+ const rect = found.element.getBoundingClientRect()
772
+ if (rect.width < 1 || rect.height < 1) return { error: 'empty', width: rect.width, height: rect.height }
773
+ return { x: rect.x + scrollX, y: rect.y + scrollY, width: rect.width, height: rect.height }
774
+ })(${FIND_SECTION_SOURCE}, ${JSON.stringify(parts)}, ${JSON.stringify(fallback)})`);
775
+ if ("error" in box) throw new Error(describeSectionError(selector, box));
776
+ return box;
777
+ }
778
+ async screenshot(options = {}) {
779
+ const cdp = this.browser.client;
780
+ let clip;
781
+ let beyondViewport = true;
782
+ if (options.clip) clip = options.clip;
783
+ else if (options.section) {
784
+ const box = await this.sectionClip(options.section);
785
+ clip = {
786
+ ...box,
787
+ height: Math.min(box.height, MAX_SHOT_HEIGHT)
788
+ };
789
+ if (clip.height < box.height) console.info(`("${options.section}" is ${Math.round(box.height)}px tall — clipped to ${MAX_SHOT_HEIGHT}px)`);
790
+ } else if (options.full) clip = await fullPageClip(cdp, this.sessionId);
791
+ else {
792
+ clip = await viewportClip(cdp, this.sessionId);
793
+ beyondViewport = false;
794
+ }
795
+ const shot = await this.send("Page.captureScreenshot", {
796
+ format: "png",
797
+ captureBeyondViewport: beyondViewport,
798
+ clip: {
799
+ ...clip,
800
+ scale: options.scale ?? 1
801
+ }
802
+ });
803
+ return Buffer.from(shot.data, "base64");
804
+ }
805
+ close() {
806
+ return this.browser.close();
807
+ }
808
+ };
809
+ //#endregion
810
+ export { splitShadowSelector as a, openPage as i, PAGE_LOADED as n, MAX_SHOT_HEIGHT as o, describeSectionError as r, findDevServer as s, NavigationError as t };