@marver-design/marver 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +38 -0
- package/dist/build-D0GnIR4G.mjs +244 -0
- package/dist/cli.mjs +58 -0
- package/dist/config-DMBEpdEN.mjs +132 -0
- package/dist/dev-BK4x3PBr.mjs +76 -0
- package/dist/init-DcOy1krf.mjs +168 -0
- package/dist/plugin-DB5t2WUl.mjs +437 -0
- package/dist/serve-BPNmWeJx.mjs +200 -0
- package/package.json +59 -0
- package/src/client/const.ts +5 -0
- package/src/client/frame-host/bridge.js +37 -0
- package/src/client/frame-host/index.html +11 -0
- package/src/client/frame-host/main.tsx +73 -0
- package/src/client/frame-host/registry.ts +40 -0
- package/src/client/runtime/index.ts +8 -0
- package/src/client/shell/App.tsx +633 -0
- package/src/client/shell/Play.tsx +399 -0
- package/src/client/shell/Tip.tsx +43 -0
- package/src/client/shell/canvas/Canvas.tsx +285 -0
- package/src/client/shell/canvas/FrameNode.tsx +194 -0
- package/src/client/shell/favicon/android-chrome-192x192-interactive.png +0 -0
- package/src/client/shell/favicon/android-chrome-192x192.png +0 -0
- package/src/client/shell/favicon/android-chrome-512x512-interactive.png +0 -0
- package/src/client/shell/favicon/android-chrome-512x512.png +0 -0
- package/src/client/shell/favicon/apple-touch-icon-interactive.png +0 -0
- package/src/client/shell/favicon/apple-touch-icon.png +0 -0
- package/src/client/shell/favicon/favicon-16x16-interactive.png +0 -0
- package/src/client/shell/favicon/favicon-16x16.png +0 -0
- package/src/client/shell/favicon/favicon-32x32-interactive.png +0 -0
- package/src/client/shell/favicon/favicon-32x32.png +0 -0
- package/src/client/shell/favicon/favicon.ico +0 -0
- package/src/client/shell/favicon/site.webmanifest +11 -0
- package/src/client/shell/hash.ts +67 -0
- package/src/client/shell/icons.tsx +53 -0
- package/src/client/shell/index.html +17 -0
- package/src/client/shell/main.tsx +9 -0
- package/src/client/shell/store.ts +540 -0
- package/src/client/shell/styles.css +508 -0
- package/src/client/shell/tidy.ts +24 -0
- package/src/client/stage/index.html +11 -0
- package/src/client/stage/main.tsx +160 -0
- package/templates/AGENTS-embedded.md +76 -0
- package/templates/AGENTS-studio.md +76 -0
- package/templates/demo/_fixtures.ts +9 -0
- package/templates/demo/dark.tsx +17 -0
- package/templates/demo/form.tsx +51 -0
- package/templates/demo/welcome.tsx +21 -0
- package/templates/design-tsconfig.json +8 -0
- package/templates/root-layout.tsx +7 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { r as ROUTE } from "./cli.mjs";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { createHash, randomBytes } 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 (spec §6). */
|
|
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(`${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
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
|
|
27
|
+
function toFrameId(designRelPath) {
|
|
28
|
+
const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
|
|
29
|
+
return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
|
|
30
|
+
}
|
|
31
|
+
function walk(dir, out = []) {
|
|
32
|
+
if (!existsSync(dir)) return out;
|
|
33
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
if (e.name.startsWith(".")) continue;
|
|
35
|
+
const p = join(dir, e.name);
|
|
36
|
+
if (e.isDirectory()) walk(p, out);
|
|
37
|
+
else out.push(p);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function scanFrames(root) {
|
|
42
|
+
const design = join(root, "design");
|
|
43
|
+
const frames = [];
|
|
44
|
+
for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
|
|
45
|
+
const name = abs.split(sep).pop();
|
|
46
|
+
if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
|
|
47
|
+
const rel = relative(design, abs).split(sep).join("/");
|
|
48
|
+
const id = toFrameId(rel);
|
|
49
|
+
const scene = id.includes("/") ? id.split("/")[0] : "";
|
|
50
|
+
if (base === "scenes" && RESERVED_SCENES.has(scene)) {
|
|
51
|
+
console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const kind = name.endsWith(".html") ? "html" : "tsx";
|
|
55
|
+
const entry = {
|
|
56
|
+
id,
|
|
57
|
+
file: `design/${rel}`,
|
|
58
|
+
kind,
|
|
59
|
+
scene
|
|
60
|
+
};
|
|
61
|
+
if (kind === "tsx") {
|
|
62
|
+
const meta = extractMeta(readFileSync(abs, "utf8"));
|
|
63
|
+
if (meta.title) entry.title = meta.title;
|
|
64
|
+
if (meta.viewport) entry.viewport = meta.viewport;
|
|
65
|
+
if (meta.theme) entry.theme = meta.theme;
|
|
66
|
+
}
|
|
67
|
+
frames.push(entry);
|
|
68
|
+
}
|
|
69
|
+
const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
|
|
70
|
+
frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
|
|
71
|
+
const seen = /* @__PURE__ */ new Map();
|
|
72
|
+
const deduped = frames.filter((f) => {
|
|
73
|
+
const prev = seen.get(f.id);
|
|
74
|
+
if (prev) {
|
|
75
|
+
console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
seen.set(f.id, f.file);
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
frames.length = 0;
|
|
82
|
+
frames.push(...deduped);
|
|
83
|
+
const sceneCounts = /* @__PURE__ */ new Map();
|
|
84
|
+
for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
|
|
85
|
+
return {
|
|
86
|
+
frames,
|
|
87
|
+
scenes: [...sceneCounts.entries()].map(([name, n]) => ({
|
|
88
|
+
name,
|
|
89
|
+
frames: n
|
|
90
|
+
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Write design/manifest.json only when content changed. Returns the manifest either way. */
|
|
94
|
+
function writeManifest(root, manifest) {
|
|
95
|
+
const file = join(root, "design", "manifest.json");
|
|
96
|
+
const next = JSON.stringify(manifest, null, 2) + "\n";
|
|
97
|
+
const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
98
|
+
if (hash(prev) === hash(next)) return false;
|
|
99
|
+
mkdirSync(join(root, "design"), { recursive: true });
|
|
100
|
+
writeFileSync(file, next);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/server/api.ts
|
|
106
|
+
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
107
|
+
const BODY_LIMIT = 1e6;
|
|
108
|
+
function json(res, status, body) {
|
|
109
|
+
res.statusCode = status;
|
|
110
|
+
res.setHeader("content-type", "application/json");
|
|
111
|
+
res.end(JSON.stringify(body));
|
|
112
|
+
}
|
|
113
|
+
function readBody(req) {
|
|
114
|
+
return new Promise((done) => {
|
|
115
|
+
let size = 0;
|
|
116
|
+
const chunks = [];
|
|
117
|
+
req.on("data", (c) => {
|
|
118
|
+
size += c.length;
|
|
119
|
+
if (size > BODY_LIMIT) {
|
|
120
|
+
done(null);
|
|
121
|
+
req.destroy();
|
|
122
|
+
} else chunks.push(c);
|
|
123
|
+
});
|
|
124
|
+
req.on("end", () => done(Buffer.concat(chunks).toString("utf8")));
|
|
125
|
+
req.on("error", () => done(null));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** Atomic write: random temp name (no predictable symlink target), rename, and a
|
|
129
|
+
* copy-fallback only for the Windows rename errors - anything else rethrows. */
|
|
130
|
+
function atomicWrite(file, content) {
|
|
131
|
+
const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
132
|
+
writeFileSync(tmp, content, { flag: "wx" });
|
|
133
|
+
try {
|
|
134
|
+
renameSync(tmp, file);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const code = err.code;
|
|
137
|
+
if (code !== "EEXIST" && code !== "EPERM") {
|
|
138
|
+
rmSync(tmp, { force: true });
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
copyFileSync(tmp, file);
|
|
142
|
+
rmSync(tmp, { force: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Containment beyond string prefixes: the realpath of the parent dir must stay inside base. */
|
|
146
|
+
function contained(target, base) {
|
|
147
|
+
try {
|
|
148
|
+
const realBase = realpathSync(base);
|
|
149
|
+
const parent = realpathSync(resolve(target, ".."));
|
|
150
|
+
if (parent !== realBase && !parent.startsWith(realBase + sep)) return false;
|
|
151
|
+
if (existsSync(target)) {
|
|
152
|
+
const real = realpathSync(target);
|
|
153
|
+
return real === realBase || real.startsWith(realBase + sep);
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function apiMiddleware(root) {
|
|
161
|
+
const boardsDir = join(root, "design", "boards");
|
|
162
|
+
const boardPath = (name) => {
|
|
163
|
+
if (!BOARD_NAME.test(name)) return null;
|
|
164
|
+
mkdirSync(boardsDir, { recursive: true });
|
|
165
|
+
const p = resolve(boardsDir, `${name}.json`);
|
|
166
|
+
return contained(p, boardsDir) ? p : null;
|
|
167
|
+
};
|
|
168
|
+
try {
|
|
169
|
+
for (const f of readdirSync(boardsDir)) if (f.endsWith(".tmp")) rmSync(join(boardsDir, f), { force: true });
|
|
170
|
+
} catch {}
|
|
171
|
+
return async (req, res, next) => {
|
|
172
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
173
|
+
if (!url.pathname.startsWith(`/__mv/api/`)) return next();
|
|
174
|
+
const path = url.pathname.slice(`${ROUTE}/api/`.length);
|
|
175
|
+
try {
|
|
176
|
+
if (path === "boards" && req.method === "GET") {
|
|
177
|
+
mkdirSync(boardsDir, { recursive: true });
|
|
178
|
+
return json(res, 200, readdirSync(boardsDir).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).map((f) => {
|
|
179
|
+
const content = readFileSync(join(boardsDir, f), "utf8");
|
|
180
|
+
return {
|
|
181
|
+
name: f.replace(/\.json$/, ""),
|
|
182
|
+
sha256: hash(content)
|
|
183
|
+
};
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
const boardMatch = /^boards\/([^/]+)$/.exec(path);
|
|
187
|
+
if (boardMatch) {
|
|
188
|
+
const p = boardPath(boardMatch[1]);
|
|
189
|
+
if (!p) return json(res, 400, { error: "invalid board name" });
|
|
190
|
+
if (req.method === "GET") {
|
|
191
|
+
if (!existsSync(p)) return json(res, 404, { error: "not found" });
|
|
192
|
+
const content = readFileSync(p, "utf8");
|
|
193
|
+
try {
|
|
194
|
+
return json(res, 200, {
|
|
195
|
+
board: JSON.parse(content),
|
|
196
|
+
sha256: hash(content)
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
return json(res, 422, { error: `board "${boardMatch[1]}" is not valid JSON - fix the file` });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (req.method === "PUT") {
|
|
203
|
+
const raw = await readBody(req);
|
|
204
|
+
if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
|
|
205
|
+
let body;
|
|
206
|
+
try {
|
|
207
|
+
body = JSON.parse(raw);
|
|
208
|
+
} catch {
|
|
209
|
+
return json(res, 400, { error: "malformed JSON" });
|
|
210
|
+
}
|
|
211
|
+
const current = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
212
|
+
if (current && body.baseHash !== hash(current)) {
|
|
213
|
+
let disk = null;
|
|
214
|
+
try {
|
|
215
|
+
disk = JSON.parse(current);
|
|
216
|
+
} catch {}
|
|
217
|
+
return json(res, 409, {
|
|
218
|
+
error: "board changed on disk",
|
|
219
|
+
board: disk,
|
|
220
|
+
sha256: hash(current)
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
mkdirSync(boardsDir, { recursive: true });
|
|
224
|
+
const next2 = JSON.stringify(body.board, null, 2) + "\n";
|
|
225
|
+
atomicWrite(p, next2);
|
|
226
|
+
return json(res, 200, { sha256: hash(next2) });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
json(res, 404, { error: "unknown endpoint" });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
json(res, 500, { error: err.message });
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/server/routes.ts
|
|
237
|
+
/**
|
|
238
|
+
* Serves the package's own pages. transformIndexHtml does not map URLs - we do (spec §3).
|
|
239
|
+
* Entry html files carry an {{ENTRY}} placeholder replaced with an /@fs/ absolute path,
|
|
240
|
+
* because a relative ./main.tsx would resolve against the URL, not the package dir.
|
|
241
|
+
*/
|
|
242
|
+
function routesMiddleware(server, clientDir) {
|
|
243
|
+
const page = (dir) => {
|
|
244
|
+
const html = readFileSync(join(clientDir, dir, "index.html"), "utf8");
|
|
245
|
+
const entry = "/@fs/" + join(clientDir, dir, "main.tsx").split("\\").join("/");
|
|
246
|
+
return html.replaceAll("{{ENTRY}}", entry).replaceAll("{{ROUTE}}", ROUTE);
|
|
247
|
+
};
|
|
248
|
+
const ICON_TYPES = {
|
|
249
|
+
png: "image/png",
|
|
250
|
+
ico: "image/x-icon",
|
|
251
|
+
webmanifest: "application/manifest+json"
|
|
252
|
+
};
|
|
253
|
+
return async (req, res, next) => {
|
|
254
|
+
const path = new URL(req.url ?? "/", "http://x").pathname;
|
|
255
|
+
let dir = null;
|
|
256
|
+
if (path === "/" || path === "/index.html") dir = "shell";
|
|
257
|
+
else if (path === `/__mv/frame/` || path === `/__mv/frame/index.html`) dir = "frame-host";
|
|
258
|
+
else if (path === `/__mv/stage/` || path === `/__mv/stage/index.html`) dir = "stage";
|
|
259
|
+
else if (path.startsWith(`/__mv/favicon/`)) {
|
|
260
|
+
const name = path.slice(`${ROUTE}/favicon/`.length);
|
|
261
|
+
const type = ICON_TYPES[name.split(".").pop() ?? ""];
|
|
262
|
+
if (!/^[\w@-]+(\.[\w]+)+$/.test(name) || !type) return next();
|
|
263
|
+
try {
|
|
264
|
+
res.setHeader("content-type", type);
|
|
265
|
+
res.setHeader("cache-control", "public, max-age=3600");
|
|
266
|
+
return res.end(readFileSync(join(clientDir, "shell", "favicon", name)));
|
|
267
|
+
} catch {
|
|
268
|
+
return next();
|
|
269
|
+
}
|
|
270
|
+
} else if (path === `/__mv/bridge.js`) {
|
|
271
|
+
res.setHeader("content-type", "text/javascript");
|
|
272
|
+
return res.end(readFileSync(join(clientDir, "frame-host", "bridge.js"), "utf8"));
|
|
273
|
+
}
|
|
274
|
+
if (!dir) return next();
|
|
275
|
+
try {
|
|
276
|
+
const html = await server.transformIndexHtml(req.url ?? "/", page(dir));
|
|
277
|
+
res.setHeader("content-type", "text/html");
|
|
278
|
+
res.end(html);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
res.statusCode = 500;
|
|
281
|
+
res.end(`marver route error: ${err.message}`);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/server/plugin.ts
|
|
287
|
+
const VIRTUAL_THEME = "virtual:sh-theme";
|
|
288
|
+
const VIRTUAL_CONFIG = "virtual:sh-config";
|
|
289
|
+
const VIRTUAL_DATA = "virtual:sh-data";
|
|
290
|
+
function marverPlugin(ctx) {
|
|
291
|
+
const { root, clientDir, config } = ctx;
|
|
292
|
+
/** Theme resolution: design/theme.css wrapper > configured > detected > empty (spec §5.4). */
|
|
293
|
+
const themeFile = () => {
|
|
294
|
+
const wrapper = join(root, "design", "theme.css");
|
|
295
|
+
if (existsSync(wrapper)) return wrapper;
|
|
296
|
+
if (config.theme && existsSync(join(root, config.theme))) return join(root, config.theme);
|
|
297
|
+
if (ctx.detectedThemeCss && existsSync(join(root, ctx.detectedThemeCss))) return join(root, ctx.detectedThemeCss);
|
|
298
|
+
return null;
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
name: "marver",
|
|
302
|
+
resolveId(id) {
|
|
303
|
+
if (id === VIRTUAL_THEME) return themeFile() ?? "\0virtual:sh-theme.css";
|
|
304
|
+
if (id === VIRTUAL_CONFIG) return "\0virtual:sh-config";
|
|
305
|
+
if (id === VIRTUAL_DATA) return "\0virtual:sh-data";
|
|
306
|
+
},
|
|
307
|
+
load(id) {
|
|
308
|
+
if (id === "\0virtual:sh-theme.css") {
|
|
309
|
+
console.warn("[marver] no theme detected - frames render unstyled. Set `theme` in design/config.ts.");
|
|
310
|
+
return "/* marver: no theme configured */";
|
|
311
|
+
}
|
|
312
|
+
if (id === "\0virtual:sh-config") return `export default ${JSON.stringify({
|
|
313
|
+
viewports: config.viewports,
|
|
314
|
+
themes: config.themes,
|
|
315
|
+
zoomSpeed: config.zoomSpeed,
|
|
316
|
+
noTheme: themeFile() == null
|
|
317
|
+
})}`;
|
|
318
|
+
if (id === "\0virtual:sh-data") return "export default null";
|
|
319
|
+
},
|
|
320
|
+
/** HTML frames: inject theme + bridge into any design/**.html Vite serves.
|
|
321
|
+
* Inline scripts go through vite's html-proxy import-analysis: bare specifiers resolve
|
|
322
|
+
* through plugins (so `virtual:sh-theme` works); `/@id/` URLs are rejected there. */
|
|
323
|
+
transformIndexHtml: {
|
|
324
|
+
order: "pre",
|
|
325
|
+
handler(html, hctx) {
|
|
326
|
+
if (!(hctx.originalUrl ?? hctx.path ?? "").split("?")[0].startsWith("/design/")) return;
|
|
327
|
+
const bridge = "/@fs/" + join(clientDir, "frame-host", "bridge.js").split("\\").join("/") + "?html=1";
|
|
328
|
+
return {
|
|
329
|
+
html,
|
|
330
|
+
tags: [{
|
|
331
|
+
tag: "script",
|
|
332
|
+
attrs: { type: "module" },
|
|
333
|
+
children: `import '${VIRTUAL_THEME}'`,
|
|
334
|
+
injectTo: "head-prepend"
|
|
335
|
+
}, {
|
|
336
|
+
tag: "script",
|
|
337
|
+
attrs: {
|
|
338
|
+
type: "module",
|
|
339
|
+
src: bridge
|
|
340
|
+
},
|
|
341
|
+
injectTo: "head-prepend"
|
|
342
|
+
}]
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
/**
|
|
347
|
+
* Frame add/unlink is OUR event (sh:manifest → shell appends/flags nodes); Vite's default for
|
|
348
|
+
* an out-of-graph file is a full-reload broadcast to every client, shell included. Suppress it.
|
|
349
|
+
* Edits to loaded frames keep normal HMR (modules present + file exists → pass through).
|
|
350
|
+
*/
|
|
351
|
+
handleHotUpdate(hctx) {
|
|
352
|
+
if (![join(root, "design", "scenes"), join(root, "design", "components")].some((w) => hctx.file.startsWith(w))) return;
|
|
353
|
+
if (!existsSync(hctx.file)) return [];
|
|
354
|
+
if (hctx.modules.length === 0) return [];
|
|
355
|
+
},
|
|
356
|
+
configureServer(server) {
|
|
357
|
+
server.middlewares.use(apiMiddleware(root));
|
|
358
|
+
server.middlewares.use(routesMiddleware(server, clientDir));
|
|
359
|
+
const regen = debounce(() => {
|
|
360
|
+
const manifest = scanFrames(root);
|
|
361
|
+
if (writeManifest(root, manifest)) server.ws.send("sh:manifest", manifest);
|
|
362
|
+
}, 150);
|
|
363
|
+
const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
|
|
364
|
+
const inScope = (file) => watched.some((w) => file.startsWith(w));
|
|
365
|
+
server.watcher.on("add", (f) => inScope(f) && regen());
|
|
366
|
+
server.watcher.on("unlink", (f) => inScope(f) && regen());
|
|
367
|
+
server.watcher.on("change", (f) => inScope(f) && /\.(tsx|jsx)$/.test(f) && regen());
|
|
368
|
+
writeManifest(root, scanFrames(root));
|
|
369
|
+
const boardsDir = join(root, "design", "boards");
|
|
370
|
+
try {
|
|
371
|
+
mkdirSync(boardsDir, { recursive: true });
|
|
372
|
+
const timers = /* @__PURE__ */ new Map();
|
|
373
|
+
const watcher = watch(boardsDir, (_event, file) => {
|
|
374
|
+
if (!file || !file.endsWith(".json")) return;
|
|
375
|
+
clearTimeout(timers.get(file));
|
|
376
|
+
timers.set(file, setTimeout(() => {
|
|
377
|
+
timers.delete(file);
|
|
378
|
+
try {
|
|
379
|
+
const content = readFileSync(join(boardsDir, file), "utf8");
|
|
380
|
+
JSON.parse(content);
|
|
381
|
+
server.ws.send("sh:board", {
|
|
382
|
+
name: file.replace(/\.json$/, ""),
|
|
383
|
+
sha256: hash(content)
|
|
384
|
+
});
|
|
385
|
+
} catch {}
|
|
386
|
+
}, 120));
|
|
387
|
+
});
|
|
388
|
+
server.httpServer?.once("close", () => watcher.close());
|
|
389
|
+
} catch {}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function debounce(fn, ms) {
|
|
394
|
+
let t;
|
|
395
|
+
return ((...a) => {
|
|
396
|
+
clearTimeout(t);
|
|
397
|
+
t = setTimeout(() => fn(...a), ms);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
/** Extra vite plugin when the host runs Tailwind v4: use their own @tailwindcss/vite. */
|
|
401
|
+
async function tailwind4Plugin(root) {
|
|
402
|
+
try {
|
|
403
|
+
const mod = await import(join(root, "node_modules", "@tailwindcss", "vite", "dist", "index.mjs"));
|
|
404
|
+
const result = (mod.default ?? mod)();
|
|
405
|
+
return Array.isArray(result) ? result : [result];
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Tailwind v3: inline PostCSS config extending the host's tailwind config with design/ globs. Host files untouched. */
|
|
411
|
+
async function tailwind3Css(root) {
|
|
412
|
+
for (const name of [
|
|
413
|
+
"tailwind.config.ts",
|
|
414
|
+
"tailwind.config.js",
|
|
415
|
+
"tailwind.config.mjs",
|
|
416
|
+
"tailwind.config.cjs"
|
|
417
|
+
]) {
|
|
418
|
+
const file = join(root, name);
|
|
419
|
+
if (!existsSync(file)) continue;
|
|
420
|
+
try {
|
|
421
|
+
const { pathToFileURL } = await import("node:url");
|
|
422
|
+
const twConfig = (await import(pathToFileURL(file).href)).default;
|
|
423
|
+
const tailwindcss = (await import(join(root, "node_modules", "tailwindcss", "lib", "index.js").replace(/\\/g, "/"))).default;
|
|
424
|
+
const content = Array.isArray(twConfig.content) ? twConfig.content : twConfig.content?.files ?? [];
|
|
425
|
+
return { postcss: { plugins: [tailwindcss({
|
|
426
|
+
...twConfig,
|
|
427
|
+
content: [...content, "./design/**/*.{ts,tsx,html}"]
|
|
428
|
+
})] } };
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.warn(`[marver] could not extend Tailwind v3 config (${err.message}). Add './design/**/*.{ts,tsx,html}' to its content globs manually.`);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
//#endregion
|
|
437
|
+
export { scanFrames as i, tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { t as NAME } from "./cli.mjs";
|
|
2
|
+
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
//#region src/server/serve.ts
|
|
7
|
+
/**
|
|
8
|
+
* `marver serve` - a zero-dependency static server for design/.dist with an optional
|
|
9
|
+
* password gate (SPEC-M2 §4b). No MARVER_PASSWORD → plain static serving. With it, the
|
|
10
|
+
* bundle is never sent pre-auth: every unauthenticated request gets the gate page, auth
|
|
11
|
+
* is a POST compare + an HMAC-signed 30-day cookie keyed off the password itself (all
|
|
12
|
+
* instances agree, nothing stored server-side).
|
|
13
|
+
*/
|
|
14
|
+
const MIME = {
|
|
15
|
+
".html": "text/html; charset=utf-8",
|
|
16
|
+
".js": "text/javascript",
|
|
17
|
+
".mjs": "text/javascript",
|
|
18
|
+
".css": "text/css",
|
|
19
|
+
".json": "application/json",
|
|
20
|
+
".svg": "image/svg+xml",
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".ico": "image/x-icon",
|
|
23
|
+
".webmanifest": "application/manifest+json",
|
|
24
|
+
".woff": "font/woff",
|
|
25
|
+
".woff2": "font/woff2",
|
|
26
|
+
".map": "application/json",
|
|
27
|
+
".txt": "text/plain"
|
|
28
|
+
};
|
|
29
|
+
const COOKIE = "mv_a";
|
|
30
|
+
const MONTH = 2592e3;
|
|
31
|
+
function serve(root, portFlag) {
|
|
32
|
+
const dist = join(root, "design", ".dist");
|
|
33
|
+
if (!existsSync(join(dist, "index.html"))) {
|
|
34
|
+
console.error(`[${NAME}] design/.dist not found - run \`npx ${NAME} build\` first.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const realDist = realpathSync(dist);
|
|
38
|
+
let meta = {
|
|
39
|
+
name: "Marver",
|
|
40
|
+
branding: true
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
meta = {
|
|
44
|
+
...meta,
|
|
45
|
+
...JSON.parse(readFileSync(join(dist, "meta.json"), "utf8"))
|
|
46
|
+
};
|
|
47
|
+
} catch {}
|
|
48
|
+
const password = process.env.MARVER_PASSWORD ?? "";
|
|
49
|
+
const verifier = password ? scryptSync(password, "marver-gate", 32) : null;
|
|
50
|
+
const cookieKey = randomBytes(32);
|
|
51
|
+
const sign = (exp) => createHmac("sha256", cookieKey).update(`v1.${exp}`).digest("hex");
|
|
52
|
+
const authed = (req) => {
|
|
53
|
+
const m = /(?:^|;\s*)mv_a=(\d+)\.([0-9a-f]+)/.exec(String(req.headers.cookie ?? ""));
|
|
54
|
+
if (!m) return false;
|
|
55
|
+
const exp = Number(m[1]);
|
|
56
|
+
if (!Number.isFinite(exp) || exp * 1e3 < Date.now()) return false;
|
|
57
|
+
const want = Buffer.from(sign(exp)), got = Buffer.from(m[2]);
|
|
58
|
+
return want.length === got.length && timingSafeEqual(want, got);
|
|
59
|
+
};
|
|
60
|
+
const server = createServer((req, res) => {
|
|
61
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
62
|
+
if (verifier) {
|
|
63
|
+
if (req.method === "POST" && url.pathname === "/__mv/auth") {
|
|
64
|
+
let body = "";
|
|
65
|
+
req.on("data", (c) => {
|
|
66
|
+
body += c;
|
|
67
|
+
if (body.length > 1e4) req.destroy();
|
|
68
|
+
});
|
|
69
|
+
req.on("end", () => {
|
|
70
|
+
const form = new URLSearchParams(body);
|
|
71
|
+
const given = form.get("password") ?? "";
|
|
72
|
+
if (timingSafeEqual(scryptSync(given, "marver-gate", 32), verifier)) {
|
|
73
|
+
const exp = Math.floor(Date.now() / 1e3) + MONTH;
|
|
74
|
+
const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
|
|
75
|
+
res.setHeader("set-cookie", `${COOKIE}=${exp}.${sign(exp)}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`);
|
|
76
|
+
const next = form.get("next") ?? "";
|
|
77
|
+
res.statusCode = 303;
|
|
78
|
+
res.setHeader("location", /^#\/[\w\/?&=%.,~-]*$/.test(next) ? `/${next}` : "/");
|
|
79
|
+
return res.end();
|
|
80
|
+
}
|
|
81
|
+
return gate(res, meta, "Wrong password - try again");
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const cosmetic = url.pathname.startsWith("/__mv/favicon/") || /^\/__mv\/logo\.(svg|png)$/.test(url.pathname);
|
|
86
|
+
if (!authed(req) && !cosmetic) return gate(res, meta);
|
|
87
|
+
}
|
|
88
|
+
let path;
|
|
89
|
+
try {
|
|
90
|
+
path = decodeURIComponent(url.pathname);
|
|
91
|
+
} catch {
|
|
92
|
+
res.statusCode = 400;
|
|
93
|
+
return res.end("bad request");
|
|
94
|
+
}
|
|
95
|
+
if (path.endsWith("/")) path += "index.html";
|
|
96
|
+
let file = resolve(dist, path.slice(1));
|
|
97
|
+
try {
|
|
98
|
+
const real = realpathSync(file);
|
|
99
|
+
const rel = relative(realDist, real);
|
|
100
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("outside dist");
|
|
101
|
+
file = real;
|
|
102
|
+
} catch {
|
|
103
|
+
file = join(dist, "index.html");
|
|
104
|
+
}
|
|
105
|
+
if (!extname(file)) file = join(dist, "index.html");
|
|
106
|
+
try {
|
|
107
|
+
const content = readFileSync(file);
|
|
108
|
+
res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
|
|
109
|
+
const cache = verifier ? "private, no-store" : file.startsWith(join(dist, "assets")) ? "public, max-age=31536000, immutable" : "no-store";
|
|
110
|
+
res.setHeader("cache-control", cache);
|
|
111
|
+
res.end(content);
|
|
112
|
+
} catch {
|
|
113
|
+
res.statusCode = 404;
|
|
114
|
+
res.end("not found");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const port = portFlag ?? (Number(process.env.PORT) || 4199);
|
|
118
|
+
server.listen(port, () => {
|
|
119
|
+
console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
|
|
120
|
+
console.log(verifier ? " gate: ON (MARVER_PASSWORD set)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
|
|
121
|
+
});
|
|
122
|
+
return server;
|
|
123
|
+
}
|
|
124
|
+
/** The Marver logo mark (ParallelogramDuo, same as the shell's sidebar). */
|
|
125
|
+
const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${size}" fill="currentColor" aria-hidden><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1"/><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z"/></svg>`;
|
|
126
|
+
const MARK = MARK_AT(17);
|
|
127
|
+
const MARK_LG = MARK_AT(24);
|
|
128
|
+
/** The gate: the canvas's own antechamber - light ground, dot grid, glass card in the
|
|
129
|
+
* shell's exact token language. The visitor is one password away from stepping in. */
|
|
130
|
+
function gate(res, meta, error) {
|
|
131
|
+
const name = meta.name ? meta.name[0].toUpperCase() + meta.name.slice(1) : "Marver";
|
|
132
|
+
const appMark = meta.logo ? `<img src="${esc(meta.logo)}" alt="" width="24" height="24" />` : MARK_LG;
|
|
133
|
+
const title = meta.branding ? `${name} | Marver - Visualize your software` : name;
|
|
134
|
+
const desc = meta.branding ? `${name}, shared as a live Marver canvas - real screens and prototypes, built from the codebase. Marver is the agent-native design canvas.` : `${name} - a private design canvas.`;
|
|
135
|
+
res.statusCode = 200;
|
|
136
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
137
|
+
res.setHeader("cache-control", "no-store");
|
|
138
|
+
res.end(`<!doctype html>
|
|
139
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
140
|
+
<title>${esc(title)}</title>
|
|
141
|
+
<meta name="description" content="${esc(desc)}" />
|
|
142
|
+
<meta name="robots" content="noindex" />
|
|
143
|
+
<meta name="theme-color" content="#e7e9ef" />
|
|
144
|
+
<meta property="og:title" content="${esc(title)}" />
|
|
145
|
+
<meta property="og:description" content="${esc(desc)}" />
|
|
146
|
+
${meta.branding ? "<meta property=\"og:site_name\" content=\"Marver\" />" : ""}
|
|
147
|
+
<meta property="og:type" content="website" />
|
|
148
|
+
<meta name="twitter:card" content="summary" />
|
|
149
|
+
<link rel="icon" href="/__mv/favicon/favicon.ico" sizes="48x48" />
|
|
150
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/__mv/favicon/favicon-32x32.png" />
|
|
151
|
+
<link rel="icon" type="image/png" sizes="16x16" href="/__mv/favicon/favicon-16x16.png" />
|
|
152
|
+
<link rel="apple-touch-icon" href="/__mv/favicon/apple-touch-icon.png" />
|
|
153
|
+
<style>
|
|
154
|
+
* { box-sizing: border-box; margin: 0 }
|
|
155
|
+
body { min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
156
|
+
background-color: #e7e9ef;
|
|
157
|
+
background-image: radial-gradient(#c9cbd5 1px, transparent 1px); background-size: 20px 20px;
|
|
158
|
+
font: 500 14px -apple-system, system-ui, sans-serif; color: #18181b;
|
|
159
|
+
-webkit-font-smoothing: antialiased }
|
|
160
|
+
main { display: flex; flex-direction: column; align-items: center; gap: 22px; padding: 16px }
|
|
161
|
+
form { width: 340px; padding: 24px; border-radius: 24px; display: flex; flex-direction: column; gap: 14px;
|
|
162
|
+
background: rgba(255, 255, 255, .64); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
|
163
|
+
border: 1px solid rgba(24, 24, 27, .1);
|
|
164
|
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .65), 0 1px 2px rgba(24, 24, 27, .05), 0 4px 12px -6px rgba(24, 24, 27, .12) }
|
|
165
|
+
header { display: flex; align-items: center; gap: 10px; padding: 4px 2px 4px 4px }
|
|
166
|
+
header svg, header img { color: #0088ff; flex: none; border-radius: 6px }
|
|
167
|
+
h1 { font-size: 17px; font-weight: 600; letter-spacing: -.01em }
|
|
168
|
+
p { font-size: 12.5px; line-height: 1.5; color: rgba(24, 24, 27, .66); padding: 0 4px }
|
|
169
|
+
input { height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid rgba(24, 24, 27, .14);
|
|
170
|
+
background: #fff; color: #18181b; font: inherit; outline: none; transition: border-color .15s, box-shadow .15s }
|
|
171
|
+
input::placeholder { color: rgba(24, 24, 27, .35) }
|
|
172
|
+
input:focus { border-color: #0088ff; box-shadow: 0 0 0 3px rgba(0, 136, 255, .18) }
|
|
173
|
+
button { height: 40px; border: 0; border-radius: 999px; background: #18181b; color: #fafafa;
|
|
174
|
+
font: 600 13px -apple-system, system-ui, sans-serif; cursor: pointer; transition: background .15s }
|
|
175
|
+
button:hover { background: #000 }
|
|
176
|
+
.err { font-size: 12px; color: #b42318; padding: 0 4px }
|
|
177
|
+
footer a { display: inline-flex; align-items: center; gap: 7px; font: 600 12.5px -apple-system, system-ui, sans-serif;
|
|
178
|
+
color: rgba(24, 24, 27, .55); text-decoration: none }
|
|
179
|
+
footer a > svg:first-of-type { width: 20px; height: 20px; color: #0088ff }
|
|
180
|
+
footer .md, footer .up { transition: color .15s, opacity .15s }
|
|
181
|
+
footer a:hover .md { color: #0088ff; text-decoration: underline; text-underline-offset: 3px }
|
|
182
|
+
footer .up { opacity: .45; margin-left: -3px }
|
|
183
|
+
footer a:hover .up { opacity: 1; color: #0088ff }
|
|
184
|
+
</style></head>
|
|
185
|
+
<body><main>
|
|
186
|
+
<form method="post" action="/__mv/auth">
|
|
187
|
+
<header>${appMark}<h1>${esc(name)}</h1></header>
|
|
188
|
+
<p>You're one step from the canvas. This space is private - enter the password to step inside.</p>
|
|
189
|
+
${error ? `<div class="err">${esc(error)}</div>` : ""}
|
|
190
|
+
<input type="password" name="password" placeholder="Password" autofocus autocomplete="current-password" />
|
|
191
|
+
<input type="hidden" name="next" />
|
|
192
|
+
<button type="submit">Open the canvas</button>
|
|
193
|
+
</form>
|
|
194
|
+
<script>document.querySelector('[name=next]').value = location.hash<\/script>
|
|
195
|
+
${meta.branding ? `<footer><a href="https://marver.design" 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>` : ""}
|
|
196
|
+
</main></body></html>`);
|
|
197
|
+
}
|
|
198
|
+
const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
199
|
+
//#endregion
|
|
200
|
+
export { serve };
|