@marver-design/marver 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/{build-D0GnIR4G.mjs → build-CNoXE13J.mjs} +3 -3
- package/dist/cli.mjs +23 -7
- package/dist/{dev-BK4x3PBr.mjs → dev-Blyy4jOL.mjs} +24 -5
- package/dist/init-3h9pXEzp.mjs +337 -0
- package/dist/manifest-CHmKAAtG.mjs +298 -0
- package/dist/{plugin-DB5t2WUl.mjs → plugin-DiDJA9n-.mjs} +157 -120
- package/dist/{serve-BPNmWeJx.mjs → serve-BvbAbWeK.mjs} +8 -1
- package/package.json +4 -3
- package/src/client/frame-host/bridge.js +8 -2
- package/src/client/frame-host/main.tsx +7 -1
- package/src/client/shell/App.tsx +69 -8
- package/src/client/shell/canvas/FrameNode.tsx +9 -0
- package/src/client/shell/store.ts +58 -8
- package/src/client/shell/styles.css +21 -2
- package/templates/AGENTS-embedded.md +38 -26
- package/templates/AGENTS-studio.md +38 -26
- package/templates/instructions/boards.md +38 -0
- package/templates/instructions/brand.md +60 -0
- package/templates/instructions/components.md +52 -0
- package/templates/instructions/configure.md +44 -0
- package/templates/instructions/craft.md +90 -0
- package/templates/instructions/discover.md +52 -0
- package/templates/instructions/reference/color.md +53 -0
- package/templates/instructions/reference/concepts.md +68 -0
- package/templates/instructions/reference/copy.md +57 -0
- package/templates/instructions/reference/critique.md +53 -0
- package/templates/instructions/reference/delight.md +35 -0
- package/templates/instructions/reference/layout.md +51 -0
- package/templates/instructions/reference/motion.md +66 -0
- package/templates/instructions/reference/operate.md +38 -0
- package/templates/instructions/reference/slop.md +76 -0
- package/templates/instructions/reference/states.md +48 -0
- package/templates/instructions/reference/tune.md +61 -0
- package/templates/instructions/reference/typography.md +45 -0
- package/templates/instructions/review.md +51 -0
- package/templates/instructions/wireframe.md +49 -0
- package/dist/config-DMBEpdEN.mjs +0 -132
- package/dist/init-DcOy1krf.mjs +0 -168
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/server/detect.ts
|
|
6
|
+
function readJson(file) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(stripJsonComments(readFileSync(file, "utf8")));
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function stripJsonComments(s) {
|
|
14
|
+
let out = "", inStr = false, inLine = false, inBlock = false;
|
|
15
|
+
for (let i = 0; i < s.length; i++) {
|
|
16
|
+
const c = s[i], n = s[i + 1];
|
|
17
|
+
if (inLine) {
|
|
18
|
+
if (c === "\n") {
|
|
19
|
+
inLine = false;
|
|
20
|
+
out += c;
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (inBlock) {
|
|
25
|
+
if (c === "*" && n === "/") {
|
|
26
|
+
inBlock = false;
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (inStr) {
|
|
32
|
+
out += c;
|
|
33
|
+
if (c === "\\") {
|
|
34
|
+
out += n ?? "";
|
|
35
|
+
i++;
|
|
36
|
+
} else if (c === "\"") inStr = false;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (c === "\"") {
|
|
40
|
+
inStr = true;
|
|
41
|
+
out += c;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === "/" && n === "/") {
|
|
45
|
+
inLine = true;
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === "/" && n === "*") {
|
|
50
|
+
inBlock = true;
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out += c;
|
|
55
|
+
}
|
|
56
|
+
let res = "", inS = false;
|
|
57
|
+
for (let i = 0; i < out.length; i++) {
|
|
58
|
+
const c = out[i];
|
|
59
|
+
if (inS) {
|
|
60
|
+
res += c;
|
|
61
|
+
if (c === "\\") {
|
|
62
|
+
res += out[i + 1] ?? "";
|
|
63
|
+
i++;
|
|
64
|
+
} else if (c === "\"") inS = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (c === "\"") {
|
|
68
|
+
inS = true;
|
|
69
|
+
res += c;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (c === ",") {
|
|
73
|
+
let j = i + 1;
|
|
74
|
+
while (j < out.length && /\s/.test(out[j])) j++;
|
|
75
|
+
if (out[j] === "}" || out[j] === "]") continue;
|
|
76
|
+
}
|
|
77
|
+
res += c;
|
|
78
|
+
}
|
|
79
|
+
return res;
|
|
80
|
+
}
|
|
81
|
+
function detectHost(root) {
|
|
82
|
+
const pkg = readJson(join(root, "package.json")) ?? {};
|
|
83
|
+
const deps = {
|
|
84
|
+
...pkg.dependencies,
|
|
85
|
+
...pkg.devDependencies
|
|
86
|
+
};
|
|
87
|
+
const twRange = deps["tailwindcss"];
|
|
88
|
+
const tailwind = !twRange ? null : /(^|[^\d])4\./.test(twRange) || twRange.startsWith("^4") || twRange.startsWith("~4") ? 4 : 3;
|
|
89
|
+
const router = deps["next"] ? "next" : deps["react-router"] || deps["react-router-dom"] ? "react-router" : null;
|
|
90
|
+
const routerPkg = deps["react-router-dom"] ? "react-router-dom" : "react-router";
|
|
91
|
+
const toaster = deps["sonner"] ? "sonner" : deps["react-hot-toast"] ? "react-hot-toast" : null;
|
|
92
|
+
let shadcn = null;
|
|
93
|
+
const comp = readJson(join(root, "components.json"));
|
|
94
|
+
if (comp?.tailwind?.css) shadcn = {
|
|
95
|
+
themeCss: comp.tailwind.css,
|
|
96
|
+
uiAlias: comp.aliases?.ui ?? "@/components/ui"
|
|
97
|
+
};
|
|
98
|
+
const themeCss = shadcn?.themeCss && existsSync(join(root, shadcn.themeCss)) ? shadcn.themeCss : firstExisting(root, [
|
|
99
|
+
"src/index.css",
|
|
100
|
+
"src/styles/theme.css",
|
|
101
|
+
"src/styles/globals.css",
|
|
102
|
+
"src/app/globals.css",
|
|
103
|
+
"app/globals.css",
|
|
104
|
+
"src/globals.css",
|
|
105
|
+
"src/style.css",
|
|
106
|
+
"styles/globals.css"
|
|
107
|
+
]);
|
|
108
|
+
const ts = readJson(join(root, "tsconfig.json"));
|
|
109
|
+
const include = ts?.include;
|
|
110
|
+
const exclude = ts?.exclude ?? [];
|
|
111
|
+
const sweeps = ts != null && !exclude.some((e) => e === "design" || e.startsWith("design/")) && (!include || include.some((i) => i === "." || i.startsWith("**") || i.startsWith("design")));
|
|
112
|
+
return {
|
|
113
|
+
tailwind,
|
|
114
|
+
router,
|
|
115
|
+
routerPkg,
|
|
116
|
+
toaster,
|
|
117
|
+
shadcn,
|
|
118
|
+
themeCss,
|
|
119
|
+
tsconfigSweepsDesign: !!sweeps
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function firstExisting(root, candidates) {
|
|
123
|
+
for (const c of candidates) if (existsSync(join(root, c))) return c;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/server/config.ts
|
|
128
|
+
const DEFAULTS = {
|
|
129
|
+
mode: "studio",
|
|
130
|
+
theme: null,
|
|
131
|
+
viewports: {
|
|
132
|
+
mobile: {
|
|
133
|
+
width: 390,
|
|
134
|
+
height: 844
|
|
135
|
+
},
|
|
136
|
+
tablet: {
|
|
137
|
+
width: 768,
|
|
138
|
+
height: 1024
|
|
139
|
+
},
|
|
140
|
+
laptop: {
|
|
141
|
+
width: 1280,
|
|
142
|
+
height: 800
|
|
143
|
+
},
|
|
144
|
+
monitor: {
|
|
145
|
+
width: 1920,
|
|
146
|
+
height: 1080
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
themes: ["light", "dark"],
|
|
150
|
+
port: 5199,
|
|
151
|
+
zoomSpeed: 1,
|
|
152
|
+
share: { branding: true }
|
|
153
|
+
};
|
|
154
|
+
/** Load design/config.ts via native TS import (Node >= 22.18). Missing or broken fields fall back to defaults. */
|
|
155
|
+
async function loadConfig(root) {
|
|
156
|
+
const file = join(root, "design", "config.ts");
|
|
157
|
+
if (!existsSync(file)) return { ...DEFAULTS };
|
|
158
|
+
try {
|
|
159
|
+
const user = (await import(`${pathToFileURL(file).href}?t=${Date.now()}`)).default ?? {};
|
|
160
|
+
return {
|
|
161
|
+
...DEFAULTS,
|
|
162
|
+
...user,
|
|
163
|
+
viewports: validViewports(user.viewports) ?? DEFAULTS.viewports,
|
|
164
|
+
themes: Array.isArray(user.themes) && user.themes.length ? user.themes.map(String) : DEFAULTS.themes,
|
|
165
|
+
port: validPort(user.port) ?? DEFAULTS.port,
|
|
166
|
+
zoomSpeed: validZoom(user.zoomSpeed) ?? DEFAULTS.zoomSpeed,
|
|
167
|
+
share: {
|
|
168
|
+
branding: user.share?.branding !== false,
|
|
169
|
+
name: typeof user.share?.name === "string" ? user.share.name : void 0,
|
|
170
|
+
logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error(`[marver] design/config.ts failed to load, using defaults:\n ${err.message}`);
|
|
175
|
+
return { ...DEFAULTS };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const validDim = (n) => typeof n === "number" && Number.isFinite(n) && n >= 1 && n <= 2e4;
|
|
179
|
+
function validPort(n) {
|
|
180
|
+
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
|
|
181
|
+
}
|
|
182
|
+
function validZoom(n) {
|
|
183
|
+
return typeof n === "number" && Number.isFinite(n) && n >= .1 && n <= 10 ? n : null;
|
|
184
|
+
}
|
|
185
|
+
function validViewports(v) {
|
|
186
|
+
if (!v || typeof v !== "object") return null;
|
|
187
|
+
const out = {};
|
|
188
|
+
for (const [k, val] of Object.entries(v)) {
|
|
189
|
+
const vp = val;
|
|
190
|
+
if (validDim(vp?.width) && validDim(vp?.height)) out[k] = {
|
|
191
|
+
width: Math.round(vp.width),
|
|
192
|
+
height: Math.round(vp.height)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return Object.keys(out).length ? out : null;
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/server/manifest.ts
|
|
199
|
+
const FRAME_EXT = /\.(tsx|jsx|html)$/;
|
|
200
|
+
const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
|
|
201
|
+
/** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted (spec §6). */
|
|
202
|
+
function extractMeta(src) {
|
|
203
|
+
const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
|
|
204
|
+
if (!m) return {};
|
|
205
|
+
const body = m[1];
|
|
206
|
+
const pick = (key) => {
|
|
207
|
+
const r = new RegExp(`${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
|
|
208
|
+
return r ? r[2] : void 0;
|
|
209
|
+
};
|
|
210
|
+
const out = {};
|
|
211
|
+
const title = pick("title");
|
|
212
|
+
if (title) out.title = title;
|
|
213
|
+
const viewport = pick("viewport");
|
|
214
|
+
if (viewport) out.viewport = viewport;
|
|
215
|
+
const theme = pick("theme");
|
|
216
|
+
if (theme) out.theme = theme;
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
|
|
220
|
+
function toFrameId(designRelPath) {
|
|
221
|
+
const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
|
|
222
|
+
return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
|
|
223
|
+
}
|
|
224
|
+
function walk(dir, out = []) {
|
|
225
|
+
if (!existsSync(dir)) return out;
|
|
226
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
227
|
+
if (e.name.startsWith(".")) continue;
|
|
228
|
+
const p = join(dir, e.name);
|
|
229
|
+
if (e.isDirectory()) walk(p, out);
|
|
230
|
+
else out.push(p);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
function scanFrames(root) {
|
|
235
|
+
const design = join(root, "design");
|
|
236
|
+
const frames = [];
|
|
237
|
+
for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
|
|
238
|
+
const name = abs.split(sep).pop();
|
|
239
|
+
if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
|
|
240
|
+
const rel = relative(design, abs).split(sep).join("/");
|
|
241
|
+
const id = toFrameId(rel);
|
|
242
|
+
const scene = id.includes("/") ? id.split("/")[0] : "";
|
|
243
|
+
if (base === "scenes" && RESERVED_SCENES.has(scene)) {
|
|
244
|
+
console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const kind = name.endsWith(".html") ? "html" : "tsx";
|
|
248
|
+
const entry = {
|
|
249
|
+
id,
|
|
250
|
+
file: `design/${rel}`,
|
|
251
|
+
kind,
|
|
252
|
+
scene
|
|
253
|
+
};
|
|
254
|
+
if (kind === "tsx") {
|
|
255
|
+
const meta = extractMeta(readFileSync(abs, "utf8"));
|
|
256
|
+
if (meta.title) entry.title = meta.title;
|
|
257
|
+
if (meta.viewport) entry.viewport = meta.viewport;
|
|
258
|
+
if (meta.theme) entry.theme = meta.theme;
|
|
259
|
+
}
|
|
260
|
+
frames.push(entry);
|
|
261
|
+
}
|
|
262
|
+
const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
|
|
263
|
+
frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
|
|
264
|
+
const seen = /* @__PURE__ */ new Map();
|
|
265
|
+
const deduped = frames.filter((f) => {
|
|
266
|
+
const prev = seen.get(f.id);
|
|
267
|
+
if (prev) {
|
|
268
|
+
console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
seen.set(f.id, f.file);
|
|
272
|
+
return true;
|
|
273
|
+
});
|
|
274
|
+
frames.length = 0;
|
|
275
|
+
frames.push(...deduped);
|
|
276
|
+
const sceneCounts = /* @__PURE__ */ new Map();
|
|
277
|
+
for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
|
|
278
|
+
return {
|
|
279
|
+
frames,
|
|
280
|
+
scenes: [...sceneCounts.entries()].map(([name, n]) => ({
|
|
281
|
+
name,
|
|
282
|
+
frames: n
|
|
283
|
+
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** Write design/manifest.json only when content changed. Returns the manifest either way. */
|
|
287
|
+
function writeManifest(root, manifest) {
|
|
288
|
+
const file = join(root, "design", "manifest.json");
|
|
289
|
+
const next = JSON.stringify(manifest, null, 2) + "\n";
|
|
290
|
+
const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
291
|
+
if (hash(prev) === hash(next)) return false;
|
|
292
|
+
mkdirSync(join(root, "design"), { recursive: true });
|
|
293
|
+
writeFileSync(file, next);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
297
|
+
//#endregion
|
|
298
|
+
export { loadConfig as a, DEFAULTS as i, scanFrames as n, detectHost as o, writeManifest as r, hash as t };
|
|
@@ -1,107 +1,9 @@
|
|
|
1
|
-
import { r as ROUTE } from "./cli.mjs";
|
|
2
|
-
import {
|
|
1
|
+
import { n as PKG, r as ROUTE, t as NAME } from "./cli.mjs";
|
|
2
|
+
import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-CHmKAAtG.mjs";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
4
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
105
7
|
//#region src/server/api.ts
|
|
106
8
|
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
107
9
|
const BODY_LIMIT = 1e6;
|
|
@@ -188,7 +90,7 @@ function apiMiddleware(root) {
|
|
|
188
90
|
const p = boardPath(boardMatch[1]);
|
|
189
91
|
if (!p) return json(res, 400, { error: "invalid board name" });
|
|
190
92
|
if (req.method === "GET") {
|
|
191
|
-
if (!existsSync(p)) return json(res,
|
|
93
|
+
if (!existsSync(p)) return json(res, 200, { board: null });
|
|
192
94
|
const content = readFileSync(p, "utf8");
|
|
193
95
|
try {
|
|
194
96
|
return json(res, 200, {
|
|
@@ -275,6 +177,7 @@ function routesMiddleware(server, clientDir) {
|
|
|
275
177
|
try {
|
|
276
178
|
const html = await server.transformIndexHtml(req.url ?? "/", page(dir));
|
|
277
179
|
res.setHeader("content-type", "text/html");
|
|
180
|
+
res.setHeader("cache-control", "no-store");
|
|
278
181
|
res.end(html);
|
|
279
182
|
} catch (err) {
|
|
280
183
|
res.statusCode = 500;
|
|
@@ -283,6 +186,73 @@ function routesMiddleware(server, clientDir) {
|
|
|
283
186
|
};
|
|
284
187
|
}
|
|
285
188
|
//#endregion
|
|
189
|
+
//#region src/server/update.ts
|
|
190
|
+
/**
|
|
191
|
+
* Update discovery - dev only, and deliberately boring about privacy: one anonymous
|
|
192
|
+
* registry metadata GET per day (the same request `npm view` makes), cached in
|
|
193
|
+
* design/.local/, nothing sent beyond the request itself. Offline, slow, or
|
|
194
|
+
* firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
|
|
195
|
+
* Published bundles never check anything - viewers are not the owner.
|
|
196
|
+
*/
|
|
197
|
+
const TTL = 864e5;
|
|
198
|
+
/** Installed version: walk up from this module to the package's own package.json
|
|
199
|
+
* (one level from dist/, two from src/server/ - the walk covers both). */
|
|
200
|
+
function installedVersion() {
|
|
201
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
202
|
+
for (let i = 0; i < 4; i++) {
|
|
203
|
+
try {
|
|
204
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
205
|
+
if (pkg.name === "@marver-design/marver") return pkg.version;
|
|
206
|
+
} catch {}
|
|
207
|
+
dir = dirname(dir);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
/** Strictly-newer numeric compare; anything non-numeric (prerelease tags) compares false. */
|
|
212
|
+
const newer = (a, b) => {
|
|
213
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
214
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
215
|
+
for (let i = 0; i < 3; i++) {
|
|
216
|
+
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
217
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
|
218
|
+
if (x !== y) return x > y;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
};
|
|
222
|
+
/** The latest published version when strictly newer than installed, else null. Never throws. */
|
|
223
|
+
async function checkUpdate(root) {
|
|
224
|
+
if (process.env.MARVER_NO_UPDATE_CHECK) return null;
|
|
225
|
+
const current = installedVersion();
|
|
226
|
+
if (!current) return null;
|
|
227
|
+
const latest = await latestVersion(root);
|
|
228
|
+
return latest && newer(latest, current) ? latest : null;
|
|
229
|
+
}
|
|
230
|
+
async function latestVersion(root) {
|
|
231
|
+
const cacheFile = join(root, "design", ".local", "update-check.json");
|
|
232
|
+
const wellFormed = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v);
|
|
233
|
+
try {
|
|
234
|
+
const c = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
235
|
+
const age = Date.now() - c.checkedAt;
|
|
236
|
+
if (typeof c.checkedAt === "number" && age >= 0 && age < TTL) return wellFormed(c.latest) ? c.latest : null;
|
|
237
|
+
} catch {}
|
|
238
|
+
let latest = null;
|
|
239
|
+
try {
|
|
240
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, { signal: AbortSignal.timeout(3e3) });
|
|
241
|
+
if (res.ok) {
|
|
242
|
+
const v = (await res.json())?.version;
|
|
243
|
+
if (wellFormed(v)) latest = v;
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
try {
|
|
247
|
+
mkdirSync(join(root, "design", ".local"), { recursive: true });
|
|
248
|
+
writeFileSync(cacheFile, JSON.stringify({
|
|
249
|
+
checkedAt: Date.now(),
|
|
250
|
+
latest
|
|
251
|
+
}) + "\n");
|
|
252
|
+
} catch {}
|
|
253
|
+
return latest;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
286
256
|
//#region src/server/plugin.ts
|
|
287
257
|
const VIRTUAL_THEME = "virtual:sh-theme";
|
|
288
258
|
const VIRTUAL_CONFIG = "virtual:sh-config";
|
|
@@ -306,15 +276,26 @@ function marverPlugin(ctx) {
|
|
|
306
276
|
},
|
|
307
277
|
load(id) {
|
|
308
278
|
if (id === "\0virtual:sh-theme.css") {
|
|
309
|
-
console.warn("[marver] no theme detected - frames render unstyled.
|
|
279
|
+
console.warn("[marver] no theme detected - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set `theme` in design/config.ts).");
|
|
310
280
|
return "/* marver: no theme configured */";
|
|
311
281
|
}
|
|
312
|
-
if (id === "\0virtual:sh-config")
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
282
|
+
if (id === "\0virtual:sh-config") {
|
|
283
|
+
const setupPending = (() => {
|
|
284
|
+
try {
|
|
285
|
+
const s = readFileSync(join(root, "design", "instructions", "setup.md"), "utf8");
|
|
286
|
+
return s.startsWith("# Setup required") && s.includes("marver init");
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
return `export default ${JSON.stringify({
|
|
292
|
+
viewports: config.viewports,
|
|
293
|
+
themes: config.themes,
|
|
294
|
+
zoomSpeed: config.zoomSpeed,
|
|
295
|
+
noTheme: themeFile() == null,
|
|
296
|
+
setup: setupPending
|
|
297
|
+
})}`;
|
|
298
|
+
}
|
|
318
299
|
if (id === "\0virtual:sh-data") return "export default null";
|
|
319
300
|
},
|
|
320
301
|
/** HTML frames: inject theme + bridge into any design/**.html Vite serves.
|
|
@@ -354,6 +335,28 @@ function marverPlugin(ctx) {
|
|
|
354
335
|
if (hctx.modules.length === 0) return [];
|
|
355
336
|
},
|
|
356
337
|
configureServer(server) {
|
|
338
|
+
server.middlewares.use((req, res, next) => {
|
|
339
|
+
if (req.url && req.url.includes("/frame-host/registry.ts")) {
|
|
340
|
+
const orig = res.setHeader.bind(res);
|
|
341
|
+
res.setHeader = ((name, value) => orig(name, String(name).toLowerCase() === "cache-control" ? "no-cache" : value));
|
|
342
|
+
}
|
|
343
|
+
next();
|
|
344
|
+
});
|
|
345
|
+
const update = checkUpdate(root).catch(() => null);
|
|
346
|
+
update.then((latest) => {
|
|
347
|
+
if (latest) console.log(`\n update: ${PKG} ${latest} is out (installed ${installedVersion() ?? "?"}) → npm i -D ${PKG}@latest && npx ${NAME} init\n`);
|
|
348
|
+
});
|
|
349
|
+
server.middlewares.use((req, res, next) => {
|
|
350
|
+
if (new URL(req.url ?? "/", "http://x").pathname !== `/__mv/api/update`) return next();
|
|
351
|
+
update.then((latest) => {
|
|
352
|
+
res.setHeader("content-type", "application/json");
|
|
353
|
+
res.setHeader("cache-control", "no-store");
|
|
354
|
+
res.end(JSON.stringify({
|
|
355
|
+
latest,
|
|
356
|
+
current: installedVersion()
|
|
357
|
+
}));
|
|
358
|
+
});
|
|
359
|
+
});
|
|
357
360
|
server.middlewares.use(apiMiddleware(root));
|
|
358
361
|
server.middlewares.use(routesMiddleware(server, clientDir));
|
|
359
362
|
const regen = debounce(() => {
|
|
@@ -362,8 +365,39 @@ function marverPlugin(ctx) {
|
|
|
362
365
|
}, 150);
|
|
363
366
|
const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
|
|
364
367
|
const inScope = (file) => watched.some((w) => file.startsWith(w));
|
|
365
|
-
|
|
366
|
-
|
|
368
|
+
const themeModules = () => {
|
|
369
|
+
const f = themeFile();
|
|
370
|
+
if (!f) return [];
|
|
371
|
+
const out = [];
|
|
372
|
+
const seen = /* @__PURE__ */ new Set();
|
|
373
|
+
const walk = (mod) => {
|
|
374
|
+
if (!mod || seen.has(mod)) return;
|
|
375
|
+
seen.add(mod);
|
|
376
|
+
out.push(mod);
|
|
377
|
+
for (const im of mod.clientImportedModules ?? mod.importedModules ?? []) if (String(im.id ?? "").split("?")[0].endsWith(".css")) walk(im);
|
|
378
|
+
};
|
|
379
|
+
for (const mod of server.moduleGraph.getModulesByFile(f) ?? []) walk(mod);
|
|
380
|
+
return out;
|
|
381
|
+
};
|
|
382
|
+
const pushTheme = debounce(() => {
|
|
383
|
+
for (const mod of themeModules()) server.reloadModule(mod).catch(() => {});
|
|
384
|
+
}, 150);
|
|
385
|
+
const rescanTheme = () => {
|
|
386
|
+
for (const mod of themeModules()) server.moduleGraph.invalidateModule(mod);
|
|
387
|
+
pushTheme();
|
|
388
|
+
};
|
|
389
|
+
server.watcher.on("add", (f) => {
|
|
390
|
+
if (inScope(f)) {
|
|
391
|
+
regen();
|
|
392
|
+
rescanTheme();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
server.watcher.on("unlink", (f) => {
|
|
396
|
+
if (inScope(f)) {
|
|
397
|
+
regen();
|
|
398
|
+
rescanTheme();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
367
401
|
server.watcher.on("change", (f) => inScope(f) && /\.(tsx|jsx)$/.test(f) && regen());
|
|
368
402
|
writeManifest(root, scanFrames(root));
|
|
369
403
|
const boardsDir = join(root, "design", "boards");
|
|
@@ -397,15 +431,18 @@ function debounce(fn, ms) {
|
|
|
397
431
|
t = setTimeout(() => fn(...a), ms);
|
|
398
432
|
});
|
|
399
433
|
}
|
|
400
|
-
/** Extra vite plugin when the host runs Tailwind v4
|
|
434
|
+
/** Extra vite plugin when the host runs Tailwind v4. The host's own @tailwindcss/vite
|
|
435
|
+
* wins (version-matched to their tailwindcss); the fallback is marver's bundled copy -
|
|
436
|
+
* Next.js hosts use @tailwindcss/postcss and never have the vite plugin (the blessed
|
|
437
|
+
* stack would otherwise render every frame without its utility classes). */
|
|
401
438
|
async function tailwind4Plugin(root) {
|
|
402
|
-
|
|
403
|
-
|
|
439
|
+
const factories = [() => import(join(root, "node_modules", "@tailwindcss", "vite", "dist", "index.mjs")), () => import("@tailwindcss/vite")];
|
|
440
|
+
for (const load of factories) try {
|
|
441
|
+
const mod = await load();
|
|
404
442
|
const result = (mod.default ?? mod)();
|
|
405
443
|
return Array.isArray(result) ? result : [result];
|
|
406
|
-
} catch {
|
|
407
|
-
|
|
408
|
-
}
|
|
444
|
+
} catch {}
|
|
445
|
+
return null;
|
|
409
446
|
}
|
|
410
447
|
/** Tailwind v3: inline PostCSS config extending the host's tailwind config with design/ globs. Host files untouched. */
|
|
411
448
|
async function tailwind3Css(root) {
|
|
@@ -434,4 +471,4 @@ async function tailwind3Css(root) {
|
|
|
434
471
|
return null;
|
|
435
472
|
}
|
|
436
473
|
//#endregion
|
|
437
|
-
export {
|
|
474
|
+
export { tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as NAME } from "./cli.mjs";
|
|
2
|
-
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
2
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
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
6
|
//#region src/server/serve.ts
|
|
@@ -115,6 +115,13 @@ function serve(root, portFlag) {
|
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
117
|
const port = portFlag ?? (Number(process.env.PORT) || 4199);
|
|
118
|
+
server.on("error", (err) => {
|
|
119
|
+
if (err.code === "EADDRINUSE") {
|
|
120
|
+
console.error(`[${NAME}] port ${port} is already in use - another \`${NAME} serve\` still running? Stop it, or pass --port <n>.`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
124
|
+
});
|
|
118
125
|
server.listen(port, () => {
|
|
119
126
|
console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
|
|
120
127
|
console.log(verifier ? " gate: ON (MARVER_PASSWORD set)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"license": "Apache-2.0",
|
|
8
8
|
"bin": {
|
|
9
|
-
"marver": "
|
|
9
|
+
"marver": "dist/cli.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"typecheck": "tsc --noEmit"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
30
31
|
"@vitejs/plugin-react": "^6.0.0",
|
|
31
32
|
"cac": "^7.0.0",
|
|
32
33
|
"react-zoom-pan-pinch": "^3.6.1",
|
|
@@ -56,4 +57,4 @@
|
|
|
56
57
|
"publishConfig": {
|
|
57
58
|
"access": "public"
|
|
58
59
|
}
|
|
59
|
-
}
|
|
60
|
+
}
|