@marver-design/marver 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -0
- package/README.md +2 -1
- package/dist/boards-BmxcT3Lc.mjs +290 -0
- package/dist/boards-PuVzw5Wp.mjs +62 -0
- package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
- package/dist/cli.mjs +33 -13
- package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
- package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
- package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
- package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
- package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
- package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
- package/docs/publish.md +4 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +45 -259
- package/src/client/shell/BoardList.tsx +438 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +156 -51
- package/src/client/shell/styles.css +40 -4
- package/src/shared/board-tree.ts +343 -0
- package/templates/AGENTS-embedded.md +25 -5
- package/templates/AGENTS-studio.md +25 -5
- package/templates/instructions/boards.md +91 -2
- package/templates/instructions/craft.md +4 -0
- package/templates/instructions/discover.md +20 -3
- package/templates/instructions/iterate.md +5 -4
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +1 -1
- package/templates/instructions/welcome.md +4 -1
- package/templates/instructions/wireframe.md +3 -0
- package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.16.1 - 2026-09-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Titles.** Boards, folders and scenes can be called anything - "MVP", "UI",
|
|
10
|
+
"Checkout (v2) 🛒". Rename in the sidebar (scenes too, now) edits a `title`;
|
|
11
|
+
the file name, folder key and scene directory stay the slug agents address.
|
|
12
|
+
No title = the Title-Cased slug, as before. Stored on the board JSON, the
|
|
13
|
+
`_folders.json` entry and the brief's YAML front matter; shown everywhere the
|
|
14
|
+
object is named and carried by the manifest and `npx marver boards`.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Drops land where the seam shows.** A board grabbed by its icon and dragged
|
|
19
|
+
between two folder boards landed after the folder; releases in blank space did
|
|
20
|
+
nothing. The resolver now reads the pointer against the rendered rows, the
|
|
21
|
+
release applies the seam on screen, and a release outside the panel cancels.
|
|
22
|
+
- A project reached through a symlink (macOS `/var`, a linked projects folder)
|
|
23
|
+
lost its live updates; the CLI now resolves the root's real path.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- `boards/rename` takes `{ from, to?, title?, baseHash? }` (title-only, CAS);
|
|
28
|
+
new `scenes/rename { scene, title }`; `sh:scenes` relabels scenes without
|
|
29
|
+
re-keying iframes.
|
|
30
|
+
|
|
31
|
+
## 0.16.0 - 2026-09-03
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Board folders.** The sidebar's boards can live in folders, one level deep.
|
|
36
|
+
Right-click the Boards header (or its `+`) for a new folder and name it
|
|
37
|
+
inline - what you type becomes a slug ("Old stuff" → `old-stuff`, shown "Old
|
|
38
|
+
Stuff"); right-click a board for **Move to new folder** (the folder takes the
|
|
39
|
+
board's slot, the input has focus) or **Move to top level**; right-click a
|
|
40
|
+
folder to **Rename** or **Delete** it (its boards go back to the top level -
|
|
41
|
+
folders organise, never own). Moving into an existing folder is a **drag**:
|
|
42
|
+
the board drag-and-drop now lands boards inside folders (drop on the folder
|
|
43
|
+
row), in any slot inside one, back out to the root (or the left gutter of a
|
|
44
|
+
folder's rows), and drags folders among boards. A click collapses a folder; the
|
|
45
|
+
choice is remembered per browser. The folder holding the active board keeps
|
|
46
|
+
the ancestor wash so its home stays visible collapsed.
|
|
47
|
+
- **Folders are files, for agents too.** `"folder": "<name>"` on a board file
|
|
48
|
+
puts it in a folder (`order` ranks it among its folder siblings);
|
|
49
|
+
`design/boards/_folders.json` names empty folders and ranks folders at the
|
|
50
|
+
root. A folder two boards name exists without the registry. The landing
|
|
51
|
+
board is the first board in sidebar order, folders included. **`npx marver
|
|
52
|
+
boards`** prints the sidebar as the files say it is (folders, boards in
|
|
53
|
+
reading order with their `order`, the landing board; `--json` for the tree),
|
|
54
|
+
so an agent looks before it organises. The agent contract and
|
|
55
|
+
`instructions/boards.md` carry the grammar, every move (create, file in,
|
|
56
|
+
move out, rank, rename, delete) as a file edit, and a nudge to group
|
|
57
|
+
proactively past six or eight boards.
|
|
58
|
+
- **Published bundles** carry the folder tree of the published boards only; a
|
|
59
|
+
folder with nothing published never reaches the bundle. The published
|
|
60
|
+
sidebar shows folders read-only.
|
|
61
|
+
- **Descriptions - purpose notes on every object, for agents.** One optional
|
|
62
|
+
`description` (one sentence: what it is for, its state) on the project
|
|
63
|
+
(`description` in `design/config.ts`), a board (its JSON - preserved by
|
|
64
|
+
autosave and by sidebar drags like `order`/`folder`), a folder (its
|
|
65
|
+
`_folders.json` entry - it rides along through renames), a scene (the first
|
|
66
|
+
non-blank line of its `_brief.md`, `#` stripped, front matter skipped - no
|
|
67
|
+
new file) and a frame
|
|
68
|
+
(`meta.description`). `design/manifest.json` becomes the orientation file it
|
|
69
|
+
was meant to be: `project`, `folders`, `boards` (sidebar order, with folder
|
|
70
|
+
and description), `scenes` (with description and brief path) and `frames`
|
|
71
|
+
(with description). `marver dev` regenerates it on board and brief edits too,
|
|
72
|
+
and broadcasts `sh:manifest` only when the frames changed - a description
|
|
73
|
+
edit never re-keys the live iframes. Published bundles ship descriptions of
|
|
74
|
+
published things only (brief paths only with source revealed). `marver
|
|
75
|
+
boards` prints them. Editing `description` in `design/config.ts` under `dev`
|
|
76
|
+
refreshes the manifest live (the rest of the config still needs a restart).
|
|
77
|
+
The agent contract teaches: write it at creation, keep it true, fix what your
|
|
78
|
+
session made false before it ends (the review walk ends on it). Nothing
|
|
79
|
+
renders in the canvas.
|
|
80
|
+
- `export const meta` picks a literal even when the prose holds the other
|
|
81
|
+
quote (`"the buyer's path"`), and refuses a computed value (`"Draft" + phase`)
|
|
82
|
+
instead of taking its literal half.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- `POST /__mv/api/boards/reorder` takes the WHOLE tree (`{ tree, base }`) in
|
|
87
|
+
place of `{ order }`: root boards as strings, folders as `{ folder, boards }`,
|
|
88
|
+
plus the sha256 the client last saw for every board it names and for the
|
|
89
|
+
registry. The server preflights every named board (present, a regular file,
|
|
90
|
+
well-formed, unchanged) BEFORE writing anything - a 409 names the stale
|
|
91
|
+
boards and the shell re-reads and replays its move once, so an agent's
|
|
92
|
+
concurrent `folder` edit and a human's drag can never silently erase each
|
|
93
|
+
other. It answers the new hashes, and the shell's autosave of the active
|
|
94
|
+
board keeps its CAS token current - a reorder no longer reboots the board
|
|
95
|
+
you are editing. `GET /__mv/api/boards` gains `folder`; `GET
|
|
96
|
+
/__mv/api/folders` is new.
|
|
97
|
+
- Board enumeration (dev API, build, `--all-boards`) counts only regular files
|
|
98
|
+
on the board-name grammar: `_folders.json`, temp files and symlinks are never
|
|
99
|
+
boards - the build fails closed on a symlinked board. A malformed
|
|
100
|
+
`_folders.json` is a 422 the sidebar toasts (and a build error), never a
|
|
101
|
+
silently empty registry the next drag would overwrite.
|
|
102
|
+
- The sidebar re-reads its list on a coalesced `sh:boards` broadcast (any add,
|
|
103
|
+
write or delete under `design/boards/`), so agent-written boards and folders
|
|
104
|
+
show in under half a second instead of the 8 s poll.
|
|
105
|
+
|
|
5
106
|
## 0.15.0 - 2026-09-02
|
|
6
107
|
|
|
7
108
|
### Added
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Frames appear on the canvas the moment the files land. That's the loop.
|
|
|
40
40
|
|
|
41
41
|
## The canvas
|
|
42
42
|
|
|
43
|
-
- **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too.
|
|
43
|
+
- **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too. Boards can live in folders: right-click the Boards header (or its `+`) for a new one, name it inline, drag boards in and out (the seam shows exactly where a release lands) and folders among boards; agents do the same by writing `"folder": "<name>"` on a board and `design/boards/_folders.json` for empty or ranked folders (`npx marver boards` prints the tree). Boards, folders and scenes take a `title` - any name you like, "MVP", "UI", "Checkout (v2) 🛒" - while their file, key and directory stay the slug agents address; Rename in the sidebar edits the title. Every object takes a one-sentence `description` (project in `design/config.ts`, boards and folders in their JSON, a scene's first `_brief.md` line, `meta.description` on a frame) and `design/manifest.json` carries them all - a new agent session orients in one read.
|
|
44
44
|
- **Devices view.** Hotkeys `1`-`5` (or the Devices menu) size every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
|
|
45
45
|
- **Prototype links.** `data-goto="scene/frame"` on any element links frames into a walkable prototype - across boards, too.
|
|
46
46
|
- **Five ways to view a board.** The canvas (frames on a plane), the board (the same, tidy), **present** (`p`: a full-screen clickable walkthrough - `data-goto` navigates, arrows step, `[` / `]` cycle variants, laser, comments, theme and device pickers in the toolbar), **focus** (one frame as a document - the reading preset for specs), and **slides** (a deck). A published board names its landing view; a frame deep link opens straight into it.
|
|
@@ -98,6 +98,7 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
|
|
|
98
98
|
| `npx marver comments …` | The agent's queue: `connect <url>` · `sync` · `list` · `reply` · `resolve` · `invite <email>` · `revoke <email>` |
|
|
99
99
|
| `npx marver work …` | Working glow from the terminal: `start <scene/frame …>` · `done … \| --all` · `list` |
|
|
100
100
|
| `npx marver shot <frame> [--scale 1-4]` | Render one frame headless and print the PNG path (needs `dev` running); 2x by default |
|
|
101
|
+
| `npx marver boards [--json]` | The sidebar as the files say it is: folders, boards in reading order with their title, `order` and description, the landing board |
|
|
101
102
|
|
|
102
103
|
## Shortcuts
|
|
103
104
|
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region src/server/hash.ts
|
|
5
|
+
/** sha256 hex of a string - the CAS token for board files, the registry, the manifest. */
|
|
6
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/shared/board-tree.ts
|
|
9
|
+
/**
|
|
10
|
+
* Board folders - the pure tree shared by the sidebar, the dev API, the build and the
|
|
11
|
+
* tests. Files are the truth: a board says which folder it sits in (`folder` on the
|
|
12
|
+
* board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
|
|
13
|
+
* says which folders exist and where they rank at the root. One level only: folders
|
|
14
|
+
* hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
|
|
15
|
+
*/
|
|
16
|
+
/** The on-disk name grammar shared by boards and folders (a board name is a filename). */
|
|
17
|
+
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
18
|
+
const isBoardName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
|
|
19
|
+
/** The folder registry beside the boards - underscore = infrastructure, never a board. */
|
|
20
|
+
const FOLDERS_FILE = "_folders.json";
|
|
21
|
+
/** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
|
|
22
|
+
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
23
|
+
const isBoardFile = (f) => f.endsWith(".json") && isBoardName(f.slice(0, -5));
|
|
24
|
+
const readDescription = (v) => {
|
|
25
|
+
if (typeof v !== "string") return void 0;
|
|
26
|
+
return v.trim().replace(/\s+/g, " ").slice(0, 300) || void 0;
|
|
27
|
+
};
|
|
28
|
+
const readTitle = (v) => {
|
|
29
|
+
if (typeof v !== "string") return void 0;
|
|
30
|
+
return Array.from(v.replace(/[\u0000-\u001f\u007f]/g, " ").trim().replace(/\s+/g, " ")).slice(0, 120).join("").trim() || void 0;
|
|
31
|
+
};
|
|
32
|
+
const rank = (o) => typeof o === "number" && Number.isFinite(o) ? o : Infinity;
|
|
33
|
+
/** A folder's title and description, present only when set. */
|
|
34
|
+
const folderExtras = (it) => ({
|
|
35
|
+
...it.title ? { title: it.title } : {},
|
|
36
|
+
...it.description ? { description: it.description } : {}
|
|
37
|
+
});
|
|
38
|
+
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
39
|
+
* malformed registry is an ERROR the human must fix (silently reading it as empty would
|
|
40
|
+
* let the next drag overwrite their folders), while a missing file is simply no folders. */
|
|
41
|
+
function parseFolders(raw) {
|
|
42
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "expected an object";
|
|
43
|
+
const { version, folders } = raw;
|
|
44
|
+
if (version !== void 0 && version !== 1) return `unsupported version ${String(version)}`;
|
|
45
|
+
if (!Array.isArray(folders)) return "expected a \"folders\" array";
|
|
46
|
+
const out = [];
|
|
47
|
+
const seen = /* @__PURE__ */ new Set();
|
|
48
|
+
for (const f of folders) {
|
|
49
|
+
const name = f?.name;
|
|
50
|
+
if (!isBoardName(name)) return "a folder needs a name - lowercase letters, numbers and dashes";
|
|
51
|
+
if (seen.has(name)) return `folder "${name}" is listed twice`;
|
|
52
|
+
seen.add(name);
|
|
53
|
+
const o = f.order;
|
|
54
|
+
const t = readTitle(f.title);
|
|
55
|
+
const d = readDescription(f.description);
|
|
56
|
+
out.push({
|
|
57
|
+
name,
|
|
58
|
+
...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
|
|
59
|
+
...t ? { title: t } : {},
|
|
60
|
+
...d ? { description: d } : {}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Sidebar order from the files. Root: boards with no folder + every folder (registered
|
|
66
|
+
* or implied by a board), ranked by `order` then kind (board before folder) then name.
|
|
67
|
+
* Inside a folder: its boards by `order` then name. Unranked sorts after ranked. A folder's
|
|
68
|
+
* title and description ride on its item (they live in the registry a tree write rewrites);
|
|
69
|
+
* a board's title stays with its row - it lives in the board's own file. */
|
|
70
|
+
function buildTree(boards, folders) {
|
|
71
|
+
const folderOrder = /* @__PURE__ */ new Map();
|
|
72
|
+
const folderMeta = /* @__PURE__ */ new Map();
|
|
73
|
+
for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) {
|
|
74
|
+
folderOrder.set(f.name, f.order);
|
|
75
|
+
folderMeta.set(f.name, folderExtras(f));
|
|
76
|
+
}
|
|
77
|
+
const members = /* @__PURE__ */ new Map();
|
|
78
|
+
const rootBoards = [];
|
|
79
|
+
for (const b of boards) {
|
|
80
|
+
if (!isBoardName(b.name) || b.name === "all-scenes") continue;
|
|
81
|
+
const folder = isBoardName(b.folder) ? b.folder : void 0;
|
|
82
|
+
if (!folder) {
|
|
83
|
+
rootBoards.push(b);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!folderOrder.has(folder)) folderOrder.set(folder, void 0);
|
|
87
|
+
const list = members.get(folder) ?? [];
|
|
88
|
+
list.push(b);
|
|
89
|
+
members.set(folder, list);
|
|
90
|
+
}
|
|
91
|
+
const byRank = (a, b) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name);
|
|
92
|
+
const root = [...rootBoards.map((b) => ({
|
|
93
|
+
item: {
|
|
94
|
+
kind: "board",
|
|
95
|
+
name: b.name
|
|
96
|
+
},
|
|
97
|
+
order: b.order
|
|
98
|
+
})), ...[...folderOrder].map(([name, order]) => ({
|
|
99
|
+
item: {
|
|
100
|
+
kind: "folder",
|
|
101
|
+
name,
|
|
102
|
+
boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name),
|
|
103
|
+
...folderMeta.get(name) ?? {}
|
|
104
|
+
},
|
|
105
|
+
order
|
|
106
|
+
}))];
|
|
107
|
+
root.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === "board" ? -1 : 1) || a.item.name.localeCompare(b.item.name));
|
|
108
|
+
return root.map((r) => r.item);
|
|
109
|
+
}
|
|
110
|
+
/** Every board in reading order - the order the switchers and the landing pick use. */
|
|
111
|
+
function flatten(tree) {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const it of tree) if (it.kind === "board") out.push(it.name);
|
|
114
|
+
else out.push(...it.boards);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function validateWire(wire) {
|
|
118
|
+
if (!Array.isArray(wire)) return "invalid tree";
|
|
119
|
+
const boards = /* @__PURE__ */ new Set(), folders = /* @__PURE__ */ new Set();
|
|
120
|
+
const board = (n) => {
|
|
121
|
+
if (!isBoardName(n) || n === "all-scenes") return "invalid board name in tree";
|
|
122
|
+
if (boards.has(n)) return `board "${n}" appears twice`;
|
|
123
|
+
boards.add(n);
|
|
124
|
+
return null;
|
|
125
|
+
};
|
|
126
|
+
for (const w of wire) {
|
|
127
|
+
if (typeof w === "string") {
|
|
128
|
+
const e = board(w);
|
|
129
|
+
if (e) return e;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
|
|
133
|
+
const { folder, boards: kids, title, description } = w;
|
|
134
|
+
if (!isBoardName(folder)) return "invalid folder name in tree";
|
|
135
|
+
if (title !== void 0 && (typeof title !== "string" || Array.from(title).length > 120)) return "invalid folder title";
|
|
136
|
+
if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
|
|
137
|
+
if (folders.has(folder)) return `folder "${folder}" appears twice`;
|
|
138
|
+
folders.add(folder);
|
|
139
|
+
if (!Array.isArray(kids)) return "invalid folder in tree";
|
|
140
|
+
for (const k of kids) {
|
|
141
|
+
const e = board(k);
|
|
142
|
+
if (e) return e;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (boards.size > 200 || folders.size > 50) return "tree too large";
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/server/boards.ts
|
|
150
|
+
/**
|
|
151
|
+
* Reading design/boards/ safely - the one enumerator the dev API, the build and the tests
|
|
152
|
+
* share. Only REGULAR files on the board-name grammar count as boards (a symlink, dangling or
|
|
153
|
+
* live, could read or publish JSON from outside the project - it is skipped, and reported so
|
|
154
|
+
* the build can fail closed). The folder registry is read the same way: absent = no folders,
|
|
155
|
+
* malformed = an error the human must fix, never a silently empty registry.
|
|
156
|
+
*/
|
|
157
|
+
/** Does realpath(dir) stay inside realpath(root)? A symlinked design/boards can't escape. */
|
|
158
|
+
function underRoot(root, dir) {
|
|
159
|
+
try {
|
|
160
|
+
const rr = realpathSync(root);
|
|
161
|
+
const rd = realpathSync(dir);
|
|
162
|
+
return rd === rr || rd.startsWith(rr + sep);
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Is design/boards a directory we may read and write? It must not be a symlink at all (a
|
|
168
|
+
* link to the repo root would list package.json as a board and let a tree write rewrite
|
|
169
|
+
* it; a link outside would publish foreign JSON) and must resolve inside the root. Absent
|
|
170
|
+
* is fine (no boards yet). Returns the error, or null. */
|
|
171
|
+
function checkBoardsDir(root, boardsDir) {
|
|
172
|
+
return checkRealDirs(root, [[join(boardsDir, ".."), "design"], [boardsDir, "design/boards"]]);
|
|
173
|
+
}
|
|
174
|
+
/** Every EXISTING path in `dirs` (root-down order) must be a real directory inside the root -
|
|
175
|
+
* a symlinked `design` with no boards dir yet would otherwise be followed by the mkdir that
|
|
176
|
+
* creates it; a symlinked `design/scenes` would let a brief write land outside the project.
|
|
177
|
+
* An absent one ends the walk (nothing beneath it exists either). Returns the error, or null. */
|
|
178
|
+
function checkRealDirs(root, dirs) {
|
|
179
|
+
for (const [p, label] of dirs) {
|
|
180
|
+
try {
|
|
181
|
+
if (lstatSync(p).isSymbolicLink()) return `${label} must be a real directory, not a symlink`;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (!underRoot(root, p)) return `${label} escapes the project`;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/** A regular file (lstat: a symlink is never followed, a dangling one is not "absent"). */
|
|
190
|
+
const isRegularFile = (p) => {
|
|
191
|
+
try {
|
|
192
|
+
return lstatSync(p).isFile();
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
/** Is there ANY node at p (a dangling symlink counts)? */
|
|
198
|
+
const nodeExists = (p) => {
|
|
199
|
+
try {
|
|
200
|
+
lstatSync(p);
|
|
201
|
+
return true;
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
/** Every board file: name, raw content, hash, and its JSON (null when malformed). `skipped`
|
|
207
|
+
* names the entries that looked like boards but were not regular files. */
|
|
208
|
+
function listBoardFiles(boardsDir) {
|
|
209
|
+
const boards = [], skipped = [];
|
|
210
|
+
if (!existsSync(boardsDir)) return {
|
|
211
|
+
boards,
|
|
212
|
+
skipped
|
|
213
|
+
};
|
|
214
|
+
for (const f of readdirSync(boardsDir)) {
|
|
215
|
+
if (!isBoardFile(f)) continue;
|
|
216
|
+
const file = join(boardsDir, f);
|
|
217
|
+
if (!isRegularFile(file)) {
|
|
218
|
+
skipped.push(f);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const content = readFileSync(file, "utf8");
|
|
222
|
+
let json = null;
|
|
223
|
+
try {
|
|
224
|
+
json = JSON.parse(content);
|
|
225
|
+
} catch {}
|
|
226
|
+
boards.push({
|
|
227
|
+
name: f.slice(0, -5),
|
|
228
|
+
file,
|
|
229
|
+
content,
|
|
230
|
+
sha256: hash(content),
|
|
231
|
+
json
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
boards,
|
|
236
|
+
skipped
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** The author-owned sidebar fields off a board's JSON, leniently: rank, folder, the title
|
|
240
|
+
* humans see, the sentence agents read. */
|
|
241
|
+
function boardFields(json, validName) {
|
|
242
|
+
const o = json;
|
|
243
|
+
const title = readTitle(o?.title);
|
|
244
|
+
const description = readDescription(o?.description);
|
|
245
|
+
return {
|
|
246
|
+
...typeof o?.order === "number" && Number.isFinite(o.order) ? { order: o.order } : {},
|
|
247
|
+
...validName(o?.folder) ? { folder: o.folder } : {},
|
|
248
|
+
...title ? { title } : {},
|
|
249
|
+
...description ? { description } : {}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** The folder registry. `sha256` is the CAS token a tree write must echo (null = "there was
|
|
253
|
+
* no file"), so a write can never silently replace a registry it never saw. */
|
|
254
|
+
function readRegistry(boardsDir) {
|
|
255
|
+
const p = join(boardsDir, FOLDERS_FILE);
|
|
256
|
+
if (!nodeExists(p)) return {
|
|
257
|
+
state: "absent",
|
|
258
|
+
folders: [],
|
|
259
|
+
sha256: null
|
|
260
|
+
};
|
|
261
|
+
if (!isRegularFile(p)) return {
|
|
262
|
+
state: "malformed",
|
|
263
|
+
error: `design/boards/${FOLDERS_FILE} must be a regular file, not a symlink`,
|
|
264
|
+
sha256: null
|
|
265
|
+
};
|
|
266
|
+
const content = readFileSync(p, "utf8");
|
|
267
|
+
let raw;
|
|
268
|
+
try {
|
|
269
|
+
raw = JSON.parse(content);
|
|
270
|
+
} catch {
|
|
271
|
+
return {
|
|
272
|
+
state: "malformed",
|
|
273
|
+
error: `design/boards/${FOLDERS_FILE} is not valid JSON - fix the file`,
|
|
274
|
+
sha256: hash(content)
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const parsed = parseFolders(raw);
|
|
278
|
+
if (typeof parsed === "string") return {
|
|
279
|
+
state: "malformed",
|
|
280
|
+
error: `design/boards/${FOLDERS_FILE}: ${parsed}`,
|
|
281
|
+
sha256: hash(content)
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
state: "ok",
|
|
285
|
+
folders: parsed,
|
|
286
|
+
sha256: hash(content)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
export { listBoardFiles as a, BOARD_NAME as c, flatten as d, isBoardName as f, hash as g, validateWire as h, isRegularFile as i, FOLDERS_FILE as l, readTitle as m, checkBoardsDir as n, nodeExists as o, readDescription as p, checkRealDirs as r, readRegistry as s, boardFields as t, buildTree as u };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
//#region src/cli/boards.ts
|
|
4
|
+
/**
|
|
5
|
+
* `marver boards` - the sidebar as the agent sees it: every folder and board in reading
|
|
6
|
+
* order, from the files (no dev server needed). One call answers "what folders exist, what
|
|
7
|
+
* is in them, what ranks where" before the agent writes `folder` on a board or edits
|
|
8
|
+
* `design/boards/_folders.json`. `--json` gives the tree shape the shell uses.
|
|
9
|
+
*/
|
|
10
|
+
function boardsCommand(root, opts) {
|
|
11
|
+
const dir = join(root, "design", "boards");
|
|
12
|
+
const de = checkBoardsDir(root, dir);
|
|
13
|
+
if (de) throw new Error(de);
|
|
14
|
+
const { boards, skipped } = listBoardFiles(dir);
|
|
15
|
+
const reg = readRegistry(dir);
|
|
16
|
+
if (reg.state === "malformed") throw new Error(reg.error);
|
|
17
|
+
const rows = boards.map((b) => ({
|
|
18
|
+
name: b.name,
|
|
19
|
+
...boardFields(b.json, isBoardName)
|
|
20
|
+
}));
|
|
21
|
+
const tree = buildTree(rows, reg.folders);
|
|
22
|
+
const hasAll = boards.some((b) => b.name === "all-scenes");
|
|
23
|
+
if (opts.json) {
|
|
24
|
+
console.log(JSON.stringify({
|
|
25
|
+
tree,
|
|
26
|
+
boards: rows.filter((r) => r.name !== "all-scenes"),
|
|
27
|
+
landing: flatten(tree)[0] ?? (hasAll ? "all-scenes" : null),
|
|
28
|
+
registry: reg.state === "ok" ? "design/boards/_folders.json" : null
|
|
29
|
+
}, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!tree.length && !hasAll) {
|
|
33
|
+
console.log("no boards yet - design/boards/ is empty");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const order = (n) => {
|
|
37
|
+
const o = rows.find((r) => r.name === n)?.order;
|
|
38
|
+
return o === void 0 ? "" : ` order ${o}`;
|
|
39
|
+
};
|
|
40
|
+
const title = (t) => t ? ` "${t}"` : "";
|
|
41
|
+
const desc = (d) => d ? ` - ${d}` : "";
|
|
42
|
+
const boardLine = (n) => {
|
|
43
|
+
const r = rows.find((x) => x.name === n);
|
|
44
|
+
return `${n}${title(r?.title)}${order(n)}${desc(r?.description)}`;
|
|
45
|
+
};
|
|
46
|
+
for (const it of tree) {
|
|
47
|
+
if (it.kind === "board") {
|
|
48
|
+
console.log(boardLine(it.name));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
console.log(`${it.name}/${title(it.title)} (folder, ${it.boards.length} board${it.boards.length === 1 ? "" : "s"}${reg.folders.some((f) => f.name === it.name) ? "" : ", implied by its boards - not in _folders.json"})${desc(it.description)}`);
|
|
52
|
+
for (const b of it.boards) console.log(` ${boardLine(b)}`);
|
|
53
|
+
if (!it.boards.length) console.log(" (empty)");
|
|
54
|
+
}
|
|
55
|
+
if (hasAll) console.log("all-scenes (auto, always last)");
|
|
56
|
+
const landing = flatten(tree)[0];
|
|
57
|
+
if (landing) console.log(`\nlanding board: ${landing}`);
|
|
58
|
+
console.log(`registry: ${reg.state === "ok" ? "design/boards/_folders.json" : "none (no empty or ranked folders yet)"}`);
|
|
59
|
+
if (skipped.length) console.log(`skipped (not regular files): ${skipped.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { boardsCommand };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME } from "./cli.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
|
|
3
|
+
import { l as detectHost, n as scanFrames, s as loadConfig } from "./manifest-CaslQIAO.mjs";
|
|
4
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BeBGu3gH.mjs";
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -148,22 +149,66 @@ function resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag) {
|
|
|
148
149
|
reveal
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
|
-
/** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly
|
|
152
|
+
/** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly, and so
|
|
153
|
+
* does a board-named entry that is not a regular file (a symlink could publish JSON from
|
|
154
|
+
* outside the project - the build fails closed rather than skipping it quietly). */
|
|
152
155
|
function readBoards(root) {
|
|
153
156
|
const dir = join(root, "design", "boards");
|
|
157
|
+
const de = checkBoardsDir(root, dir);
|
|
158
|
+
if (de) throw new Error(de);
|
|
159
|
+
const { boards, skipped } = listBoardFiles(dir);
|
|
160
|
+
if (skipped.length) throw new Error(`design/boards/${skipped[0]} is not a regular file (a symlinked board cannot be published)`);
|
|
154
161
|
const out = {};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const name = f.replace(/\.json$/, "");
|
|
159
|
-
try {
|
|
160
|
-
out[name] = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
161
|
-
} catch {
|
|
162
|
-
throw new Error(`design/boards/${f} is not valid JSON`);
|
|
163
|
-
}
|
|
162
|
+
for (const b of boards) {
|
|
163
|
+
if (b.json === null) throw new Error(`design/boards/${b.name}.json is not valid JSON`);
|
|
164
|
+
out[b.name] = b.json;
|
|
164
165
|
}
|
|
165
166
|
return out;
|
|
166
167
|
}
|
|
168
|
+
/** Switcher order = the sidebar's reading order: the folder tree over the PUBLISHED boards only
|
|
169
|
+
* (a folder left with no published board drops out - its name never reaches the bundle),
|
|
170
|
+
* flattened depth-first; all-scenes always LAST (it is the expensive everything-board, never
|
|
171
|
+
* the landing). `names[0]` is where `/` opens. Folder names of published boards are structure,
|
|
172
|
+
* like board names: they ship. */
|
|
173
|
+
function publishedTree(published, allBoards, folders) {
|
|
174
|
+
const tree = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
|
|
175
|
+
name: n,
|
|
176
|
+
...boardFields(allBoards[n], isBoardName)
|
|
177
|
+
})), folders).filter((it) => it.kind === "board" || it.boards.length > 0);
|
|
178
|
+
return {
|
|
179
|
+
tree,
|
|
180
|
+
names: [...flatten(tree), ...published.includes("all-scenes") ? ["all-scenes"] : []]
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** The bundle's manifest: the published frames, and descriptions of published things ONLY -
|
|
184
|
+
* the project's, the published scenes' (their brief path only when source is revealed),
|
|
185
|
+
* the published boards' and the folders those sit in. No unpublished name or sentence. */
|
|
186
|
+
function publishedManifest(manifest, pubFrames, publishedNames, strip) {
|
|
187
|
+
const pubScenes = new Set(pubFrames.map((f) => f.scene));
|
|
188
|
+
const pubBoardSet = new Set(publishedNames);
|
|
189
|
+
const pubBoards = (manifest.boards ?? []).filter((b) => pubBoardSet.has(b.name));
|
|
190
|
+
const pubFolderSet = new Set(pubBoards.map((b) => b.folder).filter(Boolean));
|
|
191
|
+
const pubFolders = (manifest.folders ?? []).filter((f) => pubFolderSet.has(f.name));
|
|
192
|
+
return {
|
|
193
|
+
...manifest.project ? { project: manifest.project } : {},
|
|
194
|
+
...pubFolders.length ? { folders: pubFolders } : {},
|
|
195
|
+
...pubBoards.length ? { boards: pubBoards } : {},
|
|
196
|
+
scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, title, description, brief }) => ({
|
|
197
|
+
name,
|
|
198
|
+
frames: pubFrames.filter((f) => f.scene === name).length,
|
|
199
|
+
...title ? { title } : {},
|
|
200
|
+
...description ? { description } : {},
|
|
201
|
+
...brief && !strip ? { brief } : {}
|
|
202
|
+
})),
|
|
203
|
+
frames: pubFrames
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** The folder registry: absent = no folders (boards still imply theirs); malformed fails the build. */
|
|
207
|
+
function readFolders(root) {
|
|
208
|
+
const reg = readRegistry(join(root, "design", "boards"));
|
|
209
|
+
if (reg.state === "malformed") throw new Error(reg.error);
|
|
210
|
+
return reg.folders;
|
|
211
|
+
}
|
|
167
212
|
/**
|
|
168
213
|
* The generated registry with OPAQUE keys - the source strip's half of the
|
|
169
214
|
* bundle (01-sharing §6.2). Object keys are string literals that survive
|
|
@@ -267,18 +312,17 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
267
312
|
const pkgDir = packageDir();
|
|
268
313
|
const clientDir = join(pkgDir, "src", "client");
|
|
269
314
|
const outDir = join(root, "design", ".dist");
|
|
270
|
-
const manifest = scanFrames(root
|
|
315
|
+
const manifest = scanFrames(root, {
|
|
316
|
+
name: config.share.name || basename(root),
|
|
317
|
+
description: config.description
|
|
318
|
+
});
|
|
271
319
|
const allBoards = readBoards(root);
|
|
272
320
|
const policy = resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag);
|
|
273
321
|
const rights = Object.fromEntries(Object.entries(policy.boards).map(([n, p]) => [n, p.max]));
|
|
274
322
|
const strip = !policy.reveal.source;
|
|
275
323
|
const buildSalt = randomBytes(16).toString("hex");
|
|
276
324
|
const opaquePath = (id, ext) => `__mv/f/mv${createHash("sha256").update(buildSalt).update(id).digest("hex").slice(0, 12)}${ext}`;
|
|
277
|
-
const
|
|
278
|
-
const o = allBoards[n]?.order;
|
|
279
|
-
return typeof o === "number" && Number.isFinite(o) ? o : Infinity;
|
|
280
|
-
};
|
|
281
|
-
const publishedNames = Object.keys(rights).sort((a, b) => a === "all-scenes" ? 1 : b === "all-scenes" ? -1 : boardOrder(a) - boardOrder(b) || a.localeCompare(b));
|
|
325
|
+
const { tree, names: publishedNames } = publishedTree(Object.keys(rights), allBoards, readFolders(root));
|
|
282
326
|
const includeAll = publishedNames.includes("all-scenes");
|
|
283
327
|
const boards = {};
|
|
284
328
|
for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
|
|
@@ -288,17 +332,10 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
288
332
|
for (const b of Object.values(boards)) for (const node of Array.isArray(b?.nodes) ? b.nodes : []) if (typeof node?.frame === "string") wanted.add(node.frame);
|
|
289
333
|
frames = manifest.frames.filter((f) => wanted.has(f.id));
|
|
290
334
|
}
|
|
291
|
-
const
|
|
335
|
+
const pubManifest = publishedManifest(manifest, strip ? frames.map((f) => ({
|
|
292
336
|
...f,
|
|
293
337
|
file: opaquePath(f.id, f.kind === "html" ? ".html" : "")
|
|
294
|
-
})) : frames;
|
|
295
|
-
const pubManifest = {
|
|
296
|
-
frames: pubFrames,
|
|
297
|
-
scenes: [...new Set(pubFrames.map((f) => f.scene))].sort().map((name) => ({
|
|
298
|
-
name,
|
|
299
|
-
frames: pubFrames.filter((f) => f.scene === name).length
|
|
300
|
-
}))
|
|
301
|
-
};
|
|
338
|
+
})) : frames, publishedNames, strip);
|
|
302
339
|
for (const n of publishedNames) {
|
|
303
340
|
const pb = policy.boards[n];
|
|
304
341
|
if (pb?.type !== "slides" && pb?.open !== "slides") continue;
|
|
@@ -326,10 +363,16 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
326
363
|
"slides"
|
|
327
364
|
].includes(p.open);
|
|
328
365
|
});
|
|
366
|
+
const titles = Object.fromEntries(publishedNames.flatMap((n) => {
|
|
367
|
+
const t = boardFields(allBoards[n], isBoardName).title;
|
|
368
|
+
return t ? [[n, t]] : [];
|
|
369
|
+
}));
|
|
329
370
|
const data = {
|
|
330
371
|
manifest: pubManifest,
|
|
331
372
|
boards,
|
|
332
373
|
names: publishedNames,
|
|
374
|
+
tree,
|
|
375
|
+
...Object.keys(titles).length ? { titles } : {},
|
|
333
376
|
default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
|
|
334
377
|
rights,
|
|
335
378
|
policy: {
|
|
@@ -461,7 +504,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
461
504
|
const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
|
|
462
505
|
for (const r of refs) {
|
|
463
506
|
if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
|
|
464
|
-
const { ensurePoster } = await import("./poster-
|
|
507
|
+
const { ensurePoster } = await import("./poster-CoyobbGW.mjs");
|
|
465
508
|
const g = await ensurePoster(assetsDir, r.slice(0, -11));
|
|
466
509
|
if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
|
|
467
510
|
console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
|