mechanica 2.2.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.
package/README.md CHANGED
@@ -361,7 +361,7 @@ These drive a locally installed Chrome or Edge (or `--browser <path>`) and need
361
361
  | --- | --- |
362
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) |
363
363
  | `mechanica build` | Build the client and server bundles into `dist/` (`export` runs this for you) |
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` |
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; also inside open shadow roots, `#name` also finds the block `[data-block-id="name"]`), `--scroll <px[,px…]>` (the viewport after scrolling), `--no-freeze`, `--full`, `--data` (blocks), `--out`, `--server`, `--browser` |
365
365
  | `mechanica thumbs [--blocks]` | Regenerate page or block thumbnails for the editor |
366
366
  | `mechanica images` | Generate image dimensions and blur-up previews for `.mech/assets` (needs `sharp`; `--force` redoes all) |
367
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) |
@@ -399,7 +399,7 @@ From `mechanica`:
399
399
 
400
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.
401
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`.
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)` (document, then open shadow roots; `#name` falls back to `[data-block-id="name"]`), `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
403
 
404
404
  ```js
405
405
  import { openPage } from 'mechanica/browser'
package/dist/browser.js CHANGED
@@ -1,2 +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";
1
+ import { i as openPage, n as PAGE_LOADED, o as MAX_SHOT_HEIGHT, r as describeSectionError, s as findDevServer, t as NavigationError } from "./chunks/browser-BV2iB_xD.js";
2
2
  export { MAX_SHOT_HEIGHT, NavigationError, PAGE_LOADED, describeSectionError, findDevServer, openPage };
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, win32 } from "node:path";
3
3
  import os from "node:os";
4
- import { spawn } from "node:child_process";
4
+ import { execFile, spawn } from "node:child_process";
5
5
  //#region src/cli/headless.ts
6
6
  /**
7
7
  * Headless-browser plumbing shared by `mechanica shot`, `mechanica thumbs` and
@@ -133,13 +133,56 @@ async function viewportClip(cdp, sessionId) {
133
133
  height: viewport.clientHeight
134
134
  };
135
135
  }
136
+ /** How long a spawned browser may take to print its DevTools endpoint. */
137
+ var STARTUP_TIMEOUT = 2e4;
136
138
  /**
137
139
  * 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
+ * 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.
140
144
  */
141
145
  async function launchBrowser(explicitPath) {
142
- const executable = findBrowserExecutable(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) {
143
186
  const userDataDir = fs.mkdtempSync(join(os.tmpdir(), "mechanica-shot-"));
144
187
  const child = spawn(executable.path, [
145
188
  "--headless=new",
@@ -150,55 +193,128 @@ async function launchBrowser(explicitPath) {
150
193
  "--disable-extensions",
151
194
  "--hide-scrollbars",
152
195
  "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;
196
+ ], {
197
+ stdio: [
198
+ "ignore",
199
+ "ignore",
200
+ "pipe"
201
+ ],
202
+ env: browserEnv()
203
+ });
204
+ let client;
177
205
  try {
178
- endpoint = await waitForDevtoolsEndpoint(child, 2e4);
206
+ const endpoint = await waitForDevtoolsEndpoint(child, STARTUP_TIMEOUT);
207
+ client = await CdpClient.connect(endpoint);
179
208
  } catch (error) {
180
- await cleanup();
181
- throw new Error(`Failed to start ${executable.label} (${executable.path}): ${error instanceof Error ? error.message : error}`);
209
+ await killBrowserTree(child, userDataDir);
210
+ await removeProfile(userDataDir);
211
+ throw error;
182
212
  }
183
- const client = await CdpClient.connect(endpoint);
184
213
  return {
185
214
  client,
186
215
  close: async () => {
187
216
  await client.send("Browser.close").catch(() => {});
188
217
  client.dispose();
189
- await cleanup();
218
+ await stopProcess(child);
219
+ await removeProfile(userDataDir);
190
220
  }
191
221
  };
192
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
+ }
193
306
  /** Read the spawned browser's stderr until it prints its DevTools WebSocket URL. */
194
307
  function waitForDevtoolsEndpoint(child, timeout) {
195
308
  return new Promise((resolve, reject) => {
196
309
  let buffer = "";
197
310
  const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`no DevTools endpoint within ${timeout}ms`)), timeout);
311
+ let found = false;
198
312
  child.stderr.on("data", (chunk) => {
313
+ if (found) return;
199
314
  buffer += chunk.toString();
200
315
  const match = buffer.match(/DevTools listening on (ws:\/\/\S+)/);
201
316
  if (match) {
317
+ found = true;
202
318
  clearTimeout(timer);
203
319
  resolve(match[1]);
204
320
  }
@@ -207,54 +323,60 @@ function waitForDevtoolsEndpoint(child, timeout) {
207
323
  clearTimeout(timer);
208
324
  reject(error);
209
325
  });
210
- child.on("exit", (code) => {
326
+ child.on("exit", (code, signal) => {
211
327
  clearTimeout(timer);
212
- reject(/* @__PURE__ */ new Error(`browser exited early (code ${code})`));
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}` : ""}`));
213
330
  });
214
331
  });
215
332
  }
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");
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
+ ];
238
356
  return [
239
357
  {
240
- label: "Microsoft Edge",
241
- path: join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe")
358
+ label: "Google Chrome",
359
+ path: win32.join(programFiles, ...chrome)
242
360
  },
243
361
  {
244
- label: "Microsoft Edge",
245
- path: join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe")
362
+ label: "Google Chrome",
363
+ path: win32.join(programFilesX86, ...chrome)
246
364
  },
247
365
  {
248
366
  label: "Google Chrome",
249
- path: join(programFiles, "Google", "Chrome", "Application", "chrome.exe")
367
+ path: win32.join(localAppData, ...chrome)
250
368
  },
251
369
  {
252
- label: "Google Chrome",
253
- path: join(localAppData, "Google", "Chrome", "Application", "chrome.exe")
370
+ label: "Microsoft Edge",
371
+ path: win32.join(programFilesX86, ...edge)
372
+ },
373
+ {
374
+ label: "Microsoft Edge",
375
+ path: win32.join(programFiles, ...edge)
254
376
  }
255
377
  ];
256
378
  }
257
- if (process.platform === "darwin") return [
379
+ if (platform === "darwin") return [
258
380
  {
259
381
  label: "Google Chrome",
260
382
  path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
@@ -353,6 +475,138 @@ var CdpClient = class CdpClient {
353
475
  if (typeof message.method === "string") for (const listener of this.listeners) listener(message.method, message.params ?? {}, message.sessionId);
354
476
  }
355
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
+ }`;
356
610
  //#endregion
357
611
  //#region src/cli/browser.ts
358
612
  /**
@@ -413,7 +667,7 @@ async function openPage(url, options = {}) {
413
667
  function describeSectionError(selector, box) {
414
668
  switch (box.error) {
415
669
  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(", ")}` : "");
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(", ")}` : "");
417
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?`;
418
672
  }
419
673
  }
@@ -501,25 +755,23 @@ var HeadlessPage = class HeadlessPage {
501
755
  return unfreezeAnimations(this.browser.client, this.sessionId);
502
756
  }
503
757
  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()
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()
520
772
  if (rect.width < 1 || rect.height < 1) return { error: 'empty', width: rect.width, height: rect.height }
521
773
  return { x: rect.x + scrollX, y: rect.y + scrollY, width: rect.width, height: rect.height }
522
- })(${JSON.stringify(selector)})`);
774
+ })(${FIND_SECTION_SOURCE}, ${JSON.stringify(parts)}, ${JSON.stringify(fallback)})`);
523
775
  if ("error" in box) throw new Error(describeSectionError(selector, box));
524
776
  return box;
525
777
  }
@@ -555,4 +807,4 @@ var HeadlessPage = class HeadlessPage {
555
807
  }
556
808
  };
557
809
  //#endregion
558
- export { MAX_SHOT_HEIGHT as a, openPage as i, PAGE_LOADED as n, findDevServer as o, describeSectionError as r, NavigationError as t };
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 };
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { t as buildSiteManifest } from "./chunks/site-BVqx8iKj.js";
2
2
  import { J as readImageManifest, K as isDerivedAsset, W as fsAssetStore, X as updateImageManifest, et as withRemoteAssets, it as isRasterImage, nt as analyzeImageBuffer, rt as hasSharp } from "./chunks/rich-text-codec-DCv1X0eZ.js";
3
3
  import { _ as request, a as git, c as clearPendingLogin, d as hostOrigin, f as savePendingLogin, g as parseRemoteUrl, h as REMOTE_NAME, i as uploadsIgnored, l as getPendingLogin, m as PlatformError, n as remoteAssetsFor, o as gitAuthEnv, p as saveToken, r as runAssetsPull, s as gitOk, t as pushUploads, u as getToken } from "./chunks/assets-5a2WdmFg.js";
4
4
  import { exportBuilt } from "./export.js";
5
- import { i as openPage, o as findDevServer, t as NavigationError } from "./chunks/browser-BCfAv8bW.js";
5
+ import { a as splitShadowSelector, i as openPage, s as findDevServer, t as NavigationError } from "./chunks/browser-BV2iB_xD.js";
6
6
  import fs, { readFileSync } from "node:fs";
7
7
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
8
8
  import { EDITOR_DIST_DIR, LEGACY_UPLOADS_PREFIX, SITE_MANIFEST_FILE, UPLOADS_PREFIX } from "mechanica-shared";
@@ -679,7 +679,7 @@ async function resolveServer(flag) {
679
679
  }
680
680
  //#endregion
681
681
  //#region src/cli/shot.ts
682
- var USAGE = "Usage: mechanica shot <blockId | /page/path | URL | file.html> [--page </path>] [--data <json|@file>] [--width 1440,768] [--section <css>]… [--scroll <px[,px…]>] [--no-freeze] [--full] [--out <path>] [--server <url>] [--browser <path>]";
682
+ var USAGE = "Usage: mechanica shot <blockId | /page/path | URL | file.html> [--page </path>] [--data <json|@file>] [--width 1440,768] [--section <css>]… [--scroll <px[,px…]>] [--no-freeze] [--full] [--out <path>] [--server <url>] [--browser <path>]\n--section also looks inside open shadow roots (\"host >>> inner\" crosses one explicitly); a bare #name (or name) that matches nothing finds the Mechanica block [data-block-id=\"name\"].";
683
683
  /** Readiness flag of the standalone block preview route. */
684
684
  var PREVIEW_READY = "window.__MECHANICA_PREVIEW_READY__ === true";
685
685
  /** Parse a `--width` value into a list of positive integers. Defaults to `[1440]`. */
@@ -863,6 +863,11 @@ function planShot(target, options = {}, cwd = process.cwd()) {
863
863
  const selectors = options.section ?? [];
864
864
  if (selectors.length && isBlock) throw new Error("--section applies to page, URL and file shots — a block shot is already clipped to its block");
865
865
  if (selectors.some((selector) => !selector.trim())) throw new Error("--section needs a CSS selector, e.g. --section \"#hero\"");
866
+ for (const selector of selectors) try {
867
+ splitShadowSelector(selector);
868
+ } catch (error) {
869
+ throw new Error(`--section "${selector}": ${error instanceof Error ? error.message : error}`);
870
+ }
866
871
  const slugs = sectionSlugs(selectors);
867
872
  const scrolls = parseScrolls(options.scroll);
868
873
  return {
@@ -1193,7 +1198,9 @@ Usage:
1193
1198
  Screenshot any URL or local HTML file (a design
1194
1199
  handoff) the same way, without a dev server
1195
1200
  (--page </path>, --data <json|@file>, --width 1440,768,
1196
- --section <css> (repeatable: one PNG per section),
1201
+ --section <css> (repeatable: one PNG per section;
1202
+ looks inside open shadow roots, "host >>> inner"
1203
+ crosses one; #name also finds [data-block-id=name]),
1197
1204
  --scroll <px[,px…]> (viewport after scrolling),
1198
1205
  --no-freeze, --full, --out <path>, --server <url>,
1199
1206
  --browser <path>)
@@ -25,7 +25,10 @@ export interface OpenPageOptions extends GotoOptions {
25
25
  width?: number;
26
26
  /** Viewport height in CSS px. Default 900. */
27
27
  height?: number;
28
- /** A Chromium-based executable. Default: Edge or Chrome from their usual install paths. */
28
+ /**
29
+ * A Chromium-based executable. Default: the first installed browser that
30
+ * starts, from the usual install paths (on Windows Chrome, then Edge).
31
+ */
29
32
  browser?: string;
30
33
  /**
31
34
  * Emulate `prefers-reduced-motion: reduce`, as `mechanica shot` does: pages
@@ -36,7 +39,7 @@ export interface OpenPageOptions extends GotoOptions {
36
39
  export interface ScreenshotOptions {
37
40
  /** An explicit region in document coordinates. Wins over everything else. */
38
41
  clip?: Clip;
39
- /** A CSS selector: the bounding box of the first matching element. */
42
+ /** A CSS selector: the bounding box of the first matching element (see {@link BrowserPage.sectionClip}). */
40
43
  section?: string;
41
44
  /** The whole document (height capped at {@link MAX_SHOT_HEIGHT}). */
42
45
  full?: boolean;
@@ -71,7 +74,13 @@ export interface BrowserPage {
71
74
  freezeAnimations(): Promise<void>;
72
75
  /** Undo {@link freezeAnimations}. */
73
76
  unfreezeAnimations(): Promise<void>;
74
- /** The document-coordinate box of the first element matching `selector`. Throws when none matches or the box is empty. */
77
+ /**
78
+ * The document-coordinate box of the first element matching `selector`.
79
+ * Looks in the document, then in every open shadow root (`host >>> inner`
80
+ * crosses into `host`'s shadow root explicitly). A bare `#name` or `name`
81
+ * that matches nothing falls back to `[data-block-id="name"]`, a Mechanica
82
+ * block. Throws when nothing matches or the box is empty.
83
+ */
75
84
  sectionClip(selector: string): Promise<Clip>;
76
85
  /**
77
86
  * A PNG of the page: `clip`, else `section`, else `full`, else what is in
@@ -106,7 +115,12 @@ type SectionBox = Clip | {
106
115
  message: string;
107
116
  } | {
108
117
  error: 'missing';
118
+ /** Readable ids on the page (`#name`), shadow roots included. */
109
119
  ids: string[];
120
+ /** `data-block-id` values on the page. */
121
+ blockIds?: string[];
122
+ /** The block-id selector that was tried as well. */
123
+ fallback?: string | null;
110
124
  } | {
111
125
  error: 'empty';
112
126
  width: number;
@@ -42,12 +42,54 @@ export interface LaunchedBrowser {
42
42
  client: CdpClient;
43
43
  close: () => Promise<void>;
44
44
  }
45
+ /** A Chromium-based browser executable to try. */
46
+ export interface BrowserExecutable {
47
+ label: string;
48
+ path: string;
49
+ }
45
50
  /**
46
51
  * Spawn a headless Chromium-based browser with a DevTools endpoint and connect
47
- * to it. Prefers browsers already on the machine (Edge ships with Windows,
48
- * Chrome is everywhere), so nothing needs downloading.
52
+ * to it. With `explicitPath` (`--browser`) only that executable is tried.
53
+ * Otherwise the installed browsers of {@link platformBrowserCandidates} are
54
+ * tried in order until one starts — a browser already on the machine, so
55
+ * nothing needs downloading — and the error names each one that failed.
49
56
  */
50
57
  export declare function launchBrowser(explicitPath?: string): Promise<LaunchedBrowser>;
58
+ /**
59
+ * Try `start` on each candidate in order and return the first browser that
60
+ * comes up. When none does, the error lists every candidate and why it failed.
61
+ */
62
+ export declare function startFirstBrowser<T>(candidates: BrowserExecutable[], start: (candidate: BrowserExecutable) => Promise<T>): Promise<T>;
63
+ /**
64
+ * Spawn one browser on a fresh temporary profile and connect to it. When it
65
+ * does not come up (exits early, never prints its endpoint, refuses the
66
+ * connection), everything it started is ended and the profile removed before
67
+ * the error is rethrown.
68
+ */
69
+ export declare function startBrowser(executable: BrowserExecutable): Promise<LaunchedBrowser>;
70
+ /**
71
+ * The environment a browser starts with. On Windows it leaves out
72
+ * `__COMPAT_LAYER` (Git Bash and some tool runners set it to `RunAsInvoker`):
73
+ * under a compatibility layer Edge relaunches itself without it and exits at
74
+ * once, so the process we spawned never prints its endpoint while the
75
+ * relaunched one keeps running.
76
+ */
77
+ export declare function browserEnv(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
78
+ /**
79
+ * The Windows commands that end a browser which failed to start, best effort:
80
+ * `taskkill /T /F` on its process tree while the spawned process still runs
81
+ * (`pid`), then — because a browser that relaunched itself has outlived the
82
+ * process we spawned — every process still running on its temporary profile,
83
+ * found by the profile's folder name in the command line.
84
+ */
85
+ export declare function windowsKillCommands(pid: number | undefined, userDataDir: string): Array<[string, string[]]>;
86
+ /**
87
+ * Known install locations of Chromium-based browsers, in the order they are
88
+ * tried. On Windows Chrome comes first: Edge ships with Windows, so it is the
89
+ * fallback, and it relaunches itself under a compatibility layer (see
90
+ * {@link browserEnv}).
91
+ */
92
+ export declare function platformBrowserCandidates(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, home?: string): BrowserExecutable[];
51
93
  type CdpEventListener = (method: string, params: any, sessionId?: string) => void;
52
94
  /**
53
95
  * A minimal Chrome DevTools Protocol client over the platform `WebSocket`
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Finding the element behind a section selector (`mechanica shot --section`,
3
+ * `BrowserPage.sectionClip`): the lookup that runs in the page, and the pure
4
+ * helpers that prepare its arguments.
5
+ *
6
+ * - **Shadow DOM.** Design handoffs render their sections inside custom
7
+ * elements with open shadow roots, where `document.querySelector` sees
8
+ * nothing. The lookup tries the document first, then every open shadow root
9
+ * in tree order (depth first), matching the selector within each root.
10
+ * Slotted content belongs to its host's tree, so that tree's query covers
11
+ * it. `host >>> inner` crosses into the shadow root of `host` explicitly.
12
+ * - **Block ids.** Mechanica renders a block with `data-block-id="<node id>"`
13
+ * and no `id`, so a bare `#hero` (or `hero`) that matches nothing falls back
14
+ * to `[data-block-id="hero"]`.
15
+ */
16
+ /** Crosses into the shadow root of the elements matched by the part before it. */
17
+ export declare const SHADOW_COMBINATOR = ">>>";
18
+ /**
19
+ * Split a selector at `>>>` into trimmed parts (one part when there is none).
20
+ * A `>>>` inside quotes, brackets or parentheses, or after a backslash, is
21
+ * part of the selector. Throws when a part is empty (`>>> .a`, `.a >>>`).
22
+ */
23
+ export declare function splitShadowSelector(selector: string): string[];
24
+ /**
25
+ * The block-id selector to try when a bare `#name` or `name` matches nothing:
26
+ * `[data-block-id="name"]`. Null for any other selector. A name that is not a
27
+ * valid CSS id (`#1hero`) still gets one, so block ids starting with a digit
28
+ * are reachable too.
29
+ */
30
+ export declare function blockIdFallback(selector: string): string | null;
31
+ /** Readable ids and block ids listed in a "no match" error, at most this many of each. */
32
+ export declare const MAX_LISTED_IDS = 20;
33
+ /**
34
+ * In-page source of `(parts, fallback) => result`, where `parts` is a selector
35
+ * split by {@link splitShadowSelector} and `fallback` is
36
+ * {@link blockIdFallback}'s result (or null). It finds the first element
37
+ * matching `parts`, else the first matching `fallback`, in the document and
38
+ * in every open shadow root, and returns:
39
+ *
40
+ * - `{ element }`;
41
+ * - `{ error: 'invalid', message }` when the selector does not parse (and the
42
+ * fallback finds nothing);
43
+ * - `{ error: 'missing', ids, blockIds, fallback }`: the readable ids
44
+ * (`#name`) and the `data-block-id` values on the page, shadow roots
45
+ * included, for the error message.
46
+ *
47
+ * Plain JS with no outside references: it is sent to the page as text.
48
+ */
49
+ export declare const FIND_SECTION_SOURCE: string;
@@ -13,7 +13,12 @@ export interface ShotOptions {
13
13
  browser?: string;
14
14
  /** Screenshot the full page instead of clipping to the block element (or to the viewport with `scroll`). */
15
15
  full?: boolean;
16
- /** CSS selectors (`--section`, repeatable): one PNG each, clipped to the first match. Page/URL/file shots only. */
16
+ /**
17
+ * CSS selectors (`--section`, repeatable): one PNG each, clipped to the first
18
+ * match — in the document or an open shadow root (`host >>> inner` crosses
19
+ * one explicitly); a bare `#name` that matches nothing finds
20
+ * `[data-block-id="name"]`. Page/URL/file shots only.
21
+ */
17
22
  section?: string[];
18
23
  /** Scroll offset(s) in px, comma-separated: capture the viewport scrolled there. */
19
24
  scroll?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mechanica",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Build Vue 3 websites with a visual block editor",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,7 +70,7 @@
70
70
  "prepublishOnly": "bun ../../scripts/ensure-bun-publish.js && bun run build"
71
71
  },
72
72
  "dependencies": {
73
- "mechanica-shared": "2.2.0",
73
+ "mechanica-shared": "2.2.1",
74
74
  "@vue/compiler-sfc": "^3.5.38",
75
75
  "compact-json-schema": "^0.1.5",
76
76
  "estree-walker": "^3.0.3",