@marver-design/marver 0.11.1 → 0.12.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1 -1
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-DI31MFTB.mjs → build-DmfxJc6L.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-BmaOjwP1.mjs +781 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
  9. package/dist/{dev-BWIL2ewj.mjs → dev-BXq7yE1p.mjs} +5 -5
  10. package/dist/events-C9k-ceOj.mjs +101 -0
  11. package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
  12. package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
  13. package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
  14. package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
  15. package/dist/{plugin-DjBFxEQ7.mjs → plugin-Boy7I_JH.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
  17. package/dist/{serve-3APqaNSB.mjs → serve-BeLLizNg.mjs} +70 -17
  18. package/dist/share-C64b1tdN.mjs +159 -0
  19. package/dist/summary-Bf9tiGrE.mjs +211 -0
  20. package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
  21. package/package.json +1 -1
  22. package/src/client/shell/App.tsx +43 -8
  23. package/src/client/shell/Comments.tsx +9 -5
  24. package/src/client/shell/LockedApp.tsx +113 -0
  25. package/src/client/shell/Play.tsx +97 -18
  26. package/src/client/shell/Toolbar.tsx +26 -2
  27. package/src/client/shell/canvas/Canvas.tsx +5 -15
  28. package/src/client/shell/canvas/ctl.ts +26 -0
  29. package/src/client/shell/comments-store.ts +39 -9
  30. package/src/client/shell/hash.ts +19 -0
  31. package/src/client/shell/icons.tsx +1 -0
  32. package/src/client/shell/store.ts +54 -3
  33. package/src/client/shell/styles.css +27 -0
  34. package/src/shared/events.ts +8 -3
  35. package/dist/auth-B5yuwnOq.mjs +0 -494
  36. package/dist/collab-CWaG3Q4w.mjs +0 -340
  37. package/dist/manifest-C2tzkNaC.mjs +0 -233
  38. package/dist/update-DuWDj5nR.mjs +0 -77
  39. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -1,340 +0,0 @@
1
- import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
2
- import { a as claimInvite, c as issueDeviceSession, d as publicUser, f as revokeUser, g as updateProfile, h as signOut, l as ownerName, m as signIn, o as createInvite, p as sessionUser, s as inviteInfo } from "./auth-B5yuwnOq.mjs";
3
- import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
4
- import { readFileSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { randomBytes } from "node:crypto";
7
- //#region src/server/collab.ts
8
- const MONTH = 2592e3;
9
- const MAX_BODY = 262144;
10
- /** The permission seam: v1 derives from publish policy + membership;
11
- * a per-user ACL can replace the internals without touching a single route. */
12
- function can(user, rights, board, action) {
13
- if (action === "read") return board in rights;
14
- if (action === "admin") return user?.role === "owner";
15
- return !!user && rights[board] === "comment";
16
- }
17
- const EVENT_TYPES = /* @__PURE__ */ new Set([
18
- "create",
19
- "reply",
20
- "edit",
21
- "resolve",
22
- "reopen",
23
- "react",
24
- "profile",
25
- "reanchor"
26
- ]);
27
- const ID_RE = /^[\w-]{8,64}$/;
28
- const MAX_BODY_TEXT = 1e4;
29
- /** An avatar the server will store: a raster data-URI whose ACTUAL decoded bytes
30
- * match the declared type. The declared MIME alone is a lie an attacker controls
31
- * (base64 SVG labelled image/png); the magic bytes are the truth. Capped small -
32
- * the client downscales to a 128px jpeg (~15KB), 64KB is generous headroom. */
33
- function validAvatar(s) {
34
- if (typeof s !== "string" || s.length > 65536) return false;
35
- const m = /^data:image\/(jpeg|png|webp|gif);base64,([A-Za-z0-9+/]+={0,2})$/.exec(s);
36
- if (!m) return false;
37
- let head;
38
- try {
39
- head = Buffer.from(m[2].slice(0, 24), "base64");
40
- } catch {
41
- return false;
42
- }
43
- return {
44
- jpeg: (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
45
- png: (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71,
46
- gif: (b) => b.toString("latin1", 0, 4) === "GIF8",
47
- webp: (b) => b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP"
48
- }[m[1]]?.(head) ?? false;
49
- }
50
- /** Reject anything the log must not absorb. Returns an error string or null.
51
- * Events are append-only and synced by id, so acceptance is forever - validate hard. */
52
- function validateEvents(incoming, log, u, board) {
53
- const now = Date.now();
54
- const creates = /* @__PURE__ */ new Map();
55
- const authors = /* @__PURE__ */ new Map();
56
- for (const ev of log) {
57
- if (ev.type === "create" && ev.commentId && !creates.has(ev.commentId)) creates.set(ev.commentId, ev);
58
- if ((ev.type === "create" || ev.type === "reply") && ev.commentId && ev.author?.email && !authors.has(ev.commentId)) authors.set(ev.commentId, ev.author.email.toLowerCase());
59
- }
60
- const me = u.email.toLowerCase();
61
- for (const ev of incoming) {
62
- if (!ev || typeof ev !== "object") return "malformed event";
63
- if (typeof ev.id !== "string" || !ID_RE.test(ev.id)) return "event id must be an 8-64 char token";
64
- if (!EVENT_TYPES.has(ev.type)) return `unknown event type "${ev.type}"`;
65
- if (ev.agent !== void 0 || ev.agentMeta !== void 0) return "agent provenance is daemon-only";
66
- if (typeof ev.ts !== "number" || ev.ts < 15778368e5 || ev.ts > now + 6e4) return "event timestamp out of bounds";
67
- if (ev.board !== void 0 && ev.board !== board) return "event board does not match the endpoint";
68
- if (ev.body !== void 0 && (typeof ev.body !== "string" || ev.body.length > MAX_BODY_TEXT)) return "body too long";
69
- if ((ev.type === "create" || ev.type === "reply" || ev.type === "react" || ev.type === "edit" || ev.type === "reanchor") && ev.author?.email?.toLowerCase() !== me) return "event author must be the signed-in account";
70
- if (typeof ev.commentId !== "string" || !ID_RE.test(ev.commentId)) {
71
- if (ev.type !== "profile") return "event needs a commentId";
72
- }
73
- switch (ev.type) {
74
- case "create":
75
- if (creates.has(ev.commentId)) return "a thread with that id already exists";
76
- creates.set(ev.commentId, ev);
77
- if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
78
- break;
79
- case "reply":
80
- if (typeof ev.parentId !== "string" || !creates.has(ev.parentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.parentId)) return "reply parent does not exist";
81
- if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
82
- break;
83
- case "edit": {
84
- const owner = authors.get(ev.commentId);
85
- if (!owner) return "cannot edit a comment that does not exist";
86
- if (owner !== me) return "only the author can edit a comment";
87
- break;
88
- }
89
- case "resolve":
90
- case "reopen":
91
- if (!creates.has(ev.commentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.commentId)) return "cannot resolve a thread that does not exist";
92
- break;
93
- case "reanchor": {
94
- if (!creates.has(ev.commentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.commentId)) return "cannot reanchor a thread that does not exist";
95
- if (ev.anchor == null) return "reanchor needs a new anchor";
96
- const owner = authors.get(ev.commentId);
97
- if (owner && owner !== me) return "only the thread author can reanchor";
98
- break;
99
- }
100
- }
101
- }
102
- return null;
103
- }
104
- function collabHandler(dataDir, distDir) {
105
- let rights = {};
106
- try {
107
- rights = JSON.parse(readFileSync(join(distDir, "meta.json"), "utf8")).rights ?? {};
108
- } catch {}
109
- const commentsDir = join(dataDir, "comments");
110
- const bootEpoch = randomBytes(4).toString("hex");
111
- let seq = 0;
112
- const ring = [];
113
- const clients = /* @__PURE__ */ new Set();
114
- const broadcast = (board, events) => {
115
- for (const ev of events) {
116
- const frame = `id: ${bootEpoch}-${++seq}\nevent: comment\ndata: ${JSON.stringify({
117
- board,
118
- ev
119
- })}\n\n`;
120
- ring.push({
121
- seq,
122
- data: frame
123
- });
124
- if (ring.length > 500) ring.shift();
125
- for (const res of clients) res.write(frame);
126
- }
127
- };
128
- setInterval(() => {
129
- for (const res of clients) res.write(": keepalive\n\n");
130
- }, 24e4).unref();
131
- const attempts = /* @__PURE__ */ new Map();
132
- const limited = (...keys) => {
133
- const now = Date.now();
134
- if (attempts.size > 1e4) {
135
- for (const [k, a] of attempts) if (a.reset < now) attempts.delete(k);
136
- }
137
- if (attempts.size > 2e4) return true;
138
- let hit = false;
139
- for (const key of keys) {
140
- const a = attempts.get(key);
141
- if (!a || a.reset < now) attempts.set(key, {
142
- n: 1,
143
- reset: now + 6e4
144
- });
145
- else if (++a.n > 10) hit = true;
146
- }
147
- return hit;
148
- };
149
- const cookie = (req, name) => new RegExp(`(?:^|;\\s*)${name}=([\\w-]+)`).exec(String(req.headers.cookie ?? ""))?.[1];
150
- /** browser session cookie, or a bearer token (the dev proxy / agent CLI path -
151
- * same session store, held server-side by the Vite process, never by a page) */
152
- const sessionToken = (req) => /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? cookie(req, "mv_s") ?? null;
153
- const currentUser = (req) => {
154
- const tok = sessionToken(req);
155
- return tok ? sessionUser(dataDir, tok) : null;
156
- };
157
- const json = (res, code, body) => {
158
- res.statusCode = code;
159
- res.setHeader("content-type", "application/json");
160
- res.setHeader("cache-control", "no-store");
161
- res.end(JSON.stringify(body));
162
- };
163
- const readBody = (req) => new Promise((resolve, reject) => {
164
- if (!/^application\/json\b/.test(String(req.headers["content-type"] ?? ""))) return reject(/* @__PURE__ */ new Error("content-type must be application/json"));
165
- let body = "";
166
- let done = false;
167
- const settle = (fn) => {
168
- if (done) return;
169
- done = true;
170
- clearTimeout(timer);
171
- fn();
172
- };
173
- const timer = setTimeout(() => settle(() => {
174
- req.destroy();
175
- reject(/* @__PURE__ */ new Error("body timed out"));
176
- }), 1e4);
177
- if (typeof timer.unref === "function") timer.unref();
178
- req.on("data", (c) => {
179
- body += c;
180
- if (body.length > MAX_BODY) settle(() => {
181
- req.destroy();
182
- reject(/* @__PURE__ */ new Error("body too large"));
183
- });
184
- });
185
- req.on("end", () => settle(() => {
186
- try {
187
- resolve(body ? JSON.parse(body) : {});
188
- } catch {
189
- reject(/* @__PURE__ */ new Error("bad json"));
190
- }
191
- }));
192
- req.on("error", () => settle(() => reject(/* @__PURE__ */ new Error("request failed"))));
193
- });
194
- const setSession = (req, res, token) => {
195
- const secure = secureSuffix(req);
196
- res.setHeader("set-cookie", [`mv_s=${token}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`, `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${MONTH}; SameSite=Lax${secure}`]);
197
- };
198
- const csrfOk = (req) => !!/^Bearer /.test(String(req.headers.authorization ?? "")) || !cookie(req, "mv_s") || !!cookie(req, "mv_c") && req.headers["x-mv-c"] === cookie(req, "mv_c");
199
- /** Returns true when the request was handled. Mounted behind the gate by serve. */
200
- return async (req, res, url) => {
201
- if (!url.pathname.startsWith("/__mv/api/")) return false;
202
- const path = url.pathname.slice(10);
203
- const ip = process.env.MARVER_TRUSTED_PROXY ? String(req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "?").split(",")[0].trim() : String(req.socket.remoteAddress ?? "?");
204
- try {
205
- if (req.method === "GET") {
206
- if (path === "me") {
207
- const u = currentUser(req);
208
- return json(res, u ? 200 : 401, u ? {
209
- user: publicUser(u),
210
- role: u.role
211
- } : { error: "signed out" }), true;
212
- }
213
- if (path === "boards") {
214
- const name = ownerName(dataDir);
215
- return json(res, 200, {
216
- rights,
217
- stored: listBoards(commentsDir),
218
- ...name ? { owner: { name } } : {}
219
- }), true;
220
- }
221
- const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
222
- if (m) {
223
- if (!can(currentUser(req), rights, m[1], "read")) return json(res, 404, { error: "no such board" }), true;
224
- return json(res, 200, { events: readLog(commentsDir, m[1]) }), true;
225
- }
226
- if (path === "invite-info") {
227
- if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
228
- const info = inviteInfo(dataDir, String(url.searchParams.get("token") ?? ""));
229
- return json(res, info ? 200 : 404, info ?? { error: "this invite link is invalid, expired, or already used" }), true;
230
- }
231
- if (path === "events") {
232
- res.statusCode = 200;
233
- res.setHeader("content-type", "text/event-stream");
234
- res.setHeader("cache-control", "no-store");
235
- res.setHeader("x-accel-buffering", "no");
236
- res.write(": connected\n\n");
237
- const lastRaw = String(req.headers["last-event-id"] ?? "");
238
- const lm = /^([0-9a-f]{8})-(\d+)$/.exec(lastRaw);
239
- if (lastRaw) {
240
- const last = lm && lm[1] === bootEpoch ? Number(lm[2]) : NaN;
241
- if (!Number.isFinite(last)) res.write("event: resync\ndata: {}\n\n");
242
- else {
243
- const missed = ring.filter((r) => r.seq > last);
244
- if (missed.length && missed[0].seq !== last + 1) res.write("event: resync\ndata: {}\n\n");
245
- else for (const r of missed) res.write(r.data);
246
- }
247
- }
248
- clients.add(res);
249
- req.on("close", () => clients.delete(res));
250
- return true;
251
- }
252
- return false;
253
- }
254
- if (req.method !== "POST") return false;
255
- if (!(path === "auth/claim" || path === "auth/signin") && !csrfOk(req)) return json(res, 403, { error: "missing or stale request token - reload the page" }), true;
256
- if (path === "auth/claim") {
257
- if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
258
- const b = await readBody(req);
259
- const { user, session } = claimInvite(dataDir, String(b.token ?? ""), {
260
- password: String(b.password ?? ""),
261
- name: String(b.name ?? ""),
262
- avatar: validAvatar(b.avatar) ? b.avatar : void 0
263
- });
264
- setSession(req, res, session);
265
- return json(res, 200, { user: publicUser(user) }), true;
266
- }
267
- if (path === "auth/signin") {
268
- const b = await readBody(req);
269
- const email = String(b.email ?? "").trim().toLowerCase();
270
- if (limited(`ip:${ip}`, `em:${email}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
271
- const hit = signIn(dataDir, email, String(b.password ?? ""));
272
- if (!hit) return json(res, 401, { error: "wrong email or password" }), true;
273
- setSession(req, res, hit.session);
274
- return json(res, 200, { user: publicUser(hit.user) }), true;
275
- }
276
- if (path === "auth/signout") {
277
- const tok = cookie(req, "mv_s");
278
- if (tok) signOut(dataDir, tok);
279
- res.setHeader("set-cookie", ["mv_s=; Path=/; Max-Age=0; HttpOnly", "mv_c=; Path=/; Max-Age=0"]);
280
- return json(res, 200, {}), true;
281
- }
282
- if (path === "profile") {
283
- const u = currentUser(req);
284
- if (!u) return json(res, 401, { error: "sign in first" }), true;
285
- const b = await readBody(req);
286
- const next = updateProfile(dataDir, u.email, {
287
- name: typeof b.name === "string" ? b.name : void 0,
288
- avatar: b.avatar === "" ? "" : validAvatar(b.avatar) ? b.avatar : void 0
289
- });
290
- return json(res, 200, { user: publicUser(next) }), true;
291
- }
292
- if (path === "cli-session") {
293
- const bearer = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? "";
294
- if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
295
- const issued = issueDeviceSession(dataDir, bearer);
296
- if (!issued) return json(res, 401, { error: "MARVER_CLI_TOKEN was refused, or nobody owns this canvas yet" }), true;
297
- return json(res, 200, {
298
- token: issued.token,
299
- exp: issued.exp,
300
- user: publicUser(issued.user)
301
- }), true;
302
- }
303
- if (path === "invite") {
304
- if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
305
- const b = await readBody(req);
306
- const email = String(b.email ?? "");
307
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return json(res, 400, { error: "a valid email is required" }), true;
308
- const idMode = !!process.env.MARVER_ID_ISSUER;
309
- return json(res, 200, {
310
- ...createInvite(dataDir, email),
311
- idMode
312
- }), true;
313
- }
314
- if (path === "revoke") {
315
- if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
316
- const b = await readBody(req);
317
- revokeUser(dataDir, String(b.email ?? ""));
318
- return json(res, 200, {}), true;
319
- }
320
- const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
321
- if (m) {
322
- const u = currentUser(req);
323
- if (!can(u, rights, m[1], "comment")) return json(res, u ? 403 : 401, { error: u ? `this board is read-only - ask ${ownerName(dataDir) ?? "the canvas owner"} for commenting access` : "sign in to comment" }), true;
324
- const b = await readBody(req);
325
- const incoming = Array.isArray(b.events) ? b.events : [];
326
- if (incoming.length > 100) return json(res, 400, { error: "too many events in one push" }), true;
327
- const bad = validateEvents(incoming, readLog(commentsDir, m[1]), u, m[1]);
328
- if (bad) return json(res, 400, { error: bad }), true;
329
- const fresh = appendEvents(commentsDir, m[1], incoming);
330
- broadcast(m[1], fresh);
331
- return json(res, 200, { accepted: fresh.length }), true;
332
- }
333
- return false;
334
- } catch (err) {
335
- return json(res, 400, { error: err.message }), true;
336
- }
337
- };
338
- }
339
- //#endregion
340
- export { collabHandler, validAvatar };
@@ -1,233 +0,0 @@
1
- import { r as PKG, t as CONTENT_WIDTH } from "./cli.mjs";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
- import { join, relative, sep } from "node:path";
4
- import { createHash } from "node:crypto";
5
- //#region src/server/manifest.ts
6
- const FRAME_EXT = /\.(tsx|jsx|html)$/;
7
- const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
8
- /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
9
- function extractMeta(src) {
10
- const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
11
- if (!m) return {};
12
- const body = m[1];
13
- const pick = (key) => {
14
- const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
15
- return r ? r[2] : void 0;
16
- };
17
- const out = {};
18
- const title = pick("title");
19
- if (title) out.title = title;
20
- const viewport = pick("viewport");
21
- if (viewport) out.viewport = viewport;
22
- const theme = pick("theme");
23
- if (theme) out.theme = theme;
24
- const of = pick("of");
25
- if (of) out.of = of;
26
- const variant = pick("variant");
27
- if (variant) out.variant = variant;
28
- const intent = pick("intent");
29
- if (intent) out.intent = intent;
30
- return out;
31
- }
32
- /** Content-frame detection: LEXICAL by convention - the frame file
33
- * itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
34
- * string in a UI frame can never misbadge it; barrels/re-exports are not
35
- * detected - meta.intent is the taught path and always works). Returns the
36
- * inferred intent + natural width, or null for UI frames. */
37
- const CONTENT_IMPORT = new RegExp(`from\\s+['"]${PKG}/content['"]`);
38
- const WIDE_DOC = /<Doc\b[^>]*\blayout\s*=\s*["']wide["']/;
39
- const contentWidthOf = (src) => WIDE_DOC.test(src) ? CONTENT_WIDTH.wide : CONTENT_WIDTH.document;
40
- function contentScan(src) {
41
- if (!CONTENT_IMPORT.test(src)) return null;
42
- const count = (re) => (src.match(re) ?? []).length;
43
- const diagrams = count(/<Diagram[\s>/]/g);
44
- const imgs = count(/<Img[\s>/]/g);
45
- const mds = count(/<Md[\s>/]/g);
46
- return {
47
- intent: diagrams > 0 ? "diagram" : imgs > mds ? "moodboard" : "spec",
48
- width: contentWidthOf(src)
49
- };
50
- }
51
- /** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
52
- function toFrameId(designRelPath) {
53
- const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
54
- return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
55
- }
56
- /**
57
- * A7: which frames does a changed `design/**` file affect? Manifest + directory conventions,
58
- * NOT a module-graph walk (deterministic, cheap, may over-reload but never misses a
59
- * conventionally-affected frame). Returns:
60
- * null -> uncontrolled: leave to default Vite HMR (src/** deps, theme.css, config, assets)
61
- * [] -> controlled but affects no current frame (e.g. a layout in an empty dir)
62
- * [ids] -> controlled: the shell drives a rev-stamped reload of these frames
63
- */
64
- function affectedFrameIds(absFile, root, manifest) {
65
- const design = join(root, "design");
66
- if (absFile !== design && !absFile.startsWith(design + sep)) return null;
67
- const rel = relative(design, absFile).split(sep).join("/");
68
- const name = rel.split("/").pop() ?? rel;
69
- const designRel = `design/${rel}`;
70
- const tsxFrames = manifest.frames.filter((f) => f.kind === "tsx");
71
- if (name === "theme.css") return null;
72
- if (rel === "providers.tsx" || rel === "providers.jsx") return tsxFrames.map((f) => f.id);
73
- if (/(^|\/)(_layout\.(tsx|jsx)|_fixtures\.(ts|tsx|js|jsx|json))$/.test(rel)) {
74
- const prefix = `design/${rel.slice(0, rel.length - name.length)}`;
75
- return tsxFrames.filter((f) => f.file.startsWith(prefix)).map((f) => f.id);
76
- }
77
- const direct = manifest.frames.find((f) => f.file === designRel);
78
- if (direct) return [direct.id];
79
- return null;
80
- }
81
- function walk(dir, out = []) {
82
- if (!existsSync(dir)) return out;
83
- for (const e of readdirSync(dir, { withFileTypes: true })) {
84
- if (e.name.startsWith(".")) continue;
85
- const p = join(dir, e.name);
86
- if (e.isDirectory()) walk(p, out);
87
- else out.push(p);
88
- }
89
- return out;
90
- }
91
- function scanFrames(root) {
92
- const design = join(root, "design");
93
- const frames = [];
94
- for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
95
- const name = abs.split(sep).pop();
96
- if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
97
- const rel = relative(design, abs).split(sep).join("/");
98
- const id = toFrameId(rel);
99
- const scene = id.includes("/") ? id.split("/")[0] : "";
100
- if (base === "scenes" && RESERVED_SCENES.has(scene)) {
101
- console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
102
- continue;
103
- }
104
- const kind = name.endsWith(".html") ? "html" : "tsx";
105
- const entry = {
106
- id,
107
- file: `design/${rel}`,
108
- kind,
109
- scene
110
- };
111
- if (kind === "tsx") {
112
- const src = readFileSync(abs, "utf8");
113
- const meta = extractMeta(src);
114
- if (meta.title) entry.title = meta.title;
115
- if (meta.viewport) entry.viewport = meta.viewport;
116
- if (meta.theme) entry.theme = meta.theme;
117
- if (meta.of) entry.variantGroup = meta.of;
118
- if (meta.variant) entry.variant = meta.variant;
119
- const content = contentScan(src);
120
- if (content || meta.intent) {
121
- entry.intent = meta.intent ?? content.intent;
122
- entry.contentWidth = content?.width ?? contentWidthOf(src);
123
- }
124
- }
125
- frames.push(entry);
126
- }
127
- const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
128
- frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
129
- const seen = /* @__PURE__ */ new Map();
130
- const deduped = frames.filter((f) => {
131
- const prev = seen.get(f.id);
132
- if (prev) {
133
- console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
134
- return false;
135
- }
136
- seen.set(f.id, f.file);
137
- return true;
138
- });
139
- frames.length = 0;
140
- frames.push(...deduped);
141
- inferVariantGroups(frames);
142
- const sceneCounts = /* @__PURE__ */ new Map();
143
- for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
144
- return {
145
- frames,
146
- scenes: [...sceneCounts.entries()].map(([name, n]) => ({
147
- name,
148
- frames: n
149
- })).sort((a, b) => a.name.localeCompare(b.name))
150
- };
151
- }
152
- /** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
153
- * are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
154
- * directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
155
- * Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
156
- * States (empty.tsx, error.tsx) never letter-prefix, so they never misgroup.
157
- * Mutates entries in place: only frames whose group materializes keep the fields. */
158
- function inferVariantGroups(frames) {
159
- const dirOf = (id) => {
160
- const i = id.lastIndexOf("/");
161
- return i >= 0 ? id.slice(0, i) : "";
162
- };
163
- const candidates = /* @__PURE__ */ new Map();
164
- for (const f of frames) {
165
- if (f.kind !== "tsx") {
166
- delete f.variantGroup;
167
- delete f.variant;
168
- continue;
169
- }
170
- let group = f.variantGroup;
171
- let key = f.variant;
172
- if (!group) {
173
- const base = f.id.slice(f.id.lastIndexOf("/") + 1);
174
- const m = /^([a-z])-.+$/.exec(base);
175
- if (!m) continue;
176
- group = dirOf(f.id);
177
- if (!group) continue;
178
- key = key ?? m[1];
179
- } else if (!key) {
180
- const m = /^([a-z])-.+$/.exec(f.id.slice(f.id.lastIndexOf("/") + 1));
181
- if (!m) {
182
- console.warn(`[marver] ${f.file} declares of:"${group}" but no variant key (add meta.variant or a letter prefix) - not grouped.`);
183
- delete f.variantGroup;
184
- delete f.variant;
185
- continue;
186
- }
187
- key = m[1];
188
- }
189
- f.variantGroup = group;
190
- f.variant = key;
191
- candidates.set(group, [...candidates.get(group) ?? [], f]);
192
- }
193
- for (const [group, members] of candidates) {
194
- if (new Set(members.map((m) => dirOf(m.id))).size > 1) {
195
- console.warn(`[marver] group "${group}" spans directories - variants must be siblings; not grouped.`);
196
- for (const m of members) {
197
- delete m.variantGroup;
198
- delete m.variant;
199
- }
200
- continue;
201
- }
202
- const seen = /* @__PURE__ */ new Set();
203
- const kept = [];
204
- for (const m of members.sort((a, b) => (a.variant ?? "").localeCompare(b.variant ?? ""))) {
205
- if (seen.has(m.variant)) {
206
- console.warn(`[marver] duplicate variant "${m.variant}" in group "${group}" (${m.file}) - not grouped.`);
207
- delete m.variantGroup;
208
- delete m.variant;
209
- continue;
210
- }
211
- seen.add(m.variant);
212
- kept.push(m);
213
- }
214
- if (kept.length < 2) for (const m of kept) {
215
- delete m.variantGroup;
216
- delete m.variant;
217
- }
218
- }
219
- for (const f of frames) if (f.variant && !f.variantGroup) delete f.variant;
220
- }
221
- /** Write design/manifest.json only when content changed. Returns the manifest either way. */
222
- function writeManifest(root, manifest) {
223
- const file = join(root, "design", "manifest.json");
224
- const next = JSON.stringify(manifest, null, 2) + "\n";
225
- const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
226
- if (hash(prev) === hash(next)) return false;
227
- mkdirSync(join(root, "design"), { recursive: true });
228
- writeFileSync(file, next);
229
- return true;
230
- }
231
- const hash = (s) => createHash("sha256").update(s).digest("hex");
232
- //#endregion
233
- export { writeManifest as a, toFrameId as i, hash as n, scanFrames as r, affectedFrameIds as t };
@@ -1,77 +0,0 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { r as PKG } from "./cli.mjs";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- //#region src/server/update.ts
7
- /**
8
- * Update discovery - dev only, and deliberately boring about privacy: one anonymous
9
- * registry metadata GET per day (the same request `npm view` makes), cached in
10
- * design/.local/, nothing sent beyond the request itself. Offline, slow, or
11
- * firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
12
- * Published bundles never check anything - viewers are not the owner.
13
- */
14
- var update_exports = /* @__PURE__ */ __exportAll({
15
- checkUpdate: () => checkUpdate,
16
- installedVersion: () => installedVersion
17
- });
18
- const TTL = 864e5;
19
- /** Installed version: walk up from this module to the package's own package.json
20
- * (one level from dist/, two from src/server/ - the walk covers both). */
21
- function installedVersion() {
22
- let dir = dirname(fileURLToPath(import.meta.url));
23
- for (let i = 0; i < 4; i++) {
24
- try {
25
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
26
- if (pkg.name === "@marver-design/marver") return pkg.version;
27
- } catch {}
28
- dir = dirname(dir);
29
- }
30
- return null;
31
- }
32
- /** Strictly-newer numeric compare; anything non-numeric (prerelease tags) compares false. */
33
- const newer = (a, b) => {
34
- const pa = a.split(".").map((n) => parseInt(n, 10));
35
- const pb = b.split(".").map((n) => parseInt(n, 10));
36
- for (let i = 0; i < 3; i++) {
37
- const x = pa[i] ?? 0, y = pb[i] ?? 0;
38
- if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
39
- if (x !== y) return x > y;
40
- }
41
- return false;
42
- };
43
- /** The latest published version when strictly newer than installed, else null. Never throws. */
44
- async function checkUpdate(root) {
45
- if (process.env.MARVER_NO_UPDATE_CHECK) return null;
46
- const current = installedVersion();
47
- if (!current) return null;
48
- const latest = await latestVersion(root);
49
- return latest && newer(latest, current) ? latest : null;
50
- }
51
- async function latestVersion(root) {
52
- const cacheFile = join(root, "design", ".local", "update-check.json");
53
- const wellFormed = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v);
54
- try {
55
- const c = JSON.parse(readFileSync(cacheFile, "utf8"));
56
- const age = Date.now() - c.checkedAt;
57
- if (typeof c.checkedAt === "number" && age >= 0 && age < TTL) return wellFormed(c.latest) ? c.latest : null;
58
- } catch {}
59
- let latest = null;
60
- try {
61
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, { signal: AbortSignal.timeout(3e3) });
62
- if (res.ok) {
63
- const v = (await res.json())?.version;
64
- if (wellFormed(v)) latest = v;
65
- }
66
- } catch {}
67
- try {
68
- mkdirSync(join(root, "design", ".local"), { recursive: true });
69
- writeFileSync(cacheFile, JSON.stringify({
70
- checkedAt: Date.now(),
71
- latest
72
- }) + "\n");
73
- } catch {}
74
- return latest;
75
- }
76
- //#endregion
77
- export { installedVersion as n, update_exports as r, checkUpdate as t };
@@ -1,20 +0,0 @@
1
- //#region src/shared/utm.ts
2
- /** Automatic attribution for the powered-by links: every canvas tags its marver.design
3
- * link so site analytics can see WHICH canvas sent a visitor - zero user setup.
4
- * Conventional UTM semantics (stable groupings first, the variable last):
5
- * utm_source = the surface class ('published-canvas' | 'dev-canvas')
6
- * utm_medium = the link unit ('powered-by')
7
- * utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
8
- * utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page)
9
- */
10
- function poweredByUrl(canvasName, source, content) {
11
- const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
12
- return `https://marver.design/?${new URLSearchParams({
13
- utm_source: source,
14
- utm_medium: "powered-by",
15
- ...slug ? { utm_campaign: slug } : {},
16
- utm_content: content
17
- }).toString()}`;
18
- }
19
- //#endregion
20
- export { poweredByUrl as t };