mechanica 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -156,6 +156,8 @@ Other `defineBlock` options: `icon`, `description` and `order` for the palette;
156
156
 
157
157
  Give every block meaningful `previewData`: it drives the palette's hover preview, the standalone preview route and `mechanica shot`. A `$slots` key fills slots with child blocks, e.g. `$slots: { default: [{ blockId: 'card', data: { title: 'One' } }] }`.
158
158
 
159
+ The exported site is prerendered, and the browser **hydrates** that HTML rather than rendering it again, so a block must render the same on the server and in the browser: read `window`, `document` or `matchMedia` only in `onMounted` (or behind a client-only flag set there), never in `setup` or the template. The dev server mounts into an empty page and never hydrates, so a mismatch shows only in the built site. To check, build with `MECHANICA_HYDRATION_DEBUG=1 mechanica build` and watch the browser console on the exported pages: each mismatch is logged with the element involved.
160
+
159
161
  ## Pages
160
162
 
161
163
  Each page is a `.page.md` file under `.mech/pages/`, and its path is the URL: `about.page.md` → `/about`, `blog/index.page.md` → `/blog`, `index.page.md` → `/`.
@@ -254,7 +256,7 @@ Editors can set a focal point and crop from the image field's **Position / Crop*
254
256
 
255
257
  ## Links and navigation
256
258
 
257
- `<Link :to="…">` takes a path string or a `smartLink` value. Internal links navigate client-side without a full reload, get an `is-active` class on the current page, and add the language prefix on multi-language sites. `useRouter().push(path)` navigates from code, and `useRoute().path` is the current path.
259
+ `<Link :to="…">` takes a path string or a `smartLink` value. Internal links navigate client-side without a full reload, get an `is-active` class on the current page, and add the language prefix on multi-language sites. `useRouter().push(path)` navigates from code, and `useRoute().path` is the current path. A target the client-side navigation cannot take over (an error response, or a page without Mechanica state) is opened as a normal full-page navigation instead.
258
260
 
259
261
  ## Listing pages and pagination
260
262
 
@@ -339,6 +341,8 @@ Every export also warns about missing titles and descriptions, pages without exa
339
341
 
340
342
  By default all blocks share one `blocks` chunk, cached across pages. Mark heavy, rarely used blocks with `chunk: 'charts'` in `defineBlock` to split them into their own chunk, or set the plugin's `blockChunks: 'per-block'`. The export adds per-page `<link rel="modulepreload">` and stylesheet tags, so each page loads exactly the block code it uses.
341
343
 
344
+ Code outside blocks follows Vite's defaults. To group it yourself, add your own groups under `build.rollupOptions.output.codeSplitting.groups` in `vite.config.ts`: they are kept next to Mechanica's blocks group and listed before it, so at equal `priority` yours win. The plugin sets `includeDependenciesRecursively: false`, so a group holds exactly the modules its `test` matches.
345
+
342
346
  ## Checking your work visually
343
347
 
344
348
  ```bash
@@ -361,7 +365,7 @@ These drive a locally installed Chrome or Edge (or `--browser <path>`) and need
361
365
  | --- | --- |
362
366
  | `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
367
  | `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` |
368
+ | `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
369
  | `mechanica thumbs [--blocks]` | Regenerate page or block thumbnails for the editor |
366
370
  | `mechanica images` | Generate image dimensions and blur-up previews for `.mech/assets` (needs `sharp`; `--force` redoes all) |
367
371
  | `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 +403,7 @@ From `mechanica`:
399
403
 
400
404
  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
405
 
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`.
406
+ 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
407
 
404
408
  ```js
405
409
  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 };
@@ -1,5 +1,5 @@
1
1
  import { computed, defineComponent, h, inject, nextTick, shallowReactive, shallowRef } from "vue";
2
- import { localePath, resolveComposedTemplate, templateBlockIds, walkTree } from "mechanica-shared";
2
+ import { localePath, paginationVariantPath, resolveComposedTemplate, templateBlockIds, walkTree } from "mechanica-shared";
3
3
  //#region src/core/state.ts
4
4
  /** Vue injection key for the {@link MechanicaContext}. */
5
5
  var mechanicaKey = Symbol("mechanica");
@@ -36,7 +36,7 @@ function createRouter(content, data, options) {
36
36
  if (!baseUrl) return path;
37
37
  return path.startsWith(baseUrl) ? path.slice(baseUrl.length) : path;
38
38
  };
39
- const initialPath = isBrowser ? normalizePath(stripBase(window.location.pathname)) : "/";
39
+ const initialPath = isBrowser ? normalizePath(stripBase(window.location.pathname)) : normalizePath(options.initialPath ?? "/");
40
40
  const currentRoute = shallowReactive({ path: initialPath });
41
41
  const cache = /* @__PURE__ */ new Map();
42
42
  const inflight = /* @__PURE__ */ new Map();
@@ -45,12 +45,14 @@ function createRouter(content, data, options) {
45
45
  const existing = inflight.get(path);
46
46
  if (existing) return existing;
47
47
  const run = async () => {
48
- const html = await fetch(path).then((res) => res.text());
48
+ const res = await fetch(path);
49
+ const html = res.ok ? await res.text() : "";
49
50
  const match = html.match(/window\.state\s*=\s*(\{.+?\})<\/script>/s)?.[1];
50
- const state = match ? JSON.parse(match) : {
51
- content: [],
52
- data: {}
53
- };
51
+ if (!match) {
52
+ if (isBrowser) window.location.assign(path);
53
+ return;
54
+ }
55
+ const state = JSON.parse(match);
54
56
  state.page = {
55
57
  ...state.page,
56
58
  title: html.match(/<title>(.*?)<\/title>/)?.[1]
@@ -869,6 +871,25 @@ function runtimeLayoutNames() {
869
871
  return window[RUNTIME_KEY]?.layoutNames ?? [];
870
872
  }
871
873
  //#endregion
874
+ //#region src/core/page-path.ts
875
+ /**
876
+ * The address a page's state is served at: its logical `path` under the
877
+ * locale prefix (`/about` -> `/ru/about`) plus the pagination variant suffix
878
+ * (`/blog` -> `/blog/2`). This is what the browser shows in the address bar for
879
+ * that state, so the server-side router starts there and `<Link>` resolves the
880
+ * same `is-active` on both sides of hydration.
881
+ *
882
+ * The export already serves a variant under its own path (`page.path` is
883
+ * `/blog/2` there, while dev keeps the base `/blog`); a suffix that is
884
+ * already present is kept, as `usePagination().pathFor` does.
885
+ */
886
+ function servedPagePath(page, locales) {
887
+ const localized = localePath(page?.path || "/", page?.locale, locales);
888
+ const variant = page?.pagination?.page ?? 1;
889
+ if (variant > 1 && localized.endsWith(`/${variant}`)) return localized;
890
+ return paginationVariantPath(localized, variant);
891
+ }
892
+ //#endregion
872
893
  //#region src/core/create-mechanica.ts
873
894
  /**
874
895
  * Vue plugin that wires the Mechanica runtime: page content, shared data, the
@@ -903,6 +924,7 @@ function createMechanica(options = {}) {
903
924
  router: createRouter(content, data, {
904
925
  mode,
905
926
  baseUrl: initial?.baseUrl,
927
+ initialPath: servedPagePath(initial?.page, initial?.locales),
906
928
  ensureBlocks: loaders ? async (next) => {
907
929
  await loadBlocks(loaders, next, blocks, composedMap);
908
930
  } : void 0,
@@ -927,7 +949,7 @@ function createMechanica(options = {}) {
927
949
  mergeData: (incoming) => mergeData(context.data, incoming),
928
950
  setPage: (next) => {
929
951
  applyPage(next);
930
- if (next.path) context.router.currentRoute.path = context.router.normalizePath(next.path);
952
+ if (next.path) context.router.currentRoute.path = context.router.normalizePath(servedPagePath(next, initial?.locales));
931
953
  }
932
954
  });
933
955
  } };
@@ -1,4 +1,4 @@
1
- import { a as renderBlocks, t as createMechanica } from "./create-mechanica-CYaKXuQd.js";
1
+ import { a as renderBlocks, t as createMechanica } from "./create-mechanica-CMcmeOtG.js";
2
2
  import { createApp, defineComponent, h } from "vue";
3
3
  import { areFieldSchemasRegistered, buildPreviewData, registerFieldSchemas } from "mechanica-shared";
4
4
  import { unfoldSchema } from "compact-json-schema";
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>)
package/dist/composer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import './editor.css';
2
- import { a as renderBlocks, l as ANCHOR_H, t as createMechanica, u as ANCHOR_V } from "./chunks/create-mechanica-CYaKXuQd.js";
2
+ import { a as renderBlocks, l as ANCHOR_H, t as createMechanica, u as ANCHOR_V } from "./chunks/create-mechanica-CMcmeOtG.js";
3
3
  import { A as SmartLinkField_default, C as removeBlock, I as editorBackend, L as createSaveQueue, R as VSelect_default, S as relocateBlock, T as registerBuiltinFieldEditors, _ as findBlock, a as FieldControl_default, b as moveBlock, f as toBlockMeta, g as ensureSlotList, h as duplicateBlock, i as SchemaField_default, m as cloneBlock, n as normalizeTemplate, o as shortcutChar, p as humanize, v as findParentList, w as uid, x as placeBlock, y as findParentSlot, z as VPopover_default } from "./chunks/editor-DoYHG0mS.js";
4
4
  import { d as _plugin_vue_export_helper_default, l as VIcon_default, u as contents } from "./chunks/ImagePickerDialog-DJGhtW3r.js";
5
5
  import { Fragment, Teleport, computed, createApp, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, inject, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, provide, reactive, ref, renderList, renderSlot, resolveComponent, toDisplayString, unref, vModelText, watch, withCtx, withDirectives, withKeys, withModifiers } from "vue";
package/dist/editor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import './editor.css';
2
- import { _ as mechanicaKey, a as renderBlocks, c as registerElements, i as createComposedComponent, m as createRouter, n as pushStateUpdate, r as runtimeLayoutNames } from "./chunks/create-mechanica-CYaKXuQd.js";
3
- import { i as getDataEntries, t as buildPreviewContent } from "./chunks/preview-CbUxh2gL.js";
2
+ import { _ as mechanicaKey, a as renderBlocks, c as registerElements, i as createComposedComponent, m as createRouter, n as pushStateUpdate, r as runtimeLayoutNames } from "./chunks/create-mechanica-CMcmeOtG.js";
3
+ import { i as getDataEntries, t as buildPreviewContent } from "./chunks/preview-BrtQ0KpO.js";
4
4
  import { C as removeBlock, D as contextMenuKey, E as registerRichTextWidgets, F as pathFromName, I as editorBackend, L as createSaveQueue, M as filterPages, N as groupPagesByFolder, O as createContextMenu, P as pageThumbUrl, R as VSelect_default, S as relocateBlock, T as registerBuiltinFieldEditors, _ as findBlock, b as moveBlock, c as blockAvailableIn, d as isUnderFolder, f as toBlockMeta, h as duplicateBlock, j as fetchPages, k as VSegmented_default, l as compareBlocks, m as cloneBlock, o as shortcutChar, p as humanize, r as SchemaForm_default, s as blockAvailableForLayout, t as createRootFrame, u as createContentBlock, w as uid, x as placeBlock, y as findParentSlot, z as VPopover_default } from "./chunks/editor-DoYHG0mS.js";
5
5
  import { c as useDialog, d as _plugin_vue_export_helper_default, l as VIcon_default, n as VDialog_default, o as createDialogStore, s as dialogKey } from "./chunks/ImagePickerDialog-DJGhtW3r.js";
6
6
  import { Fragment, Teleport, Transition, TransitionGroup, computed, createApp, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, inject, markRaw, mergeProps, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onErrorCaptured, onMounted, onScopeDispose, openBlock, provide, reactive, ref, renderList, resolveComponent, resolveDynamicComponent, shallowRef, toDisplayString, unref, useTemplateRef, vModelCheckbox, vModelText, watch, withCtx, withDirectives, withModifiers } from "vue";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { _ as mechanicaKey, a as renderBlocks, c as registerElements, d as Link, f as loadBlocks, g as useRouter, h as useRoute, i as createComposedComponent, m as createRouter, o as elements, p as usedBlockIds, s as isElementBlock, t as createMechanica } from "./chunks/create-mechanica-CYaKXuQd.js";
2
- import { a as registerDataEntry, i as getDataEntries, n as mountPreviewApp, r as clearDataEntries, t as buildPreviewContent } from "./chunks/preview-CbUxh2gL.js";
1
+ import { _ as mechanicaKey, a as renderBlocks, c as registerElements, d as Link, f as loadBlocks, g as useRouter, h as useRoute, i as createComposedComponent, m as createRouter, o as elements, p as usedBlockIds, s as isElementBlock, t as createMechanica } from "./chunks/create-mechanica-CMcmeOtG.js";
2
+ import { a as registerDataEntry, i as getDataEntries, n as mountPreviewApp, r as clearDataEntries, t as buildPreviewContent } from "./chunks/preview-BrtQ0KpO.js";
3
3
  import { computed, createApp, createSSRApp, defineComponent, h, inject, isReactive, onMounted, onServerPrefetch, reactive, ref, shallowReactive } from "vue";
4
4
  import { localePath, normalizeClassManifest, passDefaultValue } from "mechanica-shared";
5
5
  import { unfoldSchema } from "compact-json-schema";
@@ -205,13 +205,13 @@ var Image = defineComponent({
205
205
  const cropped = !!image.croppedSrc;
206
206
  return h("img", {
207
207
  ref: el,
208
+ loading: props.eager ? "eager" : "lazy",
209
+ fetchpriority: props.eager ? "high" : void 0,
210
+ decoding: "async",
208
211
  src: cropped ? image.croppedSrc : image.src,
209
212
  alt: image.alt ?? "",
210
213
  width: (cropped ? image.croppedWidth : image.width) ?? void 0,
211
214
  height: (cropped ? image.croppedHeight : image.height) ?? void 0,
212
- loading: props.eager ? "eager" : "lazy",
213
- fetchpriority: props.eager ? "high" : void 0,
214
- decoding: "async",
215
215
  style: lqipStyle.value,
216
216
  onLoad: () => {
217
217
  loaded.value = true;
package/dist/plugin.js CHANGED
@@ -660,6 +660,7 @@ function generateComponentsModule(manifestSpecifier) {
660
660
  * `createApp().mount()` with an explicit, generated entry.
661
661
  */
662
662
  function generateClientEntry(options) {
663
+ const hydrate = options.hydrate ? "ssr: true, " : "";
663
664
  if (options.lazy) return [
664
665
  `import 'virtual:mechanica/elements.css'`,
665
666
  `import definition from ${JSON.stringify(options.userEntry)}`,
@@ -672,7 +673,7 @@ function generateClientEntry(options) {
672
673
  `const composedMap = new Map(composedList.map((def) => [def.id, def]))`,
673
674
  `loadBlocks(blockLoaders, state.content ?? [], undefined, composedMap).then((blocks) => {`,
674
675
  ` registerComponents(blocks)`,
675
- ` createMechanicaApp(definition, { mode: ${JSON.stringify(options.mode)}, state, blocks, blockLoaders, composed: composedList })`,
676
+ ` createMechanicaApp(definition, { ${hydrate}mode: ${JSON.stringify(options.mode)}, state, blocks, blockLoaders, composed: composedList })`,
676
677
  ` .mount(${JSON.stringify(options.mount)})`,
677
678
  `})`,
678
679
  ``
@@ -697,7 +698,7 @@ function generateClientEntry(options) {
697
698
  `const state = window.state ?? { content: [], data: {} }`,
698
699
  `registerComponents(blocksMap)`,
699
700
  ...resolveQuery,
700
- `createMechanicaApp(definition, { mode: ${JSON.stringify(options.mode)}, state, blocks: blocksMap, composed: composedList${options.mode === "dev" ? ", resolveQuery" : ""} })`,
701
+ `createMechanicaApp(definition, { ${hydrate}mode: ${JSON.stringify(options.mode)}, state, blocks: blocksMap, composed: composedList${options.mode === "dev" ? ", resolveQuery" : ""} })`,
701
702
  ` .mount(${JSON.stringify(options.mount)})`,
702
703
  ``
703
704
  ].join("\n");
@@ -1118,18 +1119,22 @@ function mechanica(options = {}) {
1118
1119
  config(_config, env) {
1119
1120
  if (env.command !== "build" || env.isSsrBuild) return;
1120
1121
  if (process.env.MECHANICA_EDITOR_BUILD) return;
1121
- return { build: { rollupOptions: { output: { codeSplitting: {
1122
- includeDependenciesRecursively: false,
1123
- groups: [{
1124
- ...supportsGroupDebugName(this.meta?.rolldownVersion) ? { debugName: "mechanica:blocks" } : {},
1125
- name: (id, ctx) => {
1126
- const own = blockChunkNames.get(slash(id.split("?")[0]));
1127
- if (own !== void 0) return groupName(own);
1128
- const g = sharedGroupOf(id, ctx, /* @__PURE__ */ new Set());
1129
- return g === "skip" ? null : g;
1130
- }
1131
- }]
1132
- } } } } };
1122
+ const label = supportsGroupDebugName(this.meta?.rolldownVersion) ? { debugName: "mechanica:blocks" } : {};
1123
+ return {
1124
+ ...process.env.MECHANICA_HYDRATION_DEBUG ? { define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "true" } } : {},
1125
+ build: { rollupOptions: { output: { codeSplitting: {
1126
+ includeDependenciesRecursively: false,
1127
+ groups: [{
1128
+ ...label,
1129
+ name: (id, ctx) => {
1130
+ const own = blockChunkNames.get(slash(id.split("?")[0]));
1131
+ if (own !== void 0) return groupName(own);
1132
+ const g = sharedGroupOf(id, ctx, /* @__PURE__ */ new Set());
1133
+ return g === "skip" ? null : g;
1134
+ }
1135
+ }]
1136
+ } } } }
1137
+ };
1133
1138
  },
1134
1139
  configResolved(config) {
1135
1140
  blocksDir = join(config.root, options.blocksDir ?? "src/blocks");
@@ -1196,7 +1201,8 @@ function mechanica(options = {}) {
1196
1201
  userEntry,
1197
1202
  mount,
1198
1203
  mode: isDev || isEditorBuild ? "dev" : "client",
1199
- lazy: isClientBuild
1204
+ lazy: isClientBuild,
1205
+ hydrate: isClientBuild
1200
1206
  });
1201
1207
  if (id === RESOLVED_SSR_ID) return generateSsrEntry({
1202
1208
  userEntry,
@@ -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;
@@ -0,0 +1,13 @@
1
+ import { type LocalesConfig, type PageMeta } from 'mechanica-shared';
2
+ /**
3
+ * The address a page's state is served at: its logical `path` under the
4
+ * locale prefix (`/about` -> `/ru/about`) plus the pagination variant suffix
5
+ * (`/blog` -> `/blog/2`). This is what the browser shows in the address bar for
6
+ * that state, so the server-side router starts there and `<Link>` resolves the
7
+ * same `is-active` on both sides of hydration.
8
+ *
9
+ * The export already serves a variant under its own path (`page.path` is
10
+ * `/blog/2` there, while dev keeps the base `/blog`); a suffix that is
11
+ * already present is kept, as `usePagination().pathFor` does.
12
+ */
13
+ export declare function servedPagePath(page: PageMeta | undefined, locales?: LocalesConfig | null): string;
@@ -27,6 +27,13 @@ export interface CreateRouterOptions {
27
27
  * runtime context.
28
28
  */
29
29
  applyState?: (state: State) => void;
30
+ /**
31
+ * The address the rendered page is served at (locale prefix and pagination
32
+ * variant included). The server has no address bar, so this seeds its
33
+ * current route: `<Link>` then computes the same `is-active` the client
34
+ * will on hydration. The client reads `window.location` instead.
35
+ */
36
+ initialPath?: string;
30
37
  }
31
38
  /**
32
39
  * Create the client-side router. On the server it is inert (no listeners, no
@@ -12,6 +12,15 @@ export interface ClientEntryOptions {
12
12
  * components while unused blocks stay in their own chunks, never downloaded.
13
13
  */
14
14
  lazy?: boolean;
15
+ /**
16
+ * Hydrate the server-rendered HTML (`createSSRApp`) instead of mounting
17
+ * into an empty container. The production client build: the export
18
+ * prerenders every page, and re-rendering would throw that DOM away and
19
+ * refetch every image at once (elements the client creates start their
20
+ * fetch before `loading="lazy"` applies). Dev and the editor build mount
21
+ * into an empty `#app`, so they keep `createApp`.
22
+ */
23
+ hydrate?: boolean;
15
24
  }
16
25
  /**
17
26
  * Generate the client entry that mounts the user's app from their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mechanica",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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.2",
74
74
  "@vue/compiler-sfc": "^3.5.38",
75
75
  "compact-json-schema": "^0.1.5",
76
76
  "estree-walker": "^3.0.3",