@marver-design/marver 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -3
- package/README.md +72 -16
- package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
- package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
- package/dist/cli.mjs +16 -7
- package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
- package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
- package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
- package/dist/daemon-DkyNOwIt.mjs +878 -0
- package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
- package/dist/events-BMtBvvgU.mjs +101 -0
- package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
- package/dist/ledger-CbzTJrV2.mjs +64 -0
- package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
- package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
- package/dist/profile-BkiWglVE.mjs +39 -0
- package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
- package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
- package/dist/work-CLrmY-vQ.mjs +97 -0
- package/dist/work-lzC-lPY0.mjs +76 -0
- package/package.json +16 -1
- package/src/client/const.ts +1 -1
- package/src/client/content/diagram.tsx +1 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +1 -1
- package/src/client/content/palette.ts +2 -2
- package/src/client/frame-host/bridge.js +1 -1
- package/src/client/frame-host/inspect.js +16 -3
- package/src/client/frame-host/serialize.ts +2 -2
- package/src/client/shell/App.tsx +165 -26
- package/src/client/shell/Comments.tsx +434 -78
- package/src/client/shell/Play.tsx +12 -8
- package/src/client/shell/Toolbar.tsx +14 -2
- package/src/client/shell/canvas/Canvas.tsx +1 -1
- package/src/client/shell/canvas/FrameNode.tsx +47 -15
- package/src/client/shell/canvas/snapshots.ts +3 -3
- package/src/client/shell/comments-store.ts +147 -33
- package/src/client/shell/hash.ts +2 -2
- package/src/client/shell/icons.tsx +5 -1
- package/src/client/shell/keys.ts +39 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/perf.ts +1 -1
- package/src/client/shell/store.ts +127 -39
- package/src/client/shell/styles.css +303 -30
- package/src/client/shell/tidy.ts +5 -5
- package/src/client/stage/main.tsx +2 -2
- package/src/shared/events.ts +23 -6
- package/src/shared/utm.ts +22 -0
- package/templates/AGENTS-embedded.md +49 -1
- package/templates/AGENTS-studio.md +49 -1
- package/templates/instructions/configure.md +15 -0
- package/templates/instructions/jam.md +85 -0
- package/templates/instructions/publish.md +1 -1
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as NAME, r as PKG } from "./cli.mjs";
|
|
2
|
-
import { o as loadConfig, s as detectHost } from "./manifest-
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
2
|
+
import { o as loadConfig, s as detectHost } from "./manifest-B4zcDGBf.mjs";
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BdQEeTLg.mjs";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
|
|
@@ -141,7 +141,7 @@ async function dev(root, portFlag) {
|
|
|
141
141
|
const port = typeof addr === "object" && addr ? addr.port : picked.port;
|
|
142
142
|
if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
|
|
143
143
|
console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
|
|
144
|
-
const { loadCollab, syncOnce } = await import("./sync-
|
|
144
|
+
const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
|
|
145
145
|
if (loadCollab(root)) console.log(` comments: syncing with the published canvas (design/.local/collab.json)\n`);
|
|
146
146
|
let syncing = false;
|
|
147
147
|
const tick = async () => {
|
|
@@ -156,6 +156,31 @@ async function dev(root, portFlag) {
|
|
|
156
156
|
};
|
|
157
157
|
tick();
|
|
158
158
|
setInterval(tick, 3e4).unref();
|
|
159
|
+
const { workActivity, writeDevInfo, removeDevInfo } = await import("./work-CLrmY-vQ.mjs");
|
|
160
|
+
const unsubscribe = workActivity.onChange((frames) => server.ws.send("sh:jam-activity", { frames }));
|
|
161
|
+
const sweep = setInterval(() => workActivity.sweep(), 15e3);
|
|
162
|
+
sweep.unref?.();
|
|
163
|
+
writeDevInfo(root, port);
|
|
164
|
+
{
|
|
165
|
+
const close = server.close.bind(server);
|
|
166
|
+
server.close = (async () => {
|
|
167
|
+
unsubscribe();
|
|
168
|
+
clearInterval(sweep);
|
|
169
|
+
removeDevInfo(root);
|
|
170
|
+
return close();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (config.jam?.agent) {
|
|
174
|
+
const { startJam } = await import("./daemon-DkyNOwIt.mjs");
|
|
175
|
+
const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
|
|
176
|
+
if (jam) {
|
|
177
|
+
const close = server.close.bind(server);
|
|
178
|
+
server.close = (async () => {
|
|
179
|
+
jam.stop();
|
|
180
|
+
return close();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
159
184
|
return server;
|
|
160
185
|
}
|
|
161
186
|
//#endregion
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//#region src/shared/events.ts
|
|
2
|
+
/** Events the other side lacks, by id - the sync payload in either direction. */
|
|
3
|
+
function diffEvents(mine, theirIds) {
|
|
4
|
+
const have = new Set(theirIds);
|
|
5
|
+
return mine.filter((e) => !have.has(e.id));
|
|
6
|
+
}
|
|
7
|
+
/** Replay a board's events into current thread state. Deterministic: replay order is
|
|
8
|
+
* (ts, id) so two stores holding the same event SET always derive the same state.
|
|
9
|
+
* TWO passes - creates first, then everything else - so a reply whose author's clock
|
|
10
|
+
* ran ahead of the creator's still lands instead of being dropped forever. */
|
|
11
|
+
function replay(events) {
|
|
12
|
+
const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
|
|
13
|
+
const threads = /* @__PURE__ */ new Map();
|
|
14
|
+
for (const ev of ordered) {
|
|
15
|
+
if (ev.type !== "create") continue;
|
|
16
|
+
if (!ev.commentId || threads.has(ev.commentId)) continue;
|
|
17
|
+
threads.set(ev.commentId, {
|
|
18
|
+
id: ev.commentId,
|
|
19
|
+
board: ev.board,
|
|
20
|
+
nodeKey: ev.nodeKey,
|
|
21
|
+
frame: ev.frame,
|
|
22
|
+
anchor: ev.anchor,
|
|
23
|
+
author: ev.author,
|
|
24
|
+
body: ev.body,
|
|
25
|
+
ts: ev.ts,
|
|
26
|
+
resolved: false,
|
|
27
|
+
agent: ev.agent,
|
|
28
|
+
agentMeta: ev.agentMeta,
|
|
29
|
+
replies: [],
|
|
30
|
+
reactions: {}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
for (const ev of ordered) switch (ev.type) {
|
|
34
|
+
case "reply": {
|
|
35
|
+
const t = ev.parentId ? threads.get(ev.parentId) : void 0;
|
|
36
|
+
if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
|
|
37
|
+
t.replies.push({
|
|
38
|
+
id: ev.commentId,
|
|
39
|
+
author: ev.author,
|
|
40
|
+
body: ev.body,
|
|
41
|
+
ts: ev.ts,
|
|
42
|
+
agent: ev.agent,
|
|
43
|
+
agentMeta: ev.agentMeta
|
|
44
|
+
});
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "edit": {
|
|
48
|
+
if (!ev.commentId || ev.body === void 0) break;
|
|
49
|
+
const t = threads.get(ev.commentId);
|
|
50
|
+
if (t) {
|
|
51
|
+
t.body = ev.body;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
for (const th of threads.values()) {
|
|
55
|
+
const r = th.replies.find((r) => r.id === ev.commentId);
|
|
56
|
+
if (r) {
|
|
57
|
+
r.body = ev.body;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "resolve": {
|
|
64
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
65
|
+
if (t) {
|
|
66
|
+
t.resolved = true;
|
|
67
|
+
if (ev.addressedIn) t.addressedIn = ev.addressedIn;
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "reopen": {
|
|
72
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
73
|
+
if (t) {
|
|
74
|
+
t.resolved = false;
|
|
75
|
+
t.addressedIn = void 0;
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "reanchor": {
|
|
80
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
81
|
+
if (t && ev.anchor != null) t.anchor = ev.anchor;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "react": {
|
|
85
|
+
const t = ev.commentId ? threads.get(ev.commentId) : void 0;
|
|
86
|
+
const who = ev.author?.email;
|
|
87
|
+
if (!t || !who || !ev.emoji) break;
|
|
88
|
+
const users = t.reactions[ev.emoji] ??= [];
|
|
89
|
+
const at = users.indexOf(who);
|
|
90
|
+
if (at === -1) users.push(who);
|
|
91
|
+
else {
|
|
92
|
+
users.splice(at, 1);
|
|
93
|
+
if (!users.length) delete t.reactions[ev.emoji];
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...threads.values()];
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
export { replay as n, diffEvents as t };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as NAME } from "./cli.mjs";
|
|
2
|
-
import { a as DEFAULTS, c as readJson, i as writeManifest, r as scanFrames, s as detectHost } from "./manifest-
|
|
2
|
+
import { a as DEFAULTS, c as readJson, i as writeManifest, r as scanFrames, s as detectHost } from "./manifest-B4zcDGBf.mjs";
|
|
3
3
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -93,6 +93,30 @@ function init(root, opts) {
|
|
|
93
93
|
writeManaged("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host, noApp(host))).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : ""));
|
|
94
94
|
const agentsNow = readFileSync(join(design, "AGENTS.md"), "utf8");
|
|
95
95
|
if (!agentsNow.startsWith(MANAGED_PREFIX) && !agentsNow.startsWith(LEGACY_PREFIX) && agentsNow.includes("# Design canvas - agent contract") && !agentsNow.includes("## The method (binding)")) console.warn(` note: design/AGENTS.md predates managed regeneration - if you never edited it, delete it and re-run init to get the current contract (incl. the design/instructions routing).`);
|
|
96
|
+
const claudeMd = join(root, "CLAUDE.md");
|
|
97
|
+
const importLine = "@design/AGENTS.md";
|
|
98
|
+
if (!existsSync(claudeMd)) {
|
|
99
|
+
writeFileSync(claudeMd, `# ${NAME}\n\nThis repo designs with ${NAME}. The agent contract for the canvas lives in design/AGENTS.md:\n\n${importLine}\n`);
|
|
100
|
+
created.push("CLAUDE.md");
|
|
101
|
+
} else {
|
|
102
|
+
const cur = readFileSync(claudeMd, "utf8");
|
|
103
|
+
if (!cur.includes(importLine)) {
|
|
104
|
+
writeFileSync(claudeMd, cur.replace(/\n*$/, "") + `\n\n${importLine}\n`);
|
|
105
|
+
created.push("CLAUDE.md (updated)");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const rootAgentsMd = join(root, "AGENTS.md");
|
|
109
|
+
const pointerLine = `Before any design or canvas work, read design/AGENTS.md and follow it - it is binding.`;
|
|
110
|
+
if (!existsSync(rootAgentsMd)) {
|
|
111
|
+
writeFileSync(rootAgentsMd, `# ${NAME}\n\nThis repo designs with ${NAME}. ${pointerLine}\n`);
|
|
112
|
+
created.push("AGENTS.md");
|
|
113
|
+
} else {
|
|
114
|
+
const cur = readFileSync(rootAgentsMd, "utf8");
|
|
115
|
+
if (!cur.includes("design/AGENTS.md")) {
|
|
116
|
+
writeFileSync(rootAgentsMd, cur.replace(/\n*$/, "") + `\n\n${pointerLine}\n`);
|
|
117
|
+
created.push("AGENTS.md (updated)");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
96
120
|
const instrRoot = join(templates, "instructions");
|
|
97
121
|
for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
|
|
98
122
|
for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) writeManaged(`instructions/${e.name}/${f}`, readFileSync(join(instrRoot, e.name, f), "utf8"));
|
|
@@ -140,7 +164,8 @@ function init(root, opts) {
|
|
|
140
164
|
writeFileSync(join(design, "boards", ".gitkeep"), "");
|
|
141
165
|
created.push("design/boards/");
|
|
142
166
|
}
|
|
143
|
-
|
|
167
|
+
const sceneDirs = existsSync(join(design, "scenes")) ? readdirSync(join(design, "scenes"), { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_")).length : 0;
|
|
168
|
+
if (opts.demo && sceneDirs === 0 && !existsSync(join(design, "scenes", "demo"))) {
|
|
144
169
|
cpSync(join(templates, "demo"), join(design, "scenes", "demo"), { recursive: true });
|
|
145
170
|
created.push("design/scenes/demo/ (3 frames)");
|
|
146
171
|
}
|
|
@@ -162,7 +187,7 @@ function init(root, opts) {
|
|
|
162
187
|
console.log(`\n commit design/ - only .local/ is ignored`);
|
|
163
188
|
console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
|
|
164
189
|
if (!noApp(host) && !existsSync(join(design, "DESIGN.md"))) console.log(`\n note: design/DESIGN.md (the brand doc) does not exist yet - have your agent create it from the app's tokens (instructions/brand.md, Path A) to reach the idle state.`);
|
|
165
|
-
console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
|
|
190
|
+
console.log(`\n next: npx ${NAME} dev (or: npx ${NAME} canvas - same thing; canvas on http://localhost:${DEFAULTS.port} by default)\n`);
|
|
166
191
|
if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md. This is our first session - follow design/instructions/welcome.md."\n`);
|
|
167
192
|
}
|
|
168
193
|
const MANAGED_PREFIX = "<!-- marver:managed ";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
//#region \0rolldown/runtime.js
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __exportAll = (all, no_symbols) => {
|
|
6
|
+
let target = {};
|
|
7
|
+
for (var name in all) __defProp(target, name, {
|
|
8
|
+
get: all[name],
|
|
9
|
+
enumerable: true
|
|
10
|
+
});
|
|
11
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
12
|
+
return target;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/server/jam/ledger.ts
|
|
16
|
+
/**
|
|
17
|
+
* The device-bound authorization ledger - the whole trust boundary.
|
|
18
|
+
*
|
|
19
|
+
* When the dev POST accepts an owner-gated write, it records that event's id here. The
|
|
20
|
+
* daemon's owner-trigger check is `has(root, id)`, never a synced field: sync copies
|
|
21
|
+
* `origin` byte-for-byte, so a remote comment can spoof `origin:'local'` (proven RCE),
|
|
22
|
+
* but it can never appear in a file that is written only on THIS machine by the gated
|
|
23
|
+
* POST and never synced. Synced-in events are never in the ledger, so they never trigger.
|
|
24
|
+
*
|
|
25
|
+
* One `<board>\t<id>` per line, append-only, gitignored, never synced (design/.local/ is
|
|
26
|
+
* watch-ignored and sync-excluded). Agent-written events are never recorded (they are
|
|
27
|
+
* daemon-authored, not owner input, so they cannot self-authorize a next job).
|
|
28
|
+
*
|
|
29
|
+
* The key is (board, id), NOT id alone: event ids are client UUIDs that sync copies verbatim,
|
|
30
|
+
* so a remote collaborator could reuse an owner's ledgered id in a NEW malicious event. Binding
|
|
31
|
+
* to the board it was gate-written on defeats that - the forged copy lands on some board the
|
|
32
|
+
* ledger never authorized for that id, so it never triggers.
|
|
33
|
+
*/
|
|
34
|
+
var ledger_exports = /* @__PURE__ */ __exportAll({
|
|
35
|
+
has: () => has,
|
|
36
|
+
record: () => record
|
|
37
|
+
});
|
|
38
|
+
const ledgerFile = (root) => join(root, "design", ".local", "jam-ledger");
|
|
39
|
+
const line = (board, id) => `${board}\t${id}`;
|
|
40
|
+
/** Was this (board, id) authorized on this device by the gated dev POST? */
|
|
41
|
+
function has(root, board, id) {
|
|
42
|
+
if (!board || !id) return false;
|
|
43
|
+
const file = ledgerFile(root);
|
|
44
|
+
if (!existsSync(file)) return false;
|
|
45
|
+
const want = line(board, id);
|
|
46
|
+
for (const l of readFileSync(file, "utf8").split("\n")) if (l === want) return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/** Authorize a (board, id). fsync'd (a 200-acked, ledgered write must survive a crash) and
|
|
50
|
+
* 0600 (owner-only). Idempotent enough: a duplicate line is harmless, `has` matches either. */
|
|
51
|
+
function record(root, board, id) {
|
|
52
|
+
if (!board || !id) return;
|
|
53
|
+
const file = ledgerFile(root);
|
|
54
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
55
|
+
const fd = openSync(file, "a", 384);
|
|
56
|
+
try {
|
|
57
|
+
writeSync(fd, line(board, id) + "\n");
|
|
58
|
+
fsyncSync(fd);
|
|
59
|
+
} finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { ledger_exports as n, has as t };
|
|
@@ -169,7 +169,8 @@ async function loadConfig(root) {
|
|
|
169
169
|
branding: user.share?.branding !== false,
|
|
170
170
|
name: typeof user.share?.name === "string" ? user.share.name : void 0,
|
|
171
171
|
logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
|
|
172
|
-
}
|
|
172
|
+
},
|
|
173
|
+
jam: validJam(user.jam)
|
|
173
174
|
};
|
|
174
175
|
} catch (err) {
|
|
175
176
|
console.error(`[marver] design/config.ts failed to load, using defaults:\n ${err.message}`);
|
|
@@ -183,6 +184,20 @@ function validPort(n) {
|
|
|
183
184
|
function validZoom(n) {
|
|
184
185
|
return typeof n === "number" && Number.isFinite(n) && n >= .1 && n <= 10 ? n : null;
|
|
185
186
|
}
|
|
187
|
+
/** Normalize the jam block. An unknown/missing `agent` means Live Jam stays OFF (returns undefined),
|
|
188
|
+
* so a stray `jam: {}` never silently arms the daemon. Other fields fall back to lean defaults. */
|
|
189
|
+
function validJam(v) {
|
|
190
|
+
if (!v || typeof v !== "object") return void 0;
|
|
191
|
+
const j = v;
|
|
192
|
+
if (j.agent !== "claude" && j.agent !== "codex") return void 0;
|
|
193
|
+
const concurrency = typeof j.concurrency === "number" && Number.isInteger(j.concurrency) && j.concurrency >= 1 && j.concurrency <= 16 ? j.concurrency : 3;
|
|
194
|
+
return {
|
|
195
|
+
agent: j.agent,
|
|
196
|
+
concurrency,
|
|
197
|
+
subagents: j.subagents !== false,
|
|
198
|
+
proactive: j.proactive === true
|
|
199
|
+
};
|
|
200
|
+
}
|
|
186
201
|
function validViewports(v) {
|
|
187
202
|
if (!v || typeof v !== "object") return null;
|
|
188
203
|
const out = {};
|
|
@@ -199,7 +214,7 @@ function validViewports(v) {
|
|
|
199
214
|
//#region src/server/manifest.ts
|
|
200
215
|
const FRAME_EXT = /\.(tsx|jsx|html)$/;
|
|
201
216
|
const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
|
|
202
|
-
/** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted
|
|
217
|
+
/** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
|
|
203
218
|
function extractMeta(src) {
|
|
204
219
|
const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
|
|
205
220
|
if (!m) return {};
|
|
@@ -223,7 +238,7 @@ function extractMeta(src) {
|
|
|
223
238
|
if (intent) out.intent = intent;
|
|
224
239
|
return out;
|
|
225
240
|
}
|
|
226
|
-
/** Content-frame detection
|
|
241
|
+
/** Content-frame detection: LEXICAL by convention - the frame file
|
|
227
242
|
* itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
|
|
228
243
|
* string in a UI frame can never misbadge it; barrels/re-exports are not
|
|
229
244
|
* detected - meta.intent is the taught path and always works). Returns the
|
|
@@ -343,7 +358,7 @@ function scanFrames(root) {
|
|
|
343
358
|
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
344
359
|
};
|
|
345
360
|
}
|
|
346
|
-
/** Variant groups
|
|
361
|
+
/** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
|
|
347
362
|
* are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
|
|
348
363
|
* directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
|
|
349
364
|
* Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
|
|
3
|
+
import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-B4zcDGBf.mjs";
|
|
3
4
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
4
5
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -7,6 +8,28 @@ import { randomBytes } from "node:crypto";
|
|
|
7
8
|
//#region src/server/api.ts
|
|
8
9
|
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
9
10
|
const BODY_LIMIT = 1e6;
|
|
11
|
+
const CSRF_MAX_AGE = 2592e3;
|
|
12
|
+
/** Read one cookie value off the request. */
|
|
13
|
+
function cookie(req, name) {
|
|
14
|
+
const m = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(String(req.headers.cookie ?? ""));
|
|
15
|
+
return m ? m[1] : "";
|
|
16
|
+
}
|
|
17
|
+
/** The owner gate. The dev server is 127.0.0.1-only, so the live threat is a
|
|
18
|
+
* cross-origin drive-by page firing a POST at localhost. Two defenses, mirroring the published
|
|
19
|
+
* side (collab.ts:206): a double-submit cookie (mv_c is JS-readable same-origin, so a cross-origin
|
|
20
|
+
* page cannot read it to echo x-mv-c), plus an Origin allowlist. Only a POST that passes this is
|
|
21
|
+
* eligible to be ledgered, so a forged @marver can never authorize the local agent. */
|
|
22
|
+
function ownerGated(req) {
|
|
23
|
+
const c = cookie(req, "mv_c");
|
|
24
|
+
if (!c || req.headers["x-mv-c"] !== c) return false;
|
|
25
|
+
const origin = req.headers.origin;
|
|
26
|
+
if (origin) try {
|
|
27
|
+
if (new URL(String(origin)).host !== String(req.headers.host ?? "")) return false;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
10
33
|
function json(res, status, body) {
|
|
11
34
|
res.statusCode = status;
|
|
12
35
|
res.setHeader("content-type", "application/json");
|
|
@@ -74,6 +97,7 @@ function apiMiddleware(root) {
|
|
|
74
97
|
const url = new URL(req.url ?? "/", "http://x");
|
|
75
98
|
if (!url.pathname.startsWith(`/__mv/api/`)) return next();
|
|
76
99
|
const path = url.pathname.slice(`${ROUTE}/api/`.length);
|
|
100
|
+
if (req.method === "GET" && !cookie(req, "mv_c")) res.setHeader("set-cookie", `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${CSRF_MAX_AGE}; SameSite=Lax`);
|
|
77
101
|
try {
|
|
78
102
|
if (path === "boards" && req.method === "GET") {
|
|
79
103
|
mkdirSync(boardsDir, { recursive: true });
|
|
@@ -146,9 +170,11 @@ function apiMiddleware(root) {
|
|
|
146
170
|
if (path === "me" && req.method === "GET") return json(res, 200, {
|
|
147
171
|
user: localProfile(root),
|
|
148
172
|
role: "owner",
|
|
149
|
-
local: true
|
|
173
|
+
local: true,
|
|
174
|
+
connected: isConnected(root)
|
|
150
175
|
});
|
|
151
176
|
if (path === "profile" && req.method === "POST") {
|
|
177
|
+
if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
|
|
152
178
|
const raw = await readBody(req);
|
|
153
179
|
if (raw == null) return json(res, 400, { error: "body too large" });
|
|
154
180
|
let b;
|
|
@@ -159,17 +185,54 @@ function apiMiddleware(root) {
|
|
|
159
185
|
}
|
|
160
186
|
const dir = join(root, "design", ".local");
|
|
161
187
|
mkdirSync(dir, { recursive: true });
|
|
188
|
+
const { validAvatar } = await import("./collab-s3k5byM1.mjs");
|
|
189
|
+
let cur = {};
|
|
190
|
+
try {
|
|
191
|
+
cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
|
|
192
|
+
} catch {}
|
|
162
193
|
const prof = {
|
|
163
|
-
...
|
|
194
|
+
...cur,
|
|
164
195
|
...typeof b.name === "string" && b.name.trim() ? { name: b.name.trim() } : {},
|
|
165
196
|
...typeof b.email === "string" ? { email: b.email.trim() } : {},
|
|
166
|
-
...
|
|
197
|
+
...b.avatar === "" ? { avatar: void 0 } : validAvatar(b.avatar) ? { avatar: b.avatar } : {}
|
|
167
198
|
};
|
|
168
199
|
atomicWrite(join(dir, "profile.json"), JSON.stringify(prof, null, 2) + "\n");
|
|
169
|
-
return json(res, 200, { user:
|
|
200
|
+
return json(res, 200, { user: localProfile(root) });
|
|
201
|
+
}
|
|
202
|
+
if (path === "work") {
|
|
203
|
+
const { workActivity, readDevInfo, WORK_TTL_DEFAULT, WORK_TTL_MAX } = await import("./work-CLrmY-vQ.mjs");
|
|
204
|
+
const tokenOk = () => {
|
|
205
|
+
const t = readDevInfo(root)?.token;
|
|
206
|
+
return !!t && req.headers["x-mv-work"] === t;
|
|
207
|
+
};
|
|
208
|
+
if (req.method === "GET") {
|
|
209
|
+
if (!ownerGated(req) && !tokenOk()) return json(res, 403, { error: "forbidden" });
|
|
210
|
+
return json(res, 200, { frames: workActivity.active() });
|
|
211
|
+
}
|
|
212
|
+
if (req.method === "POST") {
|
|
213
|
+
if (!ownerGated(req) && !tokenOk()) return json(res, 403, { error: "forbidden" });
|
|
214
|
+
const raw = await readBody(req);
|
|
215
|
+
if (raw == null) return json(res, 400, { error: "body too large" });
|
|
216
|
+
let b;
|
|
217
|
+
try {
|
|
218
|
+
b = JSON.parse(raw);
|
|
219
|
+
} catch {
|
|
220
|
+
return json(res, 400, { error: "malformed JSON" });
|
|
221
|
+
}
|
|
222
|
+
const frames = Array.isArray(b.frames) ? b.frames.filter((f) => typeof f === "string" && f) : [];
|
|
223
|
+
const on = b.on !== false;
|
|
224
|
+
if (!on && b.all === true) {
|
|
225
|
+
workActivity.clearAll("cli");
|
|
226
|
+
return json(res, 200, { frames: workActivity.active() });
|
|
227
|
+
}
|
|
228
|
+
if (!frames.length) return json(res, 400, { error: "frames required" });
|
|
229
|
+
const ttl = Math.min(Math.max(Number(b.ttlMs) || WORK_TTL_DEFAULT, 1e4), WORK_TTL_MAX);
|
|
230
|
+
for (const f of frames) on ? workActivity.mark(f, ttl, "cli") : workActivity.clear(f, "cli");
|
|
231
|
+
return json(res, 200, { frames: workActivity.active() });
|
|
232
|
+
}
|
|
170
233
|
}
|
|
171
234
|
if (path === "boards.rights" && req.method === "GET") {
|
|
172
|
-
const { listBoards } = await import("./comments-
|
|
235
|
+
const { listBoards } = await import("./comments-BZBKhKRO.mjs");
|
|
173
236
|
return json(res, 200, {
|
|
174
237
|
local: true,
|
|
175
238
|
stored: listBoards(join(root, "design", "comments"))
|
|
@@ -177,10 +240,11 @@ function apiMiddleware(root) {
|
|
|
177
240
|
}
|
|
178
241
|
const cm = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
179
242
|
if (cm) {
|
|
180
|
-
const { appendEvents, readLog } = await import("./comments-
|
|
243
|
+
const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
|
|
181
244
|
const dir = join(root, "design", "comments");
|
|
182
245
|
if (req.method === "GET") return json(res, 200, { events: readLog(dir, cm[1]) });
|
|
183
246
|
if (req.method === "POST") {
|
|
247
|
+
if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
|
|
184
248
|
const raw = await readBody(req);
|
|
185
249
|
if (raw == null) return json(res, 400, { error: "body too large" });
|
|
186
250
|
let b;
|
|
@@ -191,23 +255,28 @@ function apiMiddleware(root) {
|
|
|
191
255
|
}
|
|
192
256
|
const incoming = Array.isArray(b.events) ? b.events : [];
|
|
193
257
|
const me = localProfile(root);
|
|
194
|
-
const stamped = incoming.map((ev) =>
|
|
195
|
-
...ev
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
258
|
+
const stamped = incoming.map((ev) => {
|
|
259
|
+
const { agent: _a, agentMeta: _am, origin: _o, ...clean } = ev;
|
|
260
|
+
return {
|
|
261
|
+
...clean,
|
|
262
|
+
board: clean.board ?? cm[1],
|
|
263
|
+
author: clean.author ?? ([
|
|
264
|
+
"create",
|
|
265
|
+
"reply",
|
|
266
|
+
"react",
|
|
267
|
+
"edit"
|
|
268
|
+
].includes(clean.type) ? me : void 0)
|
|
269
|
+
};
|
|
270
|
+
});
|
|
204
271
|
const fresh = appendEvents(dir, cm[1], stamped);
|
|
272
|
+
const { record } = await import("./ledger-CbzTJrV2.mjs").then((n) => n.n);
|
|
273
|
+
for (const ev of fresh) if (ev.type === "create" || ev.type === "reply") record(root, cm[1], ev.id);
|
|
205
274
|
backgroundPush(root);
|
|
206
275
|
return json(res, 200, { accepted: fresh.length });
|
|
207
276
|
}
|
|
208
277
|
}
|
|
209
278
|
if (path === "sync" && req.method === "POST") {
|
|
210
|
-
const { loadCollab, syncOnce } = await import("./sync-
|
|
279
|
+
const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
|
|
211
280
|
const collab = loadCollab(root);
|
|
212
281
|
if (!collab) return json(res, 200, { connected: false });
|
|
213
282
|
try {
|
|
@@ -228,27 +297,6 @@ function apiMiddleware(root) {
|
|
|
228
297
|
}
|
|
229
298
|
};
|
|
230
299
|
}
|
|
231
|
-
function localProfile(root) {
|
|
232
|
-
try {
|
|
233
|
-
const c = JSON.parse(readFileSync(join(root, "design", ".local", "collab.json"), "utf8"));
|
|
234
|
-
if (typeof c?.email === "string" && c.email) return {
|
|
235
|
-
email: c.email,
|
|
236
|
-
name: c.name ?? "Designer"
|
|
237
|
-
};
|
|
238
|
-
} catch {}
|
|
239
|
-
try {
|
|
240
|
-
const p = JSON.parse(readFileSync(join(root, "design", ".local", "profile.json"), "utf8"));
|
|
241
|
-
if (typeof p?.name === "string") return {
|
|
242
|
-
email: p.email ?? "",
|
|
243
|
-
name: p.name,
|
|
244
|
-
avatar: p.avatar
|
|
245
|
-
};
|
|
246
|
-
} catch {}
|
|
247
|
-
return {
|
|
248
|
-
email: "",
|
|
249
|
-
name: "Designer"
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
300
|
let pushTimer = null;
|
|
253
301
|
/** Debounced fire-and-forget push after a local write; failures are silent - the
|
|
254
302
|
* 30s sync loop is the reliability layer, this is just latency. */
|
|
@@ -257,7 +305,7 @@ function backgroundPush(root) {
|
|
|
257
305
|
pushTimer = setTimeout(async () => {
|
|
258
306
|
pushTimer = null;
|
|
259
307
|
try {
|
|
260
|
-
const { loadCollab, syncOnce } = await import("./sync-
|
|
308
|
+
const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
|
|
261
309
|
const collab = loadCollab(root);
|
|
262
310
|
if (collab) await syncOnce(root, collab);
|
|
263
311
|
} catch {}
|
|
@@ -438,7 +486,7 @@ function marverPlugin(ctx) {
|
|
|
438
486
|
zoomSpeed: config.zoomSpeed,
|
|
439
487
|
noTheme: themeFile() == null,
|
|
440
488
|
setup: setupPending,
|
|
441
|
-
projectName: basename(root)
|
|
489
|
+
projectName: config.share.name || basename(root)
|
|
442
490
|
})}`;
|
|
443
491
|
}
|
|
444
492
|
if (id === "\0virtual:sh-data") return "export default null";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
//#region src/server/profile.ts
|
|
4
|
+
/**
|
|
5
|
+
* The dev identity - ONE resolver for every server-side consumer (api, jam daemon, CLI).
|
|
6
|
+
*
|
|
7
|
+
* Two sources merge in design/.local/:
|
|
8
|
+
* - profile.json {name, email?, avatar?} - set from the composer's avatar popover
|
|
9
|
+
* - collab.json {email, name} - the connect account, written by `comments connect`
|
|
10
|
+
*
|
|
11
|
+
* The connect account wins name + email (published serves validate author == session, so
|
|
12
|
+
* events born here must carry an identity the remote will accept). The avatar always comes
|
|
13
|
+
* from profile.json - connect doesn't carry one, and the local photo is still YOUR photo.
|
|
14
|
+
*/
|
|
15
|
+
const readJson = (path) => {
|
|
16
|
+
try {
|
|
17
|
+
const v = JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
24
|
+
/** True when a connect account provides the identity - name/email are then read-only in dev. */
|
|
25
|
+
function isConnected(root) {
|
|
26
|
+
return !!str(readJson(join(root, "design", ".local", "collab.json")).email);
|
|
27
|
+
}
|
|
28
|
+
function localProfile(root) {
|
|
29
|
+
const dir = join(root, "design", ".local");
|
|
30
|
+
const prof = readJson(join(dir, "profile.json"));
|
|
31
|
+
const collab = readJson(join(dir, "collab.json"));
|
|
32
|
+
return {
|
|
33
|
+
name: str(collab.name) || str(prof.name) || "You",
|
|
34
|
+
email: str(collab.email) || str(prof.email),
|
|
35
|
+
avatar: str(prof.avatar) || void 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { localProfile as n, isConnected as t };
|
|
@@ -3,10 +3,29 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
|
3
3
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
|
+
//#region src/shared/utm.ts
|
|
7
|
+
/** Automatic attribution for the powered-by links: every canvas tags its marver.design
|
|
8
|
+
* link so site analytics can see WHICH canvas sent a visitor - zero user setup.
|
|
9
|
+
* Conventional UTM semantics (stable groupings first, the variable last):
|
|
10
|
+
* utm_source = the surface class ('published-canvas' | 'dev-canvas')
|
|
11
|
+
* utm_medium = the link unit ('powered-by')
|
|
12
|
+
* utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
|
|
13
|
+
* utm_content = the placement ('gate' badge | 'shell' wordmark)
|
|
14
|
+
*/
|
|
15
|
+
function poweredByUrl(canvasName, source, content) {
|
|
16
|
+
const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
17
|
+
return `https://marver.design/?${new URLSearchParams({
|
|
18
|
+
utm_source: source,
|
|
19
|
+
utm_medium: "powered-by",
|
|
20
|
+
...slug ? { utm_campaign: slug } : {},
|
|
21
|
+
utm_content: content
|
|
22
|
+
}).toString()}`;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
6
25
|
//#region src/server/serve.ts
|
|
7
26
|
/**
|
|
8
27
|
* `marver serve` - a zero-dependency static server for design/.dist with an optional
|
|
9
|
-
* password gate
|
|
28
|
+
* password gate. No MARVER_PASSWORD → plain static serving. With it, the
|
|
10
29
|
* bundle is never sent pre-auth: every unauthenticated request gets the gate page, auth
|
|
11
30
|
* is a POST compare + an HMAC-signed 30-day cookie keyed off the password itself (all
|
|
12
31
|
* instances agree, nothing stored server-side).
|
|
@@ -49,9 +68,9 @@ async function serve(root, portFlag) {
|
|
|
49
68
|
let bearerCheck = null;
|
|
50
69
|
let sessionCheck = null;
|
|
51
70
|
if (process.env.MARVER_DATA_DIR) {
|
|
52
|
-
const { collabHandler } = await import("./collab-
|
|
53
|
-
const { appendEvents, readLog } = await import("./comments-
|
|
54
|
-
const { dataDir } = await import("./comments-
|
|
71
|
+
const { collabHandler } = await import("./collab-s3k5byM1.mjs");
|
|
72
|
+
const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
|
|
73
|
+
const { dataDir } = await import("./comments-BZBKhKRO.mjs");
|
|
55
74
|
const dir = dataDir();
|
|
56
75
|
const seedDir = join(dist, "design", "comments");
|
|
57
76
|
if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
|
|
@@ -60,7 +79,7 @@ async function serve(root, portFlag) {
|
|
|
60
79
|
appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
|
|
61
80
|
}
|
|
62
81
|
collab = collabHandler(dir, dist);
|
|
63
|
-
const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-
|
|
82
|
+
const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-KQ9Aj-nB.mjs");
|
|
64
83
|
bearerCheck = (token) => sessionUser(dir, token);
|
|
65
84
|
sessionCheck = (req) => {
|
|
66
85
|
const tok = /(?:^|;\s*)mv_s=([\w-]+)/.exec(String(req.headers.cookie ?? ""))?.[1];
|
|
@@ -128,7 +147,12 @@ async function serve(root, portFlag) {
|
|
|
128
147
|
if (!bearerOk && !preGate) return gate(res, meta, !!collab);
|
|
129
148
|
}
|
|
130
149
|
}
|
|
131
|
-
if (
|
|
150
|
+
if (url.pathname.startsWith("/__mv/api/")) {
|
|
151
|
+
if (!collab) {
|
|
152
|
+
res.statusCode = 404;
|
|
153
|
+
res.setHeader("content-type", "application/json");
|
|
154
|
+
return res.end("{\"error\":\"collaboration is not enabled on this canvas\"}");
|
|
155
|
+
}
|
|
132
156
|
collab(req, res, url).then((handled) => {
|
|
133
157
|
if (!handled) {
|
|
134
158
|
res.statusCode = 404;
|
|
@@ -188,7 +212,7 @@ const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${
|
|
|
188
212
|
const MARK = MARK_AT(17);
|
|
189
213
|
const MARK_LG = MARK_AT(24);
|
|
190
214
|
/** The gate: the canvas's own antechamber - light ground, dot grid, glass card in the
|
|
191
|
-
* shell's exact token language. Three doors, one credential each
|
|
215
|
+
* shell's exact token language. Three doors, one credential each:
|
|
192
216
|
* guests pay the canvas password, members sign in with their OWN password, and an
|
|
193
217
|
* invite link (#/i/<token>) opens straight into the claim. The member and claim
|
|
194
218
|
* states exist only on collaboration canvases - a static canvas keeps one field. */
|
|
@@ -423,7 +447,7 @@ ${collabOn ? `
|
|
|
423
447
|
` : ""}
|
|
424
448
|
})()
|
|
425
449
|
<\/script>
|
|
426
|
-
${meta.branding ? `<footer><a href="
|
|
450
|
+
${meta.branding ? `<footer><a href="${poweredByUrl(meta.name, "published-canvas", "gate")}" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> <svg class="up" viewBox="0 0 256 256" width="11" height="11" fill="currentColor" aria-hidden><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"/></svg></a></footer>` : ""}
|
|
427
451
|
</main></body></html>`);
|
|
428
452
|
}
|
|
429
453
|
const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|