mechanica 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,38 +2,61 @@ 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
6
  import fs, { readFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
7
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
7
8
  import { EDITOR_DIST_DIR, LEGACY_UPLOADS_PREFIX, SITE_MANIFEST_FILE, UPLOADS_PREFIX } from "mechanica-shared";
8
9
  import { appendFile, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
9
10
  import os from "node:os";
10
- import { spawn } from "node:child_process";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { build } from "vite";
13
13
  //#region src/cli/args.ts
14
14
  /** Parse `argv` (without node/script) into a command, positionals and a `--flag[=value]` map. */
15
- function parseArgs(argv) {
15
+ function parseArgs(argv, options = {}) {
16
16
  const [command, ...rest] = argv;
17
17
  const args = [];
18
18
  const flags = {};
19
+ for (const token of scanArgs(rest, options)) if (token.flag === void 0) args.push(token.value);
20
+ else flags[token.flag] = token.value;
21
+ return {
22
+ command,
23
+ args,
24
+ flags
25
+ };
26
+ }
27
+ /**
28
+ * Every value a repeatable flag was given, in order — `--section a --section b`
29
+ * → `['a', 'b']`; a bare `--section` contributes `true`. Takes the same `argv`
30
+ * as {@link parseArgs} (command first).
31
+ */
32
+ function flagValues(argv, name, options = {}) {
33
+ return scanArgs(argv.slice(1), options).filter((token) => token.flag === name).map((token) => token.value);
34
+ }
35
+ function scanArgs(rest, { booleans = [] }) {
36
+ const tokens = [];
19
37
  for (let i = 0; i < rest.length; i++) {
20
38
  const arg = rest[i];
21
39
  if (!arg.startsWith("--")) {
22
- args.push(arg);
40
+ tokens.push({ value: arg });
23
41
  continue;
24
42
  }
25
43
  const key = arg.slice(2);
26
44
  if (key.includes("=")) {
27
45
  const eq = key.indexOf("=");
28
- flags[key.slice(0, eq)] = key.slice(eq + 1);
29
- } else if (rest[i + 1] && !rest[i + 1].startsWith("--")) flags[key] = rest[++i];
30
- else flags[key] = true;
46
+ tokens.push({
47
+ flag: key.slice(0, eq),
48
+ value: key.slice(eq + 1)
49
+ });
50
+ } else if (!booleans.includes(key) && rest[i + 1] && !rest[i + 1].startsWith("--")) tokens.push({
51
+ flag: key,
52
+ value: rest[++i]
53
+ });
54
+ else tokens.push({
55
+ flag: key,
56
+ value: true
57
+ });
31
58
  }
32
- return {
33
- command,
34
- args,
35
- flags
36
- };
59
+ return tokens;
37
60
  }
38
61
  //#endregion
39
62
  //#region src/cli/platform/engine-version.ts
@@ -621,27 +644,23 @@ async function runPush() {
621
644
  console.info(`\n${branch} is live at ${deploy.url ?? status.url} — ${deploy.pages} page(s)`);
622
645
  }
623
646
  //#endregion
624
- //#region src/cli/headless.ts
625
- /**
626
- * Headless-browser plumbing shared by `mechanica shot` and `mechanica thumbs`:
627
- * dev-server discovery, browser discovery/spawn, and a minimal CDP client.
628
- *
629
- * Talks raw CDP over the platform `WebSocket` instead of using an automation
630
- * library: Playwright/Puppeteer launch handshakes rely on Node-only fd pipes
631
- * that hang under Bun, and screenshots need only a handful of CDP calls.
632
- */
647
+ //#region src/cli/dev-server.ts
633
648
  /**
634
649
  * Find a dev server to render against: an explicit origin, a running local
635
650
  * Mechanica dev server, or an ephemeral one booted from the project's
636
651
  * `vite.config.ts` and torn down afterwards.
652
+ *
653
+ * The only browser-side helper that needs Vite, kept out of `headless.ts` so
654
+ * the public `mechanica/browser` entry never pulls it in.
637
655
  */
638
656
  async function resolveServer(flag) {
639
657
  if (flag) return {
640
658
  origin: flag.replace(/\/+$/, ""),
641
659
  close: async () => {}
642
660
  };
643
- for (const origin of ["http://127.0.0.1:5173", "http://localhost:5173"]) if (await isMechanicaServer(origin)) return {
644
- origin,
661
+ const running = await findDevServer();
662
+ if (running) return {
663
+ origin: running,
645
664
  close: async () => {}
646
665
  };
647
666
  console.info("No running dev server found — starting one…");
@@ -658,299 +677,11 @@ async function resolveServer(flag) {
658
677
  close: () => server.close()
659
678
  };
660
679
  }
661
- async function isMechanicaServer(origin) {
662
- try {
663
- return (await fetch(`${origin}/@mechanica/folders`, { signal: AbortSignal.timeout(1e3) })).ok;
664
- } catch {
665
- return false;
666
- }
667
- }
668
- async function setViewport(cdp, sessionId, width, height = 900) {
669
- await cdp.send("Emulation.setDeviceMetricsOverride", {
670
- width,
671
- height,
672
- deviceScaleFactor: 1,
673
- mobile: false
674
- }, sessionId);
675
- }
676
- async function evaluateValue(cdp, sessionId, expression) {
677
- return (await cdp.send("Runtime.evaluate", {
678
- expression,
679
- returnByValue: true
680
- }, sessionId)).result?.value;
681
- }
682
- /**
683
- * Poll an expression on the page until it is `true` or the timeout elapses.
684
- * Evaluate errors (execution context destroyed mid-navigation) count as "not
685
- * ready yet" rather than failing the wait.
686
- */
687
- async function waitForCondition(cdp, sessionId, expression, timeout) {
688
- const deadline = Date.now() + timeout;
689
- while (Date.now() < deadline) {
690
- try {
691
- if (await evaluateValue(cdp, sessionId, expression)) return;
692
- } catch {}
693
- await new Promise((resolve) => setTimeout(resolve, 100));
694
- }
695
- throw new Error(`Timed out after ${timeout}ms waiting for the page to become ready`);
696
- }
697
- /** Wait for web fonts and all images — normal pages have no injected ready flag. */
698
- async function settlePageMedia(cdp, sessionId) {
699
- await cdp.send("Runtime.evaluate", {
700
- expression: `Promise.all([
701
- document.fonts ? document.fonts.ready : null,
702
- ...[...document.images].map((img) =>
703
- img.complete ? null : new Promise((done) => { img.onload = done; img.onerror = done })),
704
- ].filter(Boolean)).then(() => true)`,
705
- awaitPromise: true,
706
- returnByValue: true
707
- }, sessionId);
708
- }
709
- /** Freeze animations/transitions so captures are deterministic across runs. */
710
- async function freezeAnimations(cdp, sessionId) {
711
- await cdp.send("Runtime.evaluate", { expression: `{
712
- const style = document.createElement('style')
713
- style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }'
714
- document.head.appendChild(style)
715
- }` }, sessionId);
716
- }
717
- /** Chromium refuses to encode arbitrarily tall captures — cap and say so. */
718
- var MAX_SHOT_HEIGHT = 12e3;
719
- /** The full document size as a screenshot clip. */
720
- async function fullPageClip(cdp, sessionId) {
721
- const metrics = await cdp.send("Page.getLayoutMetrics", {}, sessionId);
722
- const size = metrics.cssContentSize ?? metrics.contentSize;
723
- const height = Math.min(size.height, MAX_SHOT_HEIGHT);
724
- if (height < size.height) console.info(`(page is ${Math.round(size.height)}px tall — clipped to ${MAX_SHOT_HEIGHT}px)`);
725
- return {
726
- x: 0,
727
- y: 0,
728
- width: size.width,
729
- height
730
- };
731
- }
732
- /**
733
- * Spawn a headless Chromium-based browser with a DevTools endpoint and connect
734
- * to it. Prefers browsers already on the machine (Edge ships with Windows,
735
- * Chrome is everywhere), so nothing needs downloading.
736
- */
737
- async function launchBrowser(explicitPath) {
738
- const executable = findBrowserExecutable(explicitPath);
739
- const userDataDir = fs.mkdtempSync(join(os.tmpdir(), "mechanica-shot-"));
740
- const child = spawn(executable.path, [
741
- "--headless=new",
742
- "--remote-debugging-port=0",
743
- `--user-data-dir=${userDataDir}`,
744
- "--no-first-run",
745
- "--no-default-browser-check",
746
- "--disable-extensions",
747
- "--hide-scrollbars",
748
- "about:blank"
749
- ], { stdio: [
750
- "ignore",
751
- "ignore",
752
- "pipe"
753
- ] });
754
- const cleanup = async () => {
755
- const exited = new Promise((resolve) => {
756
- if (child.exitCode != null) return resolve();
757
- child.once("exit", () => resolve());
758
- setTimeout(resolve, 3e3);
759
- });
760
- child.kill();
761
- await exited;
762
- for (let attempt = 0; attempt < 3; attempt++) try {
763
- fs.rmSync(userDataDir, {
764
- recursive: true,
765
- force: true
766
- });
767
- return;
768
- } catch {
769
- await new Promise((resolve) => setTimeout(resolve, 300));
770
- }
771
- };
772
- let endpoint;
773
- try {
774
- endpoint = await waitForDevtoolsEndpoint(child, 2e4);
775
- } catch (error) {
776
- await cleanup();
777
- throw new Error(`Failed to start ${executable.label} (${executable.path}): ${error instanceof Error ? error.message : error}`);
778
- }
779
- const client = await CdpClient.connect(endpoint);
780
- return {
781
- client,
782
- close: async () => {
783
- await client.send("Browser.close").catch(() => {});
784
- client.dispose();
785
- await cleanup();
786
- }
787
- };
788
- }
789
- /** Read the spawned browser's stderr until it prints its DevTools WebSocket URL. */
790
- function waitForDevtoolsEndpoint(child, timeout) {
791
- return new Promise((resolve, reject) => {
792
- let buffer = "";
793
- const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`no DevTools endpoint within ${timeout}ms`)), timeout);
794
- child.stderr.on("data", (chunk) => {
795
- buffer += chunk.toString();
796
- const match = buffer.match(/DevTools listening on (ws:\/\/\S+)/);
797
- if (match) {
798
- clearTimeout(timer);
799
- resolve(match[1]);
800
- }
801
- });
802
- child.on("error", (error) => {
803
- clearTimeout(timer);
804
- reject(error);
805
- });
806
- child.on("exit", (code) => {
807
- clearTimeout(timer);
808
- reject(/* @__PURE__ */ new Error(`browser exited early (code ${code})`));
809
- });
810
- });
811
- }
812
- /** Find a Chromium-based executable: an explicit path, or a known install location. */
813
- function findBrowserExecutable(explicitPath) {
814
- if (explicitPath) {
815
- if (!fs.existsSync(explicitPath)) throw new Error(`--browser not found: ${explicitPath}`);
816
- return {
817
- label: "browser from --browser",
818
- path: explicitPath
819
- };
820
- }
821
- const candidates = platformBrowserCandidates();
822
- const found = candidates.find((candidate) => fs.existsSync(candidate.path));
823
- if (!found) {
824
- const looked = candidates.map((c) => ` ${c.label}: ${c.path}`).join("\n");
825
- throw new Error(`No Chromium-based browser found. Looked for:\n${looked}\nInstall Chrome or Edge, or pass --browser <path-to-executable>.`);
826
- }
827
- return found;
828
- }
829
- function platformBrowserCandidates() {
830
- if (process.platform === "win32") {
831
- const programFiles = process.env["ProgramFiles"] ?? "C:\\Program Files";
832
- const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
833
- const localAppData = process.env["LOCALAPPDATA"] ?? join(os.homedir(), "AppData", "Local");
834
- return [
835
- {
836
- label: "Microsoft Edge",
837
- path: join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe")
838
- },
839
- {
840
- label: "Microsoft Edge",
841
- path: join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe")
842
- },
843
- {
844
- label: "Google Chrome",
845
- path: join(programFiles, "Google", "Chrome", "Application", "chrome.exe")
846
- },
847
- {
848
- label: "Google Chrome",
849
- path: join(localAppData, "Google", "Chrome", "Application", "chrome.exe")
850
- }
851
- ];
852
- }
853
- if (process.platform === "darwin") return [
854
- {
855
- label: "Google Chrome",
856
- path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
857
- },
858
- {
859
- label: "Microsoft Edge",
860
- path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
861
- },
862
- {
863
- label: "Chromium",
864
- path: "/Applications/Chromium.app/Contents/MacOS/Chromium"
865
- }
866
- ];
867
- return [
868
- {
869
- label: "Google Chrome",
870
- path: "/usr/bin/google-chrome"
871
- },
872
- {
873
- label: "Chromium",
874
- path: "/usr/bin/chromium"
875
- },
876
- {
877
- label: "Chromium",
878
- path: "/usr/bin/chromium-browser"
879
- },
880
- {
881
- label: "Microsoft Edge",
882
- path: "/usr/bin/microsoft-edge"
883
- }
884
- ];
885
- }
886
- /**
887
- * A minimal Chrome DevTools Protocol client over the platform `WebSocket`
888
- * (native in Bun and Node ≥ 22): request/response matching by id, plus event
889
- * fan-out. Flat session mode — pass a `sessionId` to address a page target.
890
- */
891
- var CdpClient = class CdpClient {
892
- ws;
893
- nextId = 1;
894
- pending = /* @__PURE__ */ new Map();
895
- listeners = [];
896
- constructor(ws) {
897
- this.ws = ws;
898
- ws.onmessage = (event) => this.onMessage(String(event.data));
899
- ws.onclose = () => {
900
- for (const { reject } of this.pending.values()) reject(/* @__PURE__ */ new Error("CDP connection closed"));
901
- this.pending.clear();
902
- };
903
- }
904
- static connect(url) {
905
- return new Promise((resolve, reject) => {
906
- const ws = new WebSocket(url);
907
- const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`CDP connect timeout: ${url}`)), 1e4);
908
- ws.onopen = () => {
909
- clearTimeout(timer);
910
- resolve(new CdpClient(ws));
911
- };
912
- ws.onerror = () => {
913
- clearTimeout(timer);
914
- reject(/* @__PURE__ */ new Error(`CDP connection failed: ${url}`));
915
- };
916
- });
917
- }
918
- send(method, params = {}, sessionId) {
919
- const id = this.nextId++;
920
- this.ws.send(JSON.stringify({
921
- id,
922
- method,
923
- params,
924
- ...sessionId ? { sessionId } : {}
925
- }));
926
- return new Promise((resolve, reject) => this.pending.set(id, {
927
- resolve,
928
- reject
929
- }));
930
- }
931
- onEvent(listener) {
932
- this.listeners.push(listener);
933
- }
934
- dispose() {
935
- try {
936
- this.ws.close();
937
- } catch {}
938
- }
939
- onMessage(raw) {
940
- const message = JSON.parse(raw);
941
- if (typeof message.id === "number") {
942
- const pending = this.pending.get(message.id);
943
- if (!pending) return;
944
- this.pending.delete(message.id);
945
- if (message.error) pending.reject(/* @__PURE__ */ new Error(`${message.error.message ?? "CDP error"}`));
946
- else pending.resolve(message.result ?? {});
947
- return;
948
- }
949
- if (typeof message.method === "string") for (const listener of this.listeners) listener(message.method, message.params ?? {}, message.sessionId);
950
- }
951
- };
952
680
  //#endregion
953
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>]";
683
+ /** Readiness flag of the standalone block preview route. */
684
+ var PREVIEW_READY = "window.__MECHANICA_PREVIEW_READY__ === true";
954
685
  /** Parse a `--width` value into a list of positive integers. Defaults to `[1440]`. */
955
686
  function parseWidths(value) {
956
687
  if (!value) return [1440];
@@ -958,6 +689,13 @@ function parseWidths(value) {
958
689
  if (widths.length === 0 || widths.some((w) => !Number.isFinite(w) || w <= 0)) throw new Error(`Invalid --width value "${value}" — expected e.g. 1440 or 1440,768,390`);
959
690
  return widths;
960
691
  }
692
+ /** Parse a `--scroll` value (px from the top, comma-separated). Undefined when absent. */
693
+ function parseScrolls(value) {
694
+ if (value === void 0) return void 0;
695
+ const parts = value.split(",").map((part) => part.trim());
696
+ if (parts.some((part) => !/^\d+$/.test(part))) throw new Error(`Invalid --scroll value "${value}" — expected pixels from the top, e.g. 800 or 0,800,1600`);
697
+ return parts.map(Number);
698
+ }
961
699
  /** Resolve `--data`: inline JSON or `@file`, validated to a JSON object. */
962
700
  function resolveDataArg(value) {
963
701
  if (!value) return void 0;
@@ -977,13 +715,22 @@ function previewUrl(origin, blockId, data) {
977
715
  const query = data ? `?data=${encodeURIComponent(JSON.stringify(data))}` : "";
978
716
  return `${base}/@mechanica/preview/${encodeURIComponent(blockId)}${query}`;
979
717
  }
980
- /** Resolve the output PNG path for one width. */
981
- function outputPath(out, name, width, multi) {
982
- const suffix = multi ? `-w${width}` : "";
718
+ /**
719
+ * Resolve the output PNG path for one capture. `variant` (`-<section>`,
720
+ * `-y<scroll>`) is always part of the name; the `-w<width>` suffix only when
721
+ * shooting several widths. An `--out` ending in `.png` is a file name, any
722
+ * other value a directory.
723
+ */
724
+ function outputPath(out, name, width, multi, variant = "") {
725
+ const suffix = `${variant}${multi ? `-w${width}` : ""}`;
983
726
  if (!out) return join(".mech", "shots", `${name}${suffix}.png`);
984
- if (out.endsWith(".png")) return multi ? out.replace(/\.png$/, `${suffix}.png`) : out;
727
+ if (out.endsWith(".png")) return suffix ? out.replace(/\.png$/, `${suffix}.png`) : out;
985
728
  return join(out, `${name}${suffix}.png`);
986
729
  }
730
+ /** The name suffix of one capture: `-<section slug>` and/or `-y<scroll>`. */
731
+ function shotVariant(sectionSlug, scroll) {
732
+ return `${sectionSlug ? `-${sectionSlug}` : ""}${scroll !== void 0 ? `-y${scroll}` : ""}`;
733
+ }
987
734
  /** `/` → `index`, `/docs/getting-started` → `docs-getting-started`. */
988
735
  function pageSlug(path) {
989
736
  const trimmed = path.replace(/^\/+|\/+$/g, "");
@@ -993,98 +740,250 @@ function pageSlug(path) {
993
740
  function normalizePagePath(path) {
994
741
  return "/" + path.trim().replace(/^\/+|\/+$/g, "");
995
742
  }
996
- var USAGE = "Usage: mechanica shot <blockId | /page/path> [--page </path>] [--data <json|@file>] [--width 1440,768] [--out <path>] [--server <url>] [--browser <path>] [--full]";
743
+ /** Lowercase letters and digits (any script) joined by single dashes. */
744
+ function slugify(value) {
745
+ return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
746
+ }
747
+ /**
748
+ * One file-name slug per `--section` selector: `#hero` → `hero`,
749
+ * `.stage > .pin` → `stage-pin`. A selector with no letters or digits becomes
750
+ * `section`; repeats get `-2`, `-3`…
751
+ */
752
+ function sectionSlugs(selectors) {
753
+ const seen = /* @__PURE__ */ new Map();
754
+ return selectors.map((selector) => {
755
+ const base = slugify(selector).slice(0, 40).replace(/-+$/, "") || "section";
756
+ const count = (seen.get(base) ?? 0) + 1;
757
+ seen.set(base, count);
758
+ return count === 1 ? base : `${base}-${count}`;
759
+ });
760
+ }
761
+ var WINDOWS_DRIVE_PATH = /^[a-zA-Z]:[\\/]/;
762
+ var RELATIVE_PATH = /^\.\.?[\\/]/;
763
+ var HTML_FILE = /\.html?$/i;
764
+ /**
765
+ * Classify a `shot` target, in this order:
766
+ * - `--page </path>` → that page;
767
+ * - `http://…` / `https://…` → a URL, opened as is;
768
+ * - `file://…`, a Windows drive path (`C:\…`), a `./` or `../` path, or any
769
+ * path ending in `.html`/`.htm` → a local file (relative to `cwd`);
770
+ * - anything else starting with `/` → a page of this project;
771
+ * - anything else → a block id.
772
+ */
773
+ function classifyTarget(target, page, cwd = process.cwd()) {
774
+ if (page) return {
775
+ kind: "page",
776
+ path: normalizePagePath(page)
777
+ };
778
+ const value = target?.trim();
779
+ if (!value) throw new Error(USAGE);
780
+ if (/^https?:\/\//i.test(value)) return {
781
+ kind: "url",
782
+ url: parseUrl(value)
783
+ };
784
+ if (/^file:\/\//i.test(value)) {
785
+ const url = parseUrl(value);
786
+ return {
787
+ kind: "file",
788
+ url,
789
+ file: filePathOf(url)
790
+ };
791
+ }
792
+ if (WINDOWS_DRIVE_PATH.test(value) || RELATIVE_PATH.test(value) || HTML_FILE.test(value)) {
793
+ const file = resolve(cwd, value);
794
+ return {
795
+ kind: "file",
796
+ url: pathToFileURL(file).href,
797
+ file
798
+ };
799
+ }
800
+ if (value.startsWith("/")) return {
801
+ kind: "page",
802
+ path: normalizePagePath(value)
803
+ };
804
+ return {
805
+ kind: "block",
806
+ blockId: value
807
+ };
808
+ }
809
+ function parseUrl(value) {
810
+ try {
811
+ return new URL(value).href;
812
+ } catch {
813
+ throw new Error(`Invalid URL "${value}"`);
814
+ }
815
+ }
816
+ function filePathOf(url) {
817
+ try {
818
+ return fileURLToPath(url);
819
+ } catch {
820
+ return;
821
+ }
822
+ }
823
+ /**
824
+ * Default output name of a URL or file shot:
825
+ * - a URL → its host (no `www.`, port appended) and path, extension dropped:
826
+ * `https://example.com/business/evolve` → `example-com-business-evolve`;
827
+ * - a file → its path relative to `cwd` (or its last three path segments when
828
+ * it lies outside), extension dropped: `design/evolve-level/index.html` →
829
+ * `design-evolve-level-index`.
830
+ */
831
+ function foreignShotName(target, cwd = process.cwd()) {
832
+ if (target.kind === "url") {
833
+ const url = new URL(target.url);
834
+ return slugify(`${url.hostname.replace(/^www\./, "") + (url.port ? `-${url.port}` : "")}/${stripExtension(url.pathname)}`) || "shot";
835
+ }
836
+ if (target.kind !== "file") throw new Error(`Not a URL or file target: ${target.kind}`);
837
+ const file = target.file ?? decodeURIComponent(new URL(target.url).pathname);
838
+ const rel = relative(cwd, file);
839
+ const inside = rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
840
+ const segments = (inside ? rel : file).split(/[\\/]+/).filter(Boolean);
841
+ return slugify(stripExtension((inside ? segments : segments.slice(-3)).join("/"))) || "shot";
842
+ }
843
+ function stripExtension(path) {
844
+ return path.replace(/\.[^./\\]+$/, "");
845
+ }
997
846
  /**
998
- * `mechanica shot <blockId>` / `mechanica shot /page/path` — render one block
999
- * through the dev preview route (or a whole page, editor overlay stripped) in
1000
- * a headless Chromium and write PNG screenshot(s). Made for agent loops:
1001
- * prints the image paths plus any console/render errors, and fails loudly when
1002
- * the block or page failed to render.
847
+ * How each capture is framed: `--section` boxes, else `--full` (whole
848
+ * document), else the viewport when scrolling (so sticky/pinned states are
849
+ * what's in the picture), else the block element for block shots and the
850
+ * whole document for pages, URLs and files.
851
+ */
852
+ function captureMode(kind, flags) {
853
+ if (flags.section) return "section";
854
+ if (flags.full) return "full";
855
+ if (flags.scroll) return "viewport";
856
+ return kind === "block" ? "block" : "full";
857
+ }
858
+ /** Resolve and validate a `shot` invocation without touching a browser or a server. */
859
+ function planShot(target, options = {}, cwd = process.cwd()) {
860
+ const shotTarget = classifyTarget(target, options.page, cwd);
861
+ const isBlock = shotTarget.kind === "block";
862
+ if (!isBlock && options.data) throw new Error("--data applies to block shots only");
863
+ const selectors = options.section ?? [];
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
+ if (selectors.some((selector) => !selector.trim())) throw new Error("--section needs a CSS selector, e.g. --section \"#hero\"");
866
+ const slugs = sectionSlugs(selectors);
867
+ const scrolls = parseScrolls(options.scroll);
868
+ return {
869
+ target: shotTarget,
870
+ name: shotTarget.kind === "block" ? shotTarget.blockId : shotTarget.kind === "page" ? `page-${pageSlug(shotTarget.path)}` : foreignShotName(shotTarget, cwd),
871
+ widths: parseWidths(options.width),
872
+ scrolls,
873
+ sections: selectors.map((selector, index) => ({
874
+ selector: selector.trim(),
875
+ slug: slugs[index]
876
+ })),
877
+ data: resolveDataArg(options.data),
878
+ mode: captureMode(shotTarget.kind, {
879
+ section: selectors.length > 0,
880
+ full: options.full,
881
+ scroll: !!scrolls
882
+ }),
883
+ freeze: !options.noFreeze
884
+ };
885
+ }
886
+ /**
887
+ * `mechanica shot <target>` — render one block through the dev preview route,
888
+ * a whole page (editor overlay stripped), or a foreign document (any URL, or
889
+ * a local `.html` file such as a design handoff) in a headless Chromium and
890
+ * write PNG screenshot(s): per width, per scroll offset, per `--section`.
891
+ * Made for agent loops: prints the image paths plus any console/render errors,
892
+ * and fails loudly when the block or page failed to render.
1003
893
  */
1004
894
  async function runShot(target, options = {}) {
1005
- const rawPage = options.page ?? (target?.startsWith("/") ? target : void 0);
1006
- const pagePath = rawPage ? normalizePagePath(rawPage) : void 0;
1007
- const blockId = pagePath ? void 0 : target;
1008
- if (!pagePath && !blockId) throw new Error(USAGE);
1009
- if (pagePath && options.data) throw new Error("--data applies to block shots only");
1010
- const widths = parseWidths(options.width);
1011
- const data = resolveDataArg(options.data);
1012
- const server = await resolveServer(options.server);
1013
- let browser;
895
+ const plan = planShot(target, options);
896
+ const shotTarget = plan.target;
897
+ if (shotTarget.kind === "file" && shotTarget.file) checkLocalFile(shotTarget.file, target);
898
+ const local = shotTarget.kind === "block" || shotTarget.kind === "page";
899
+ const server = local ? await resolveServer(options.server) : void 0;
900
+ let page;
1014
901
  try {
1015
- browser = await launchBrowser(options.browser);
1016
- const cdp = browser.client;
1017
- const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
1018
- const { sessionId } = await cdp.send("Target.attachToTarget", {
1019
- targetId,
1020
- flatten: true
1021
- });
1022
- const consoleLines = [];
1023
- cdp.onEvent((method, params) => {
1024
- if (method === "Runtime.consoleAPICalled" && (params.type === "error" || params.type === "warning")) {
1025
- const text = (params.args ?? []).map((arg) => arg.value ?? arg.description ?? "").join(" ");
1026
- consoleLines.push(`[console.${params.type}] ${text}`);
1027
- }
1028
- if (method === "Runtime.exceptionThrown") {
1029
- const details = params.exceptionDetails;
1030
- consoleLines.push(`[pageerror] ${details?.exception?.description ?? details?.text ?? "unknown error"}`);
1031
- }
902
+ page = await openPage(void 0, {
903
+ width: plan.widths[0],
904
+ browser: options.browser
1032
905
  });
1033
- await cdp.send("Runtime.enable", {}, sessionId);
1034
- await cdp.send("Page.enable", {}, sessionId);
1035
- await setViewport(cdp, sessionId, widths[0]);
1036
- await cdp.send("Emulation.setEmulatedMedia", { features: [{
1037
- name: "prefers-reduced-motion",
1038
- value: "reduce"
1039
- }] }, sessionId);
1040
- if (pagePath) await ensurePageExists(server.origin, pagePath);
1041
- const url = pagePath ? `${server.origin}${pagePath}?mechanica-shot=1` : previewUrl(server.origin, blockId, data);
1042
- const navigation = await cdp.send("Page.navigate", { url }, sessionId);
1043
- if (navigation.errorText) throw new Error(`Failed to open ${url}: ${navigation.errorText} (is this a Mechanica project?)`);
1044
- let previewError = null;
1045
- if (pagePath) {
1046
- await waitForCondition(cdp, sessionId, "document.readyState === 'complete'", 15e3);
1047
- await settlePageMedia(cdp, sessionId);
1048
- } else {
1049
- await waitForCondition(cdp, sessionId, "window.__MECHANICA_PREVIEW_READY__ === true", 15e3);
1050
- previewError = await evaluateValue(cdp, sessionId, "window.__MECHANICA_PREVIEW_ERROR__ ?? null");
906
+ if (shotTarget.kind === "page") await ensurePageExists(server.origin, shotTarget.path);
907
+ const url = shotUrl(shotTarget, server, plan.data);
908
+ try {
909
+ await page.goto(url, shotTarget.kind === "block" ? {
910
+ ready: PREVIEW_READY,
911
+ settle: false
912
+ } : {});
913
+ } catch (error) {
914
+ if (!(error instanceof NavigationError)) throw error;
915
+ throw new Error(local ? `${error.message} (is this a Mechanica project?)` : error.message);
1051
916
  }
1052
- await freezeAnimations(cdp, sessionId);
917
+ const previewError = shotTarget.kind === "block" ? await page.evaluate("window.__MECHANICA_PREVIEW_ERROR__ ?? null") : null;
918
+ if (plan.freeze && !plan.scrolls) await page.freezeAnimations();
1053
919
  const outputs = [];
1054
- for (const width of widths) {
1055
- await setViewport(cdp, sessionId, width);
1056
- await cdp.send("Runtime.evaluate", {
1057
- expression: "new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))",
1058
- awaitPromise: true
1059
- }, sessionId);
1060
- const name = pagePath ? `page-${pageSlug(pagePath)}` : blockId;
1061
- const out = outputPath(options.out, name, width, widths.length > 1);
1062
- await mkdir(dirname(out), { recursive: true });
1063
- const clip = (!pagePath && !options.full && !previewError ? await evaluateValue(cdp, sessionId, `(() => {
1064
- const el = document.querySelector('[data-block-id]')
1065
- if (!el) return null
1066
- const rect = el.getBoundingClientRect()
1067
- if (rect.width < 1 || rect.height < 1) return null
1068
- return { x: rect.x + scrollX, y: rect.y + scrollY, width: rect.width, height: rect.height }
1069
- })()`) : null) ?? await fullPageClip(cdp, sessionId);
1070
- const shot = await cdp.send("Page.captureScreenshot", {
1071
- format: "png",
1072
- captureBeyondViewport: true,
1073
- clip: {
1074
- ...clip,
1075
- scale: 1
920
+ const failures = [];
921
+ for (const width of plan.widths) {
922
+ await page.setViewport(width);
923
+ await page.nextFrame();
924
+ for (const scroll of plan.scrolls ?? [void 0]) {
925
+ if (scroll !== void 0) {
926
+ if (plan.freeze) await page.unfreezeAnimations();
927
+ const reached = await page.scrollTo(scroll);
928
+ if (reached + 1 < scroll) console.info(`(at ${width}px the page scrolls only to ${Math.round(reached)}px)`);
929
+ await page.settle();
930
+ if (plan.freeze) await page.freezeAnimations();
931
+ }
932
+ for (const section of plan.sections.length ? plan.sections : [void 0]) {
933
+ const out = outputPath(options.out, plan.name, width, plan.widths.length > 1, shotVariant(section?.slug, scroll));
934
+ let png;
935
+ try {
936
+ png = await capture(page, plan.mode, section?.selector, previewError);
937
+ } catch (error) {
938
+ if (!section) throw error;
939
+ const where = `at ${width}px` + (scroll !== void 0 ? `, scrolled to ${scroll}px` : "");
940
+ failures.push(`--section "${section.selector}" ${where}: ${error instanceof Error ? error.message : error}`);
941
+ continue;
942
+ }
943
+ await mkdir(dirname(out), { recursive: true });
944
+ fs.writeFileSync(out, png);
945
+ outputs.push(out);
1076
946
  }
1077
- }, sessionId);
1078
- fs.writeFileSync(out, Buffer.from(shot.data, "base64"));
1079
- outputs.push(out);
947
+ }
1080
948
  }
1081
- for (const line of consoleLines) console.info(line);
949
+ for (const line of page.messages) console.info(line);
1082
950
  for (const out of outputs) console.info(`✓ ${out}`);
1083
951
  if (previewError) throw new Error(`Block preview failed: ${previewError}`);
1084
- if (pagePath && consoleLines.some((line) => line.startsWith("[pageerror]"))) throw new Error("The page threw while rendering — see the [pageerror] output above");
952
+ if (shotTarget.kind === "page" && page.messages.some((line) => line.startsWith("[pageerror]"))) throw new Error("The page threw while rendering — see the [pageerror] output above");
953
+ if (failures.length === 1) throw new Error(failures[0]);
954
+ if (failures.length) {
955
+ for (const failure of failures) console.error(`✗ ${failure}`);
956
+ throw new Error(`${failures.length} section captures failed — see the ✗ lines above`);
957
+ }
1085
958
  } finally {
1086
- await browser?.close();
1087
- await server.close();
959
+ await page?.close();
960
+ await server?.close();
961
+ }
962
+ }
963
+ /** A local target must be an existing file — a directory would render as a file listing. */
964
+ function checkLocalFile(file, raw) {
965
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
966
+ if (stat?.isFile()) return;
967
+ const problem = stat ? `${file} is a directory, not an HTML file` : `File not found: ${file}`;
968
+ const msys = process.env.MSYSTEM && !HTML_FILE.test(raw) && WINDOWS_DRIVE_PATH.test(raw);
969
+ throw new Error(msys ? `${problem}. If you meant a page path, Git Bash rewrote it: write it with two slashes (//about) or set MSYS_NO_PATHCONV=1` : problem);
970
+ }
971
+ function shotUrl(target, server, data) {
972
+ switch (target.kind) {
973
+ case "block": return previewUrl(server.origin, target.blockId, data);
974
+ case "page": return `${server.origin}${target.path}?mechanica-shot=1`;
975
+ default: return target.url;
976
+ }
977
+ }
978
+ async function capture(page, mode, selector, previewError) {
979
+ switch (mode) {
980
+ case "section": return page.screenshot({ section: selector });
981
+ case "viewport": return page.screenshot();
982
+ case "full": return page.screenshot({ full: true });
983
+ case "block": {
984
+ const clip = previewError ? null : await page.sectionClip("[data-block-id]").catch(() => null);
985
+ return page.screenshot(clip ? { clip } : { full: true });
986
+ }
1088
987
  }
1089
988
  }
1090
989
  /** Fail early with the list of real pages instead of shooting an empty 200. */
@@ -1134,7 +1033,7 @@ function orphanThumbs(files, slugs) {
1134
1033
  async function runThumbs(prefix, options = {}) {
1135
1034
  const normalizedPrefix = prefix ? normalizePagePath(prefix) : void 0;
1136
1035
  const server = await resolveServer(options.server);
1137
- let browser;
1036
+ let tab;
1138
1037
  try {
1139
1038
  const listed = await fetch(`${server.origin}/@mechanica/pages`);
1140
1039
  if (!listed.ok) throw new Error(`Could not list pages (${listed.status}) — is this a Mechanica project?`);
@@ -1146,43 +1045,29 @@ async function runThumbs(prefix, options = {}) {
1146
1045
  }
1147
1046
  const outDir = options.out ?? join(".mech", "thumbs");
1148
1047
  await mkdir(outDir, { recursive: true });
1149
- browser = await launchBrowser(options.browser);
1150
- const cdp = browser.client;
1151
- const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
1152
- const { sessionId } = await cdp.send("Target.attachToTarget", {
1153
- targetId,
1154
- flatten: true
1048
+ tab = await openPage(void 0, {
1049
+ width: CAPTURE_WIDTH,
1050
+ height: VIEWPORT_HEIGHT,
1051
+ browser: options.browser
1155
1052
  });
1156
- await cdp.send("Page.enable", {}, sessionId);
1157
- await cdp.send("Runtime.enable", {}, sessionId);
1158
- await setViewport(cdp, sessionId, CAPTURE_WIDTH, VIEWPORT_HEIGHT);
1159
- await cdp.send("Emulation.setEmulatedMedia", { features: [{
1160
- name: "prefers-reduced-motion",
1161
- value: "reduce"
1162
- }] }, sessionId);
1163
1053
  const failures = [];
1164
1054
  for (const page of targets) try {
1165
- const navigation = await cdp.send("Page.navigate", { url: `${server.origin}${page.path}?mechanica-shot=1` }, sessionId);
1166
- if (navigation.errorText) throw new Error(navigation.errorText);
1167
- await waitForCondition(cdp, sessionId, "document.readyState === 'complete'", 15e3);
1168
- await settlePageMedia(cdp, sessionId);
1169
- await freezeAnimations(cdp, sessionId);
1170
- const metrics = await cdp.send("Page.getLayoutMetrics", {}, sessionId);
1055
+ await tab.goto(`${server.origin}${page.path}?mechanica-shot=1`);
1056
+ await tab.freezeAnimations();
1057
+ const metrics = await tab.send("Page.getLayoutMetrics");
1171
1058
  const contentSize = metrics.cssContentSize ?? metrics.contentSize;
1172
1059
  const height = Math.min(Math.ceil(contentSize.height), MAX_CAPTURE_HEIGHT);
1173
- const shot = await cdp.send("Page.captureScreenshot", {
1174
- format: "png",
1175
- captureBeyondViewport: true,
1060
+ const png = await tab.screenshot({
1176
1061
  clip: {
1177
1062
  x: 0,
1178
1063
  y: 0,
1179
1064
  width: CAPTURE_WIDTH,
1180
- height,
1181
- scale: THUMB_WIDTH / CAPTURE_WIDTH
1182
- }
1183
- }, sessionId);
1065
+ height
1066
+ },
1067
+ scale: THUMB_WIDTH / CAPTURE_WIDTH
1068
+ });
1184
1069
  const out = join(outDir, `${pageSlug(page.path)}.png`);
1185
- fs.writeFileSync(out, Buffer.from(shot.data, "base64"));
1070
+ fs.writeFileSync(out, png);
1186
1071
  console.info(`✓ ${page.path} → ${out}`);
1187
1072
  } catch (error) {
1188
1073
  failures.push(page.path);
@@ -1197,7 +1082,7 @@ async function runThumbs(prefix, options = {}) {
1197
1082
  }
1198
1083
  if (failures.length) throw new Error(`Failed to render ${failures.length} page(s): ${failures.join(", ")}`);
1199
1084
  } finally {
1200
- await browser?.close();
1085
+ await tab?.close();
1201
1086
  await server.close();
1202
1087
  }
1203
1088
  }
@@ -1215,7 +1100,7 @@ var BLOCK_THUMB_WIDTH = 240;
1215
1100
  */
1216
1101
  async function runBlockThumbs(filter, options = {}) {
1217
1102
  const server = await resolveServer(options.server);
1218
- let browser;
1103
+ let tab;
1219
1104
  try {
1220
1105
  const listed = await fetch(`${server.origin}/@mechanica/blocks`);
1221
1106
  if (!listed.ok) throw new Error(`Could not list blocks (${listed.status}) — is this a Mechanica project?`);
@@ -1227,44 +1112,34 @@ async function runBlockThumbs(filter, options = {}) {
1227
1112
  }
1228
1113
  const outDir = options.out ?? join(".mech", "thumbs", "blocks");
1229
1114
  await mkdir(outDir, { recursive: true });
1230
- browser = await launchBrowser(options.browser);
1231
- const cdp = browser.client;
1232
- const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
1233
- const { sessionId } = await cdp.send("Target.attachToTarget", {
1234
- targetId,
1235
- flatten: true
1115
+ tab = await openPage(void 0, {
1116
+ width: BLOCK_CAPTURE_WIDTH,
1117
+ height: BLOCK_MAX_HEIGHT,
1118
+ browser: options.browser
1236
1119
  });
1237
- await cdp.send("Page.enable", {}, sessionId);
1238
- await cdp.send("Runtime.enable", {}, sessionId);
1239
- await setViewport(cdp, sessionId, BLOCK_CAPTURE_WIDTH, BLOCK_MAX_HEIGHT);
1240
- await cdp.send("Emulation.setEmulatedMedia", { features: [{
1241
- name: "prefers-reduced-motion",
1242
- value: "reduce"
1243
- }] }, sessionId);
1244
1120
  const failures = [];
1245
1121
  for (const block of targets) try {
1246
- const navigation = await cdp.send("Page.navigate", { url: previewUrl(server.origin, block.id) }, sessionId);
1247
- if (navigation.errorText) throw new Error(navigation.errorText);
1248
- await waitForCondition(cdp, sessionId, "window.__MECHANICA_PREVIEW_READY__ === true", 15e3);
1249
- const previewError = await evaluateValue(cdp, sessionId, "window.__MECHANICA_PREVIEW_ERROR__ ?? null");
1122
+ await tab.goto(previewUrl(server.origin, block.id), {
1123
+ ready: PREVIEW_READY,
1124
+ settle: false
1125
+ });
1126
+ const previewError = await tab.evaluate("window.__MECHANICA_PREVIEW_ERROR__ ?? null");
1250
1127
  if (previewError) throw new Error(previewError);
1251
- await settlePageMedia(cdp, sessionId);
1252
- await freezeAnimations(cdp, sessionId);
1253
- const measured = await evaluateValue(cdp, sessionId, `document.querySelector('[data-block-id]')?.getBoundingClientRect().height ?? 0`);
1128
+ await tab.settle();
1129
+ await tab.freezeAnimations();
1130
+ const measured = await tab.evaluate(`document.querySelector('[data-block-id]')?.getBoundingClientRect().height ?? 0`);
1254
1131
  const height = Math.min(BLOCK_MAX_HEIGHT, Math.max(BLOCK_MIN_HEIGHT, Math.ceil(measured)));
1255
- const shot = await cdp.send("Page.captureScreenshot", {
1256
- format: "png",
1257
- captureBeyondViewport: true,
1132
+ const png = await tab.screenshot({
1258
1133
  clip: {
1259
1134
  x: 0,
1260
1135
  y: 0,
1261
1136
  width: BLOCK_CAPTURE_WIDTH,
1262
- height,
1263
- scale: BLOCK_THUMB_WIDTH / BLOCK_CAPTURE_WIDTH
1264
- }
1265
- }, sessionId);
1137
+ height
1138
+ },
1139
+ scale: BLOCK_THUMB_WIDTH / BLOCK_CAPTURE_WIDTH
1140
+ });
1266
1141
  const out = join(outDir, `${block.id}.png`);
1267
- fs.writeFileSync(out, Buffer.from(shot.data, "base64"));
1142
+ fs.writeFileSync(out, png);
1268
1143
  console.info(`✓ ${block.id} → ${out}`);
1269
1144
  } catch (error) {
1270
1145
  failures.push(block.id);
@@ -1279,7 +1154,7 @@ async function runBlockThumbs(filter, options = {}) {
1279
1154
  }
1280
1155
  if (failures.length) throw new Error(`Failed to render ${failures.length} block(s): ${failures.join(", ")}`);
1281
1156
  } finally {
1282
- await browser?.close();
1157
+ await tab?.close();
1283
1158
  await server.close();
1284
1159
  }
1285
1160
  }
@@ -1314,8 +1189,14 @@ Usage:
1314
1189
  the site, any other gets an address of its own
1315
1190
  mechanica shot <blockId> Screenshot one block via the dev preview route
1316
1191
  mechanica shot </path> Screenshot a whole page (editor overlay stripped)
1192
+ mechanica shot <url | file.html>
1193
+ Screenshot any URL or local HTML file (a design
1194
+ handoff) the same way, without a dev server
1317
1195
  (--page </path>, --data <json|@file>, --width 1440,768,
1318
- --out <path>, --server <url>, --browser <path>, --full)
1196
+ --section <css> (repeatable: one PNG per section),
1197
+ --scroll <px[,px…]> (viewport after scrolling),
1198
+ --no-freeze, --full, --out <path>, --server <url>,
1199
+ --browser <path>)
1319
1200
  mechanica thumbs [/path] Thumbnail every page (or those under /path) into
1320
1201
  .mech/thumbs/ for the editor's page browser
1321
1202
  (--out <dir>, --server <url>, --browser <path>)
@@ -1333,10 +1214,12 @@ Usage:
1333
1214
  mechanica migrate Bring .mech content up to the current format: uploads
1334
1215
  are referenced as /media/<file> (was /@mechanica/assets/)
1335
1216
  `;
1217
+ var PARSE_OPTIONS = { booleans: ["full", "no-freeze"] };
1336
1218
  /** CLI entry: dispatch a command to its handler. */
1337
1219
  async function run(argv) {
1338
- const { command, args, flags } = parseArgs(argv);
1220
+ const { command, args, flags } = parseArgs(argv, PARSE_OPTIONS);
1339
1221
  const str = (value) => typeof value === "string" ? value : void 0;
1222
+ const required = (value) => value === true ? "" : str(value);
1340
1223
  switch (command) {
1341
1224
  case "build": return runBuild({ editor: flags.editor === true });
1342
1225
  case "export": return runExport({
@@ -1370,7 +1253,10 @@ async function run(argv) {
1370
1253
  out: str(flags.out),
1371
1254
  server: str(flags.server),
1372
1255
  browser: str(flags.browser),
1373
- full: flags.full === true
1256
+ full: flags.full === true,
1257
+ section: flagValues(argv, "section", PARSE_OPTIONS).map((value) => required(value) ?? ""),
1258
+ scroll: required(flags.scroll),
1259
+ noFreeze: flags["no-freeze"] === true
1374
1260
  });
1375
1261
  case "thumbs": {
1376
1262
  const thumbsOptions = {