@marver-design/marver 0.17.0 → 0.18.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/CHANGELOG.md +68 -0
- package/README.md +1 -0
- package/dist/bake-jr38C_pX.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-ER_7T6Cw.mjs} +179 -10
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-BvLbY98O.mjs} +204 -10
- package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-Cai5C1WV.mjs} +107 -28
- package/dist/{poster-BEjUcQP3.mjs → poster-CIuz_PwH.mjs} +1 -1
- package/dist/publish-bakes-D0LhbQQ3.mjs +216 -0
- package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
- package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
- package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
- package/dist/{shot-DlmTO8AF.mjs → shot-iicees2e.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +7 -0
- package/src/client/shell/Comments.tsx +2 -2
- package/src/client/shell/canvas/Canvas.tsx +2 -2
- package/src/client/shell/canvas/FrameNode.tsx +82 -89
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/store.ts +14 -0
- package/src/client/shell/styles.css +9 -25
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/src/client/frame-host/serialize.ts +0 -195
- package/src/client/shell/canvas/snapshots.ts +0 -233
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { a as ROUTE, i as PKG, r as NAME } from "./cli.mjs";
|
|
3
3
|
import { a as listBoardFiles, c as BOARD_NAME, g as hash, h as validateWire, i as isRegularFile, l as FOLDERS_FILE, m as readTitle, n as checkBoardsDir, o as nodeExists, p as readDescription, s as readRegistry, t as boardFields } from "./boards-BmxcT3Lc.mjs";
|
|
4
4
|
import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
|
|
5
|
-
import { a as writeManifest, n as scanFrames, r as setSceneTitle, s as loadConfig, t as affectedFrameIds } from "./manifest-
|
|
5
|
+
import { a as writeManifest, n as scanFrames, r as setSceneTitle, s as loadConfig, t as affectedFrameIds } from "./manifest-DAnEL8_a.mjs";
|
|
6
6
|
import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
7
7
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -422,7 +422,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
422
422
|
};
|
|
423
423
|
const asPng = url.searchParams.get("format") === "png";
|
|
424
424
|
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
425
|
-
const { shootFrame } = await import("./shot-
|
|
425
|
+
const { shootFrame } = await import("./shot-iicees2e.mjs");
|
|
426
426
|
const r = await shootFrame({
|
|
427
427
|
root,
|
|
428
428
|
viewports: opts.viewports ?? {},
|
|
@@ -472,7 +472,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
472
472
|
if (typeof theme !== "string" || !/^[a-z0-9-]+$/i.test(theme)) return json(res, 400, { error: "invalid theme" });
|
|
473
473
|
const scale = body.scale === void 0 ? void 0 : body.scale;
|
|
474
474
|
if (scale !== void 0 && !(Number.isInteger(scale) && scale >= 1 && scale <= 4)) return json(res, 400, { error: "invalid scale" });
|
|
475
|
-
const { resolveFrames, shootBatch } = await import("./shot-
|
|
475
|
+
const { resolveFrames, shootBatch } = await import("./shot-iicees2e.mjs");
|
|
476
476
|
const sel = resolveFrames(root, body);
|
|
477
477
|
if (!sel.ok) return json(res, sel.status, { error: sel.error });
|
|
478
478
|
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
@@ -489,11 +489,63 @@ function apiMiddleware(root, opts = {}) {
|
|
|
489
489
|
return json(res, 503, { error: err.message });
|
|
490
490
|
}
|
|
491
491
|
}
|
|
492
|
+
if (path === "bakes" && req.method === "POST") {
|
|
493
|
+
if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
|
|
494
|
+
const raw = await readBody(req);
|
|
495
|
+
if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
|
|
496
|
+
let body;
|
|
497
|
+
try {
|
|
498
|
+
body = JSON.parse(raw);
|
|
499
|
+
} catch {
|
|
500
|
+
return json(res, 400, { error: "malformed JSON" });
|
|
501
|
+
}
|
|
502
|
+
if (!body || typeof body !== "object" || !Array.isArray(body.asks) || !body.asks.length || body.asks.length > 200) return json(res, 400, { error: "asks must list 1-200 frames" });
|
|
503
|
+
const { bakeBatch, ASK_MAX } = await import("./bake-jr38C_pX.mjs");
|
|
504
|
+
let manifest = {};
|
|
505
|
+
try {
|
|
506
|
+
manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
|
|
507
|
+
} catch {}
|
|
508
|
+
const frames = new Map((manifest.frames ?? []).map((f) => [f.id, f]));
|
|
509
|
+
const asks = [];
|
|
510
|
+
for (const a of body.asks) {
|
|
511
|
+
const frame = typeof a?.frame === "string" ? a.frame : "";
|
|
512
|
+
const theme = typeof a?.theme === "string" && /^[a-z0-9-]{1,32}$/i.test(a.theme) ? a.theme : "";
|
|
513
|
+
const w = Math.round(Number(a?.w)), h = Math.round(Number(a?.h));
|
|
514
|
+
if (!frames.has(frame) || !theme || !(w >= 120 && h >= 80 && w <= ASK_MAX.side && h <= ASK_MAX.side && w * h <= ASK_MAX.area)) return json(res, 400, { error: `invalid ask for "${frame}"` });
|
|
515
|
+
asks.push({
|
|
516
|
+
frame,
|
|
517
|
+
theme,
|
|
518
|
+
w,
|
|
519
|
+
h
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
523
|
+
const { ROUTE } = await import("./cli.mjs").then((n) => n.t);
|
|
524
|
+
const gen = opts.bakeGen?.() ?? 0;
|
|
525
|
+
return json(res, 200, {
|
|
526
|
+
gen,
|
|
527
|
+
answers: await bakeBatch({
|
|
528
|
+
root,
|
|
529
|
+
gen,
|
|
530
|
+
asks,
|
|
531
|
+
live: () => opts.bakeGen?.() ?? 0,
|
|
532
|
+
log: (a) => {
|
|
533
|
+
if (a.ok) console.log(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - ${a.targets.length} effects, ${a.rejected} stay live, ${a.ms} ms`);
|
|
534
|
+
else console.log(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - stays live: ${a.error}`);
|
|
535
|
+
},
|
|
536
|
+
urlFor: (ask) => {
|
|
537
|
+
const f = frames.get(ask.frame);
|
|
538
|
+
return f.kind === "html" ? `${origin}/${f.file}?theme=${encodeURIComponent(ask.theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(ask.frame)}&theme=${encodeURIComponent(ask.theme)}`;
|
|
539
|
+
},
|
|
540
|
+
urlBase: `${ROUTE}/bakes`
|
|
541
|
+
})
|
|
542
|
+
});
|
|
543
|
+
}
|
|
492
544
|
if (path === "poster" && req.method === "GET") {
|
|
493
545
|
if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
|
|
494
546
|
const src = url.searchParams.get("src") ?? "";
|
|
495
|
-
const { ensurePoster, isLocalClip } = await import("./poster-
|
|
496
|
-
const { isLocalAssetRef } = await import("./build-
|
|
547
|
+
const { ensurePoster, isLocalClip } = await import("./poster-CIuz_PwH.mjs");
|
|
548
|
+
const { isLocalAssetRef } = await import("./build-ER_7T6Cw.mjs");
|
|
497
549
|
if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
|
|
498
550
|
const r = await ensurePoster(join(root, "design", "assets"), src);
|
|
499
551
|
if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
|
|
@@ -681,6 +733,24 @@ function routesMiddleware(server, clientDir) {
|
|
|
681
733
|
} catch {
|
|
682
734
|
return next();
|
|
683
735
|
}
|
|
736
|
+
} else if (path.startsWith(`/__mv/bakes/`)) {
|
|
737
|
+
const m = /^\/(\d+)\/([a-f0-9]{16})\/(\d+)\.png$/.exec(path.slice(`${ROUTE}/bakes`.length));
|
|
738
|
+
if (!m) {
|
|
739
|
+
res.statusCode = 404;
|
|
740
|
+
return res.end();
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
const base = realpathSync(join(server.config.root, "design", ".local", "bakes"));
|
|
744
|
+
const file = realpathSync(join(base, m[1], m[2], `${m[3]}.png`));
|
|
745
|
+
if (!file.startsWith(base + sep)) throw new Error("outside the cache");
|
|
746
|
+
const png = readFileSync(file);
|
|
747
|
+
res.setHeader("content-type", "image/png");
|
|
748
|
+
res.setHeader("cache-control", "public, max-age=31536000, immutable");
|
|
749
|
+
return res.end(png);
|
|
750
|
+
} catch {
|
|
751
|
+
res.statusCode = 404;
|
|
752
|
+
return res.end();
|
|
753
|
+
}
|
|
684
754
|
} else if (path === `/__mv/bridge.js`) {
|
|
685
755
|
res.setHeader("content-type", "text/javascript");
|
|
686
756
|
return res.end(readFileSync(join(clientDir, "frame-host", "bridge.js"), "utf8"));
|
|
@@ -846,27 +916,19 @@ function marverPlugin(ctx) {
|
|
|
846
916
|
const bridge = "/@fs/" + join(clientDir, "frame-host", "bridge.js").split("\\").join("/") + "?html=1";
|
|
847
917
|
return {
|
|
848
918
|
html,
|
|
849
|
-
tags: [
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
919
|
+
tags: [{
|
|
920
|
+
tag: "script",
|
|
921
|
+
attrs: { type: "module" },
|
|
922
|
+
children: `import '${VIRTUAL_THEME}'`,
|
|
923
|
+
injectTo: "head-prepend"
|
|
924
|
+
}, {
|
|
925
|
+
tag: "script",
|
|
926
|
+
attrs: {
|
|
927
|
+
type: "module",
|
|
928
|
+
src: bridge
|
|
854
929
|
},
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
attrs: { type: "module" },
|
|
858
|
-
children: `import '${VIRTUAL_THEME}'`,
|
|
859
|
-
injectTo: "head-prepend"
|
|
860
|
-
},
|
|
861
|
-
{
|
|
862
|
-
tag: "script",
|
|
863
|
-
attrs: {
|
|
864
|
-
type: "module",
|
|
865
|
-
src: bridge
|
|
866
|
-
},
|
|
867
|
-
injectTo: "head-prepend"
|
|
868
|
-
}
|
|
869
|
-
]
|
|
930
|
+
injectTo: "head-prepend"
|
|
931
|
+
}]
|
|
870
932
|
};
|
|
871
933
|
}
|
|
872
934
|
},
|
|
@@ -888,6 +950,7 @@ function marverPlugin(ctx) {
|
|
|
888
950
|
return [];
|
|
889
951
|
},
|
|
890
952
|
configureServer(server) {
|
|
953
|
+
let bakeGen = Date.now();
|
|
891
954
|
server.middlewares.use((req, res, next) => {
|
|
892
955
|
if (req.url && req.url.includes("/frame-host/registry.ts")) {
|
|
893
956
|
const orig = res.setHeader.bind(res);
|
|
@@ -917,7 +980,8 @@ function marverPlugin(ctx) {
|
|
|
917
980
|
};
|
|
918
981
|
server.middlewares.use(apiMiddleware(root, {
|
|
919
982
|
viewports: config.viewports,
|
|
920
|
-
origin: listeningOrigin
|
|
983
|
+
origin: listeningOrigin,
|
|
984
|
+
bakeGen: () => bakeGen
|
|
921
985
|
}));
|
|
922
986
|
server.middlewares.use(routesMiddleware(server, clientDir));
|
|
923
987
|
devServer = server;
|
|
@@ -955,6 +1019,21 @@ function marverPlugin(ctx) {
|
|
|
955
1019
|
for (const mod of themeModules()) server.moduleGraph.invalidateModule(mod);
|
|
956
1020
|
pushTheme();
|
|
957
1021
|
};
|
|
1022
|
+
const localDir = join(root, "design", ".local"), boardsDirGen = join(root, "design", "boards"), manifestPath = join(root, "design", "manifest.json");
|
|
1023
|
+
const bumpGen = (f) => {
|
|
1024
|
+
if (f.startsWith(localDir) || f.startsWith(boardsDirGen) || f === manifestPath || f.includes("/node_modules/")) return;
|
|
1025
|
+
bakeGen++;
|
|
1026
|
+
prune();
|
|
1027
|
+
};
|
|
1028
|
+
const prune = () => setTimeout(async () => {
|
|
1029
|
+
try {
|
|
1030
|
+
(await import("./bake-jr38C_pX.mjs")).pruneBakes(root, bakeGen);
|
|
1031
|
+
} catch {}
|
|
1032
|
+
}, 500);
|
|
1033
|
+
prune();
|
|
1034
|
+
server.watcher.on("add", bumpGen);
|
|
1035
|
+
server.watcher.on("unlink", bumpGen);
|
|
1036
|
+
server.watcher.on("change", bumpGen);
|
|
958
1037
|
server.watcher.on("add", (f) => {
|
|
959
1038
|
if (inScope(f)) {
|
|
960
1039
|
regen();
|
|
@@ -1087,7 +1166,7 @@ function marverPlugin(ctx) {
|
|
|
1087
1166
|
});
|
|
1088
1167
|
return;
|
|
1089
1168
|
}
|
|
1090
|
-
const { shootFrame, shootBatch, resolveFrames } = await import("./shot-
|
|
1169
|
+
const { shootFrame, shootBatch, resolveFrames } = await import("./shot-iicees2e.mjs");
|
|
1091
1170
|
if (ways[0] === "frame") {
|
|
1092
1171
|
if (typeof spec.frame !== "string") {
|
|
1093
1172
|
write({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capture, t as findChrome } from "./shot-
|
|
1
|
+
import { capture, t as findChrome } from "./shot-iicees2e.mjs";
|
|
2
2
|
import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, sep } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { planShot, t as findChrome } from "./shot-iicees2e.mjs";
|
|
2
|
+
import { ASK_MAX, bakeBatch } from "./bake-jr38C_pX.mjs";
|
|
3
|
+
import { MIME } from "./serve-z5qtj_wJ.mjs";
|
|
4
|
+
import { copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
//#region src/server/publish-bakes.ts
|
|
8
|
+
/**
|
|
9
|
+
* Textures at publish time (spec 16, published canvases).
|
|
10
|
+
*
|
|
11
|
+
* A published canvas is a static site with no compiler, so its glass stayed live and a shared hi-fi
|
|
12
|
+
* board flashed on pan and zoom as before 0.18.0. Everything the compiler needs is known when the
|
|
13
|
+
* site is built: the published boards, every node's frame and size on them, and the themes a
|
|
14
|
+
* visitor can flip to. So the build serves design/.dist to itself on a loopback port, compiles every
|
|
15
|
+
* (frame, theme, size) against the PUBLISHED document - the one visitors get, not the dev one - with
|
|
16
|
+
* the same certification as the dev server (bake.ts), ships the certified textures under
|
|
17
|
+
* design/.dist/__mv/bakes/<build>/ and writes one static index the shell reads instead of asking a
|
|
18
|
+
* server. Anything the compiler refuses is simply absent from the index: that frame sleeps with the
|
|
19
|
+
* pause alone, glass live, as today. No Chrome on the build machine: the note is printed and the
|
|
20
|
+
* site ships without textures.
|
|
21
|
+
*/
|
|
22
|
+
/** The shell's plain key for an ask (sleep.ts keyOf): frame|theme|w|h. */
|
|
23
|
+
const indexKey = (a) => `${a.frame}|${a.theme}|${Math.round(a.w)}|${Math.round(a.h)}`;
|
|
24
|
+
/** Every (frame, theme, size) a visitor can rest on: each published board's nodes, sized the way
|
|
25
|
+
* the shell sizes them (the node's own size, else the frame's default), each theme; the published
|
|
26
|
+
* `all-scenes` board shows every frame at its default size. Sizes are rounded first, then held to
|
|
27
|
+
* the compiler's limits: an oversize node is skipped, never clamped to a document of another size. */
|
|
28
|
+
function publishedAsks(boards, themes, frames, viewports, allScenes = false) {
|
|
29
|
+
const byId = new Map(frames.map((f) => [f.id, f]));
|
|
30
|
+
const seen = /* @__PURE__ */ new Map();
|
|
31
|
+
const add = (frame, w, h) => {
|
|
32
|
+
w = Math.round(w);
|
|
33
|
+
h = Math.round(h);
|
|
34
|
+
if (!(w >= 120) || !(h >= 80) || w > ASK_MAX.side || h > ASK_MAX.side || w * h > ASK_MAX.area) return;
|
|
35
|
+
for (const theme of themes.length ? themes : ["light"]) {
|
|
36
|
+
const ask = {
|
|
37
|
+
frame,
|
|
38
|
+
theme,
|
|
39
|
+
w,
|
|
40
|
+
h
|
|
41
|
+
};
|
|
42
|
+
seen.set(indexKey(ask), ask);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const size = (f, n) => {
|
|
46
|
+
const nw = n && typeof n.w === "number" && n.w > 0 ? n.w : void 0, nh = n && typeof n.h === "number" && n.h > 0 ? n.h : void 0;
|
|
47
|
+
const p = planShot(f, viewports, {});
|
|
48
|
+
if (p.fullHeight && !nh) return null;
|
|
49
|
+
return {
|
|
50
|
+
w: nw ?? p.width,
|
|
51
|
+
h: nh ?? p.initialHeight
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
for (const b of Object.values(boards)) for (const n of b?.nodes ?? []) {
|
|
55
|
+
const f = typeof n?.frame === "string" ? byId.get(n.frame) : void 0;
|
|
56
|
+
const s = f && size(f, n);
|
|
57
|
+
if (s) add(f.id, s.w, s.h);
|
|
58
|
+
}
|
|
59
|
+
if (allScenes) for (const f of frames) {
|
|
60
|
+
const s = size(f);
|
|
61
|
+
if (s) add(f.id, s.w, s.h);
|
|
62
|
+
}
|
|
63
|
+
return [...seen.values()];
|
|
64
|
+
}
|
|
65
|
+
/** The index: only answers that certified at least one texture, and only their certified targets
|
|
66
|
+
* (a refused target's selector ships nothing); everything else is absent. */
|
|
67
|
+
function publishedIndex(gen, answers) {
|
|
68
|
+
const out = {
|
|
69
|
+
gen,
|
|
70
|
+
answers: {}
|
|
71
|
+
};
|
|
72
|
+
for (const a of answers) {
|
|
73
|
+
if (!a.ok) continue;
|
|
74
|
+
const targets = a.targets.filter((t) => t.verified && t.texture);
|
|
75
|
+
if (targets.length) out.answers[indexKey(a)] = {
|
|
76
|
+
ok: true,
|
|
77
|
+
targets
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/** Serve `dir` on a loopback port the way `marver serve` does (extensionless = index.html). */
|
|
83
|
+
function serveDir(dir) {
|
|
84
|
+
const real = realpathSync(dir);
|
|
85
|
+
const server = createServer((req, res) => {
|
|
86
|
+
let path = "";
|
|
87
|
+
try {
|
|
88
|
+
path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
|
89
|
+
} catch {
|
|
90
|
+
res.statusCode = 400;
|
|
91
|
+
return res.end();
|
|
92
|
+
}
|
|
93
|
+
if (path.endsWith("/")) path += "index.html";
|
|
94
|
+
let file = resolve(dir, path.slice(1));
|
|
95
|
+
try {
|
|
96
|
+
const r = realpathSync(file);
|
|
97
|
+
if (relative(real, r).startsWith("..") || isAbsolute(relative(real, r))) throw 0;
|
|
98
|
+
file = r;
|
|
99
|
+
} catch {
|
|
100
|
+
file = join(dir, "index.html");
|
|
101
|
+
}
|
|
102
|
+
if (!extname(file)) file = join(dir, "index.html");
|
|
103
|
+
try {
|
|
104
|
+
const c = readFileSync(file);
|
|
105
|
+
res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
|
|
106
|
+
res.end(c);
|
|
107
|
+
} catch {
|
|
108
|
+
res.statusCode = 404;
|
|
109
|
+
res.end();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return new Promise((ok) => server.listen(0, "127.0.0.1", () => {
|
|
113
|
+
ok({
|
|
114
|
+
origin: `http://127.0.0.1:${server.address().port}`,
|
|
115
|
+
close: () => server.close()
|
|
116
|
+
});
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
/** Compile the published boards' frames against the built site and ship the textures with it.
|
|
120
|
+
* Returns null when there is no Chrome to compile with. */
|
|
121
|
+
async function bakePublished(opts) {
|
|
122
|
+
const { root, outDir, gen, boards, themes, frames, viewports, allScenes, urlFor, log } = opts;
|
|
123
|
+
if (!findChrome()) return null;
|
|
124
|
+
const t0 = Date.now();
|
|
125
|
+
const asks = publishedAsks(boards, themes, frames, viewports, allScenes).filter((a) => urlFor(a.frame, a.theme));
|
|
126
|
+
const stats = {
|
|
127
|
+
asked: asks.length,
|
|
128
|
+
asleep: 0,
|
|
129
|
+
live: 0,
|
|
130
|
+
plain: 0,
|
|
131
|
+
bytes: 0,
|
|
132
|
+
ms: 0
|
|
133
|
+
};
|
|
134
|
+
if (!asks.length) return stats;
|
|
135
|
+
const cache = join(root, "design", ".local", "bakes", String(gen));
|
|
136
|
+
const site = await serveDir(outDir);
|
|
137
|
+
let answers;
|
|
138
|
+
try {
|
|
139
|
+
answers = await bakeBatch({
|
|
140
|
+
root,
|
|
141
|
+
gen,
|
|
142
|
+
asks,
|
|
143
|
+
urlBase: "/__mv/bakes",
|
|
144
|
+
urlFor: (a) => site.origin + urlFor(a.frame, a.theme)
|
|
145
|
+
});
|
|
146
|
+
const index = publishedIndex(gen, answers);
|
|
147
|
+
const to = join(outDir, "__mv", "bakes");
|
|
148
|
+
rmSync(to, {
|
|
149
|
+
recursive: true,
|
|
150
|
+
force: true
|
|
151
|
+
});
|
|
152
|
+
const grammar = new RegExp(`^/__mv/bakes/${gen}/[0-9a-f]{16}/\\d+\\.png$`);
|
|
153
|
+
const realCache = (() => {
|
|
154
|
+
try {
|
|
155
|
+
return realpathSync(cache);
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
for (const [key, a] of Object.entries(index.answers)) {
|
|
161
|
+
const files = [];
|
|
162
|
+
if (!(realCache !== null && a.targets.every((t) => {
|
|
163
|
+
if (!grammar.test(t.texture)) return false;
|
|
164
|
+
const from = join(root, "design", ".local", "bakes", t.texture.replace(/^\/__mv\/bakes\//, ""));
|
|
165
|
+
try {
|
|
166
|
+
if (!lstatSync(from).isFile()) return false;
|
|
167
|
+
if (!realpathSync(from).startsWith(realCache + "/")) return false;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
files.push([from, join(outDir, t.texture.slice(1))]);
|
|
172
|
+
return true;
|
|
173
|
+
}))) {
|
|
174
|
+
delete index.answers[key];
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
for (const [from, dest] of files) {
|
|
178
|
+
mkdirSync(join(dest, ".."), { recursive: true });
|
|
179
|
+
copyFileSync(from, dest);
|
|
180
|
+
stats.bytes += statSync(dest).size;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
mkdirSync(join(to, String(gen)), { recursive: true });
|
|
184
|
+
writeFileSync(join(to, String(gen), "index.json"), JSON.stringify(index));
|
|
185
|
+
for (const a of answers) {
|
|
186
|
+
if (!a.ok) {
|
|
187
|
+
stats.live++;
|
|
188
|
+
log?.(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - stays live: ${a.error}`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!(a.effects ?? a.targets.length)) {
|
|
192
|
+
stats.plain++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!a.targets.length) {
|
|
196
|
+
stats.live++;
|
|
197
|
+
log?.(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - ${a.effects} effects, all nested, stay live`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const shipped = index.answers[indexKey(a)]?.targets.length ?? 0;
|
|
201
|
+
if (shipped) stats.asleep++;
|
|
202
|
+
else stats.live++;
|
|
203
|
+
log?.(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - ${a.targets.length} effects, ${a.targets.length - shipped} stay live, ${a.ms} ms`);
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
site.close();
|
|
207
|
+
rmSync(cache, {
|
|
208
|
+
recursive: true,
|
|
209
|
+
force: true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
stats.ms = Date.now() - t0;
|
|
213
|
+
return stats;
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
export { bakePublished };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { r as NAME } from "./cli.mjs";
|
|
2
2
|
import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
|
|
3
3
|
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
4
4
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -147,7 +147,7 @@ async function serve(root, portFlag) {
|
|
|
147
147
|
console.error(`[${NAME}] MARVER_ID_ISSUER is set but MARVER_PUBLIC_ORIGIN is not.\n Every assertion is bound to this canvas's exact origin, and it cannot be\n inferred from request headers - a proxy can make any request look local.\n Set it to the origin people actually reach this canvas on:\n MARVER_PUBLIC_ORIGIN=https://canvas.example.com\n MARVER_PUBLIC_ORIGIN=http://localhost:${portFlag ?? (Number(process.env.PORT) || 4199)} (development)`);
|
|
148
148
|
process.exit(1);
|
|
149
149
|
}
|
|
150
|
-
const { marverIdHandler } = await import("./marver-id-gate-
|
|
150
|
+
const { marverIdHandler } = await import("./marver-id-gate-B_idGdHm.mjs");
|
|
151
151
|
const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
|
|
152
152
|
const canvasName = humanName(meta.name);
|
|
153
153
|
const { ceilingsFromRights } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);
|
|
@@ -659,4 +659,4 @@ ${collabOn ? `
|
|
|
659
659
|
}
|
|
660
660
|
const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
661
661
|
//#endregion
|
|
662
|
-
export { serve, poweredByUrl as t };
|
|
662
|
+
export { MIME, serve, poweredByUrl as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as ROUTE, o as slideSize } from "./cli.mjs";
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
@@ -36,12 +36,19 @@ function findChrome() {
|
|
|
36
36
|
}
|
|
37
37
|
/** Profile directories marver has ever created (the sweep in shot.ts looks for both). */
|
|
38
38
|
const PROFILE_PREFIXES = ["mv-shot-", "mv-browser-"];
|
|
39
|
+
/** What a Linux container needs and a desktop must not get: Chrome refuses to run as root with its
|
|
40
|
+
* sandbox on (a build image runs as root), and a container's /dev/shm is too small for its tiles. */
|
|
41
|
+
function containerFlags(platform, uid) {
|
|
42
|
+
return platform === "linux" && uid === 0 ? ["--no-sandbox", "--disable-dev-shm-usage"] : [];
|
|
43
|
+
}
|
|
39
44
|
const FLAGS = [
|
|
40
|
-
"--headless=new",
|
|
45
|
+
...process.env.MV_CHROME_HEADED ? [] : ["--headless=new"],
|
|
41
46
|
"--hide-scrollbars",
|
|
42
47
|
"--no-first-run",
|
|
43
48
|
"--no-default-browser-check",
|
|
44
|
-
"--disable-extensions"
|
|
49
|
+
"--disable-extensions",
|
|
50
|
+
...containerFlags(process.platform, process.getuid?.()),
|
|
51
|
+
...process.env.MV_CHROME_FLAGS?.split(" ").filter(Boolean) ?? []
|
|
45
52
|
];
|
|
46
53
|
const HANDSHAKE_MS = 15e3;
|
|
47
54
|
const MAX_MESSAGE = 1 << 30;
|
|
@@ -50,9 +57,9 @@ const MAX_MESSAGE = 1 << 30;
|
|
|
50
57
|
* and concatenated ONCE per delimiter (a screenshot answer is many chunks; re-concatenating
|
|
51
58
|
* the remainder per chunk would be quadratic). The size guard runs BEFORE a segment is held. */
|
|
52
59
|
var Frames = class {
|
|
53
|
-
max;
|
|
54
60
|
chunks = [];
|
|
55
61
|
held = 0;
|
|
62
|
+
max;
|
|
56
63
|
constructor(max = MAX_MESSAGE) {
|
|
57
64
|
this.max = max;
|
|
58
65
|
}
|
|
@@ -330,8 +337,8 @@ async function shootFrame(opts) {
|
|
|
330
337
|
};
|
|
331
338
|
const plan = planShot(frame, viewports, size);
|
|
332
339
|
if (frame.kind !== "html") try {
|
|
333
|
-
const { scanAssetRefs } = await import("./build-
|
|
334
|
-
const { ensurePoster } = await import("./poster-
|
|
340
|
+
const { scanAssetRefs } = await import("./build-ER_7T6Cw.mjs");
|
|
341
|
+
const { ensurePoster } = await import("./poster-CIuz_PwH.mjs");
|
|
335
342
|
const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
|
|
336
343
|
for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
|
|
337
344
|
} catch {}
|
|
@@ -923,4 +930,4 @@ async function captureIn({ url, width, height, out, fullHeight = false, timeoutM
|
|
|
923
930
|
}
|
|
924
931
|
}
|
|
925
932
|
//#endregion
|
|
926
|
-
export { capture, resolveFrames, shootBatch, shootFrame, sweepGhosts, findChrome as t };
|
|
933
|
+
export { AREA, SURFACE, capture, planShot, pool, resolveFrames, shootBatch, shootFrame, shotConcurrency, sweepGhosts, findChrome as t, withBrowser };
|
package/docs/publish.md
CHANGED
|
@@ -36,6 +36,20 @@ only around published boards (a folder with nothing published never reaches the
|
|
|
36
36
|
and `title`s and `description`s ship only for published things - the project's, the
|
|
37
37
|
published boards', their folders', scenes' and frames'.
|
|
38
38
|
|
|
39
|
+
**Glass at rest.** `marver build` compiles the textures a hi-fi frame rests under (the same
|
|
40
|
+
certified compile as the dev canvas, spec 16) against the site it just built - every published
|
|
41
|
+
node, at its size on its board, in every theme - and ships them with it under `__mv/bakes/`. A
|
|
42
|
+
visitor's browser reads one static index; the shell is the same. Whatever the compiler cannot
|
|
43
|
+
certify (glass inside glass, blend modes, a frame whose paint is not a function of its URL) rests
|
|
44
|
+
with its glass live, as before. The compile needs Chrome on the machine that builds: without one
|
|
45
|
+
the build says so and ships without textures; `--no-textures` (or `MARVER_NO_TEXTURES=1` in CI)
|
|
46
|
+
skips it on purpose. A visitor who resizes a frame to a device preset sees it live at that size
|
|
47
|
+
(no texture was compiled for it). The textures are certified as the build machine renders the
|
|
48
|
+
frame, so bundle the fonts your frames use (`@fontsource-*`, or files under `public/`): a font
|
|
49
|
+
that exists only on a designer's laptop renders differently in a build container, and the
|
|
50
|
+
visitor's browser then refuses those textures and rests the frame live. A republish mints new
|
|
51
|
+
textures; a tab already open keeps the old shell and rests its glass live until it reloads.
|
|
52
|
+
|
|
39
53
|
## Who can open your canvas
|
|
40
54
|
|
|
41
55
|
Three choices, and the canvas is public until you make one.
|
|
@@ -246,10 +260,13 @@ containerised canvas reports as one campaign.
|
|
|
246
260
|
|
|
247
261
|
## Railway (the one-pager)
|
|
248
262
|
|
|
249
|
-
1. Push your repo to GitHub and create a Railway
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
263
|
+
1. Push your repo to GitHub with the Dockerfile below at its root, and create a Railway
|
|
264
|
+
service from it (Railway detects the Dockerfile; a Nixpacks build has no browser and ships
|
|
265
|
+
the hi-fi frames with live glass).
|
|
266
|
+
2. Start command: `npx marver serve` (Railway's `$PORT` is picked up automatically)
|
|
267
|
+
3. Variables: `MARVER_PASSWORD=<your password>`, or the identity gate from the table above.
|
|
268
|
+
4. Read the build log: `textures: N frame views asleep under certified glass ...` is the line
|
|
269
|
+
that says the glass compiled; `textures: none - no Chrome` means the image has no browser.
|
|
253
270
|
|
|
254
271
|
Deploy. The repo itself is the deployable - nothing to export, nothing to sync.
|
|
255
272
|
|
|
@@ -257,14 +274,19 @@ Deploy. The repo itself is the deployable - nothing to export, nothing to sync.
|
|
|
257
274
|
|
|
258
275
|
```dockerfile
|
|
259
276
|
FROM node:22-slim
|
|
277
|
+
RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
|
|
278
|
+
&& rm -rf /var/lib/apt/lists/* # the browser `marver build` compiles the glass textures with
|
|
260
279
|
WORKDIR /app # set share.name in design/config.ts - the fallback name is this directory
|
|
261
280
|
COPY . .
|
|
262
|
-
RUN npm ci && npx marver build
|
|
281
|
+
RUN npm ci && npx marver build # as root in a container: marver adds Chrome's --no-sandbox itself
|
|
263
282
|
ENV PORT=8080
|
|
264
283
|
EXPOSE 8080
|
|
265
284
|
CMD ["npx", "marver", "serve"]
|
|
266
285
|
```
|
|
267
286
|
|
|
287
|
+
Without a browser in the image the build still succeeds, says so, and ships every feature but the
|
|
288
|
+
textures; `npx marver build --no-textures` skips the compile on purpose.
|
|
289
|
+
|
|
268
290
|
## Cloudflare Pages + Access (email/domain allowlists)
|
|
269
291
|
|
|
270
292
|
For teams that want per-email policies instead of one password: build in CI
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -12,20 +12,15 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
|
|
|
12
12
|
const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
|
|
13
13
|
const id = new URLSearchParams(location.search).get('id') ?? location.pathname
|
|
14
14
|
|
|
15
|
-
// The shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
|
|
16
|
-
// are walkable, but a CLOSED root is invisible after the fact - flag it at creation so the serialiser
|
|
17
|
-
// degrades the frame (keeps it live) instead of shipping a lean copy missing its shadow content.
|
|
18
|
-
const _attachShadow = Element.prototype.attachShadow
|
|
19
|
-
if (_attachShadow) Element.prototype.attachShadow = function (init) {
|
|
20
|
-
if (init && init.mode === 'closed') window.__mvClosedShadow = true
|
|
21
|
-
return _attachShadow.call(this, init)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
15
|
// theme lands as BOTH signals: [data-theme] plus the `dark` class Tailwind/shadcn key on
|
|
25
16
|
const setTheme = (theme) => {
|
|
26
17
|
document.documentElement.dataset.theme = theme
|
|
27
18
|
document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
19
|
+
// the shell sleeps a frame only under the theme it has actually painted: report it, two frames later
|
|
20
|
+
requestAnimationFrame(() => requestAnimationFrame(() => post({ type: 'sh:theme-applied', id, theme })))
|
|
28
21
|
}
|
|
22
|
+
// the theme this document booted with (the URL's) counts as applied
|
|
23
|
+
requestAnimationFrame(() => requestAnimationFrame(() => post({ type: 'sh:theme-applied', id, theme: new URLSearchParams(location.search).get('theme') ?? 'light' })))
|
|
29
24
|
|
|
30
25
|
if (isHtmlFrame) {
|
|
31
26
|
const theme = new URLSearchParams(location.search).get('theme')
|