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 +21 -1
- package/dist/browser.js +2 -0
- package/dist/chunks/browser-BCfAv8bW.js +558 -0
- package/dist/cli.js +346 -460
- package/dist/types/cli/args.d.ts +12 -1
- package/dist/types/cli/browser.d.ts +116 -0
- package/dist/types/cli/dev-server.d.ts +13 -0
- package/dist/types/cli/headless.d.ts +25 -16
- package/dist/types/cli/shot.d.ts +105 -8
- package/package.json +6 -2
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
|
|
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/`.
|
package/dist/browser.js
ADDED
|
@@ -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 };
|