mechanica 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/browser.js +2 -0
- package/dist/chunks/browser-BCfAv8bW.js +558 -0
- package/dist/cli.js +346 -460
- package/dist/types/cli/args.d.ts +12 -1
- package/dist/types/cli/browser.d.ts +116 -0
- package/dist/types/cli/dev-server.d.ts +13 -0
- package/dist/types/cli/headless.d.ts +25 -16
- package/dist/types/cli/shot.d.ts +105 -8
- package/package.json +6 -2
package/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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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/
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
/**
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
|
|
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
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
await
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
}
|
|
1078
|
-
fs.writeFileSync(out, Buffer.from(shot.data, "base64"));
|
|
1079
|
-
outputs.push(out);
|
|
947
|
+
}
|
|
1080
948
|
}
|
|
1081
|
-
for (const line of
|
|
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 (
|
|
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
|
|
1087
|
-
await server
|
|
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
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
await
|
|
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
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
}
|
|
1065
|
+
height
|
|
1066
|
+
},
|
|
1067
|
+
scale: THUMB_WIDTH / CAPTURE_WIDTH
|
|
1068
|
+
});
|
|
1184
1069
|
const out = join(outDir, `${pageSlug(page.path)}.png`);
|
|
1185
|
-
fs.writeFileSync(out,
|
|
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
|
|
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
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
|
1252
|
-
await freezeAnimations(
|
|
1253
|
-
const measured = await
|
|
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
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
}
|
|
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,
|
|
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
|
|
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
|
-
--
|
|
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 = {
|