@marver-design/marver 0.18.0 → 0.19.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/CHANGELOG.md +28 -0
- package/README.md +2 -1
- package/dist/{bake-jr38C_pX.mjs → bake-kaf5kGZ7.mjs} +1 -1
- package/dist/{build-ER_7T6Cw.mjs → build-7ed5H2vT.mjs} +40 -7
- package/dist/cli.mjs +3 -3
- package/dist/{daemon-CKhg0zuT.mjs → daemon-DbHvLQUL.mjs} +1 -1
- package/dist/{dev-BvLbY98O.mjs → dev-D3mP2x27.mjs} +6 -5
- package/dist/{init-BWbHqVng.mjs → init-BQYCS3EU.mjs} +1 -1
- package/dist/{manifest-DAnEL8_a.mjs → manifest-B01PSyDc.mjs} +33 -6
- package/dist/{plugin-Cai5C1WV.mjs → plugin-DI-7NAnx.mjs} +9 -9
- package/dist/{poster-CIuz_PwH.mjs → poster-DNh6N27C.mjs} +1 -1
- package/dist/{publish-bakes-D0LhbQQ3.mjs → publish-bakes-Dp-ZFk3d.mjs} +2 -2
- package/dist/{shot-iicees2e.mjs → shot-DMDvDbeP.mjs} +2 -2
- package/docs/sticky-notes.md +43 -0
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +27 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +48 -0
- package/src/client/shell/App.tsx +7 -63
- package/src/client/shell/Comments.tsx +75 -12
- package/src/client/shell/Play.tsx +6 -3
- package/src/client/shell/canvas/Canvas.tsx +10 -1
- package/src/client/shell/canvas/FrameNode.tsx +17 -0
- package/src/client/shell/canvas/Sticky.tsx +284 -0
- package/src/client/shell/goto.ts +72 -0
- package/src/client/shell/notes.ts +151 -0
- package/src/client/shell/store.ts +22 -18
- package/src/client/shell/styles.css +83 -0
- package/src/client/shell/tidy.ts +20 -2
- package/templates/AGENTS-embedded.md +15 -0
- package/templates/AGENTS-studio.md +15 -0
- package/templates/instructions/shape.md +62 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
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.19.0 - 2026-09-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Sticky notes.** A markdown file beside a frame (`cart.note.md` next to `cart.tsx`, `.jsx` or
|
|
10
|
+
`.html`) or a scene (`_note.md` in the scene directory) renders as a yellow note left of the
|
|
11
|
+
frame on the canvas - the scene's on the scene's first frame of the board, above the frame's
|
|
12
|
+
own. Markdown with the Md block's rules (`goto:` links jump to a frame, images from
|
|
13
|
+
`design/assets/`, raw HTML inert) and ` ```mermaid ` fences drawn hand-drawn. Comments pin on a
|
|
14
|
+
note's elements as on a frame's; a folded note parks its pins on its tab. Tidy keeps room for
|
|
15
|
+
notes. Every viewer folds a note at its corner and hides all with `N`; the choice stays in the
|
|
16
|
+
browser, never in the board file. Published and shared canvases carry the notes, their images
|
|
17
|
+
copied like any Md image. An edit to a note file updates the sticky in place; the frame is never
|
|
18
|
+
reloaded. Every diagram family wears the note's look - flowchart, sequence, state, class, ER,
|
|
19
|
+
pie, mindmap, timeline, gantt, journey, quadrant, git graph, block: hand-sketched boxes, one
|
|
20
|
+
yellow palette, handwriting labels (the Diagram block in content frames keeps its own theme).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- The manifest carries `note` on frames and scenes; the dev watcher regenerates it when a note
|
|
25
|
+
file changes.
|
|
26
|
+
- The agent contract (`design/AGENTS.md`, refreshed by `npx marver init`) and
|
|
27
|
+
`instructions/shape.md` teach sticky notes: what one is, when to write one, what it supports
|
|
28
|
+
(markdown, tables, `goto:` links, images, hand-drawn mermaid) and how it works.
|
|
29
|
+
- Markdown in content frames: text inside a raw `<script>`/`<style>` block is now escaped like
|
|
30
|
+
every other text, and the rendered HTML passes a tag and attribute allowlist before insertion.
|
|
31
|
+
Diagram source is judged after decoding escapes; image shapes and directives are refused.
|
|
32
|
+
|
|
5
33
|
## 0.18.0 - 2026-09-07
|
|
6
34
|
|
|
7
35
|
### Changed
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Screens, prototypes, specs, and now slide decks - all real code, all on one canvas, all shareable with people who sign in as themselves.
|
|
10
10
|
|
|
11
|
-
[marver.design](https://marver.design) · [Slides](docs/slides.md) · [Live Jam](docs/live-jam.md) · [Deploying a canvas](docs/publish.md) · [Sharing](docs/sharing.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Issues](https://github.com/TNEP4/marver/issues)
|
|
11
|
+
[marver.design](https://marver.design) · [Slides](docs/slides.md) · [Sticky notes](docs/sticky-notes.md) · [Live Jam](docs/live-jam.md) · [Deploying a canvas](docs/publish.md) · [Sharing](docs/sharing.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Issues](https://github.com/TNEP4/marver/issues)
|
|
12
12
|
|
|
13
13
|
## Quickstart
|
|
14
14
|
|
|
@@ -45,6 +45,7 @@ Frames appear on the canvas the moment the files land. That's the loop.
|
|
|
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.
|
|
47
47
|
- **Content frames.** Specs, Mermaid diagrams, mood boards, and slides live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img`, `Slide`, `Chart`, `Video` from `@marver-design/marver/content` and think a feature through before any pixels exist. Works in a repo with no app at all: idea first, design second.
|
|
48
|
+
- **Sticky notes.** A markdown file beside a frame (`cart.note.md`) or a scene (`_note.md`) becomes a yellow note left of the frame on the canvas - what it is for, how two variations differ, an open question. Markdown, `goto:` links to frames, hand-drawn Mermaid; readers comment on a note's text like on a frame's, fold it to its corner, hide them all with `n`. Published canvases carry them.
|
|
48
49
|
- **Hi-fi at rest.** A frame at rest is its own live document, asleep: animations paused and every `backdrop-filter` element painted with a certified texture of its filtered backdrop (compiled by the dev server's headless Chrome), so a board of 30 glass screens pans like 30 statics and wakes pixel-identical on interact. Glass inside glass, blend modes and frames whose paint depends on random data or the clock stay live; `?awake=1` keeps every frame live for comparison.
|
|
49
50
|
- **Charts and video in any frame.** `Chart` (Apache ECharts, SVG, still at rest) inherits the ink, typeface and accent of whatever frame it sits in - a Tailwind dashboard, a dark spec, a slide - sizes its type to the context and follows the layout on resize. `Video` is poster-first everywhere: click to play wherever the frame is live, `autoplay` for an ambient loop, `ratio` for vertical clips; omit the poster and marver renders one from the clip. A screen with a chart or a clip is still a screen.
|
|
50
51
|
- **Copy as image.** Select a frame, press `i` - a 2x PNG of it lands on the clipboard, rendered by the same headless Chrome that serves `marver shot`; `⇧i` for 4x (a slide is 5120×2880). Paste into Slack, a doc, or a chat with your agent.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AREA, SURFACE, pool, shotConcurrency, withBrowser } from "./shot-
|
|
1
|
+
import { AREA, SURFACE, pool, shotConcurrency, withBrowser } from "./shot-DMDvDbeP.mjs";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, sep } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { a as ROUTE, r as NAME } from "./cli.mjs";
|
|
2
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 {
|
|
4
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
3
|
+
import { c as loadConfig, r as scanFrames, u as detectHost } from "./manifest-B01PSyDc.mjs";
|
|
4
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DI-7NAnx.mjs";
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join, sep } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
9
9
|
import { build } from "vite";
|
|
10
10
|
import react from "@vitejs/plugin-react";
|
|
11
|
+
import { Marked } from "marked";
|
|
11
12
|
//#region src/server/css-fix.ts
|
|
12
13
|
/** Every `-webkit-backdrop-filter` declaration (the property itself, never a custom property that
|
|
13
14
|
* happens to end in it) is mirrored by the standard one right after it, in order, importance and
|
|
@@ -193,7 +194,37 @@ function scanAssetRefs(src, moduleId) {
|
|
|
193
194
|
}
|
|
194
195
|
if (posterM) out.push(posterM[1]);
|
|
195
196
|
}
|
|
196
|
-
for (const tpl of templates)
|
|
197
|
+
for (const tpl of templates) out.push(...markdownImageRefs(tpl.slice(1, -1)));
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
/** Markdown images in prose, through the parser: inline and reference-style, titles and
|
|
201
|
+
* parentheses in paths handled, code never counted. Decoded the way the client's assetUrl
|
|
202
|
+
* decodes (one pass; a path still carrying `%` is refused there and skipped here). Md content
|
|
203
|
+
* inside frames and sticky notes alike. */
|
|
204
|
+
function markdownImageRefs(md) {
|
|
205
|
+
const out = [];
|
|
206
|
+
const walk = (tokens) => {
|
|
207
|
+
for (const t of tokens) {
|
|
208
|
+
if (t.type === "image" && typeof t.href === "string") {
|
|
209
|
+
let p = t.href.trim();
|
|
210
|
+
try {
|
|
211
|
+
p = decodeURIComponent(p);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!p.includes("%")) out.push(p);
|
|
216
|
+
}
|
|
217
|
+
if (t.type !== "code" && t.type !== "codespan") {
|
|
218
|
+
if (Array.isArray(t.tokens)) walk(t.tokens);
|
|
219
|
+
if (Array.isArray(t.items)) walk(t.items);
|
|
220
|
+
if (Array.isArray(t.rows)) for (const row of t.rows) walk(row.map((c) => ({ tokens: c.tokens })));
|
|
221
|
+
if (Array.isArray(t.header)) walk(t.header.map((c) => ({ tokens: c.tokens })));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
walk(new Marked({ gfm: true }).lexer(md));
|
|
227
|
+
} catch {}
|
|
197
228
|
return out;
|
|
198
229
|
}
|
|
199
230
|
/** Same shape the client's assetUrl accepts: relative, inside design/assets/, no tricks. */
|
|
@@ -335,12 +366,13 @@ function publishedManifest(manifest, pubFrames, publishedNames, strip) {
|
|
|
335
366
|
...manifest.project ? { project: manifest.project } : {},
|
|
336
367
|
...pubFolders.length ? { folders: pubFolders } : {},
|
|
337
368
|
...pubBoards.length ? { boards: pubBoards } : {},
|
|
338
|
-
scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, title, description, brief }) => ({
|
|
369
|
+
scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, title, description, brief, note }) => ({
|
|
339
370
|
name,
|
|
340
371
|
frames: pubFrames.filter((f) => f.scene === name).length,
|
|
341
372
|
...title ? { title } : {},
|
|
342
373
|
...description ? { description } : {},
|
|
343
|
-
...brief && !strip ? { brief } : {}
|
|
374
|
+
...brief && !strip ? { brief } : {},
|
|
375
|
+
...note ? { note } : {}
|
|
344
376
|
})),
|
|
345
377
|
frames: pubFrames
|
|
346
378
|
};
|
|
@@ -646,11 +678,12 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds, textures =
|
|
|
646
678
|
const rel = file.startsWith(rootP + "/") ? file.slice(rootP.length + 1) : file;
|
|
647
679
|
for (const r of scanAssetRefs(src, rel)) refs.add(r);
|
|
648
680
|
}
|
|
681
|
+
for (const note of [...pubManifest.frames.map((f) => f.note), ...pubManifest.scenes.map((s) => s.note)]) if (note) for (const r of markdownImageRefs(note)) refs.add(r);
|
|
649
682
|
let copiedAssets = 0;
|
|
650
683
|
const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
|
|
651
684
|
for (const r of refs) {
|
|
652
685
|
if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
|
|
653
|
-
const { ensurePoster } = await import("./poster-
|
|
686
|
+
const { ensurePoster } = await import("./poster-DNh6N27C.mjs");
|
|
654
687
|
const g = await ensurePoster(assetsDir, r.slice(0, -11));
|
|
655
688
|
if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
|
|
656
689
|
console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
|
|
@@ -717,7 +750,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds, textures =
|
|
|
717
750
|
}));
|
|
718
751
|
let textureLine = " textures: skipped (--no-textures)";
|
|
719
752
|
if (textures && bakeGen) {
|
|
720
|
-
const { bakePublished } = await import("./publish-bakes-
|
|
753
|
+
const { bakePublished } = await import("./publish-bakes-Dp-ZFk3d.mjs");
|
|
721
754
|
const pubFrameFile = new Map(pubFrames.map((f) => [f.id, f]));
|
|
722
755
|
const r = await bakePublished({
|
|
723
756
|
root,
|
package/dist/cli.mjs
CHANGED
|
@@ -72,14 +72,14 @@ const resolve$1 = (dir) => {
|
|
|
72
72
|
};
|
|
73
73
|
const cli = cac(NAME);
|
|
74
74
|
cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
75
|
-
const { init } = await import("./init-
|
|
75
|
+
const { init } = await import("./init-BQYCS3EU.mjs");
|
|
76
76
|
init(resolve$1(opts.root), {
|
|
77
77
|
mode: opts.mode === "embedded" ? "embedded" : "studio",
|
|
78
78
|
demo: opts.demo !== false
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
81
|
for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
|
|
82
|
-
const { dev } = await import("./dev-
|
|
82
|
+
const { dev } = await import("./dev-D3mP2x27.mjs");
|
|
83
83
|
let port;
|
|
84
84
|
if (opts.port !== void 0) {
|
|
85
85
|
const n = Number(opts.port);
|
|
@@ -89,7 +89,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
|
|
|
89
89
|
await dev(resolve$1(opts.root), port);
|
|
90
90
|
});
|
|
91
91
|
cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--embed-seeds", "Copy comment history INTO the web root (identifying - every event carries its author's email)").option("--no-textures", "Skip compiling the glass textures the published frames rest under (needs Chrome; a CI without one skips on its own)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
92
|
-
const { buildSite } = await import("./build-
|
|
92
|
+
const { buildSite } = await import("./build-7ed5H2vT.mjs");
|
|
93
93
|
try {
|
|
94
94
|
const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
|
|
95
95
|
await buildSite(resolve$1(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true, opts.textures !== false && !process.env.MARVER_NO_TEXTURES);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as replay } from "./events-B3LBn74P.mjs";
|
|
2
2
|
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
|
|
3
3
|
import { n as localProfile } from "./profile-BjAPAJSb.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { a as toFrameId } from "./manifest-B01PSyDc.mjs";
|
|
5
5
|
import { workActivity } from "./work-CLrmY-vQ.mjs";
|
|
6
6
|
import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
|
|
7
7
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { i as PKG, r as NAME } from "./cli.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
2
|
+
import { c as loadConfig, u as detectHost } from "./manifest-B01PSyDc.mjs";
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DI-7NAnx.mjs";
|
|
4
4
|
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -437,6 +437,7 @@ async function dev(root, portFlag) {
|
|
|
437
437
|
"react/jsx-dev-runtime",
|
|
438
438
|
`${PKG} > marked`,
|
|
439
439
|
`${PKG} > mermaid`,
|
|
440
|
+
`${PKG} > roughjs`,
|
|
440
441
|
`${PKG} > html-to-image`,
|
|
441
442
|
...iconModules(root)
|
|
442
443
|
],
|
|
@@ -451,12 +452,12 @@ async function dev(root, portFlag) {
|
|
|
451
452
|
console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
|
|
452
453
|
setTimeout(async () => {
|
|
453
454
|
try {
|
|
454
|
-
await (await import("./shot-
|
|
455
|
+
await (await import("./shot-DMDvDbeP.mjs")).sweepGhosts((m) => console.log(` ${m}`));
|
|
455
456
|
} catch {}
|
|
456
457
|
}, 0);
|
|
457
458
|
try {
|
|
458
459
|
const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
|
|
459
|
-
const { installedVersion } = await import("./plugin-
|
|
460
|
+
const { installedVersion } = await import("./plugin-DI-7NAnx.mjs").then((n) => n.i);
|
|
460
461
|
const stale = staleManagedInstructions(root);
|
|
461
462
|
if (stale.length) {
|
|
462
463
|
const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
|
|
@@ -494,7 +495,7 @@ async function dev(root, portFlag) {
|
|
|
494
495
|
});
|
|
495
496
|
}
|
|
496
497
|
if (config.jam) {
|
|
497
|
-
const { startJam } = await import("./daemon-
|
|
498
|
+
const { startJam } = await import("./daemon-DbHvLQUL.mjs");
|
|
498
499
|
const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
|
|
499
500
|
if (jam) {
|
|
500
501
|
const close = server.close.bind(server);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as NAME } from "./cli.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { d as readJson, l as detectAgent, o as writeManifest, r as scanFrames, s as DEFAULTS, u as detectHost } from "./manifest-B01PSyDc.mjs";
|
|
3
3
|
import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, relative } from "node:path";
|
|
@@ -375,6 +375,23 @@ function validViewports(v) {
|
|
|
375
375
|
//#region src/server/manifest.ts
|
|
376
376
|
const FRAME_EXT = /\.(tsx|jsx|html)$/;
|
|
377
377
|
const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
|
|
378
|
+
/** Sticky notes (spec 18): the longest note the manifest carries - a note is an aside, not a spec. */
|
|
379
|
+
const NOTE_MAX = 2e4;
|
|
380
|
+
/** The note file beside a frame file: `cart.tsx` -> `cart.note.md`; a scene's is `_note.md`. */
|
|
381
|
+
const noteFileFor = (frameFile) => frameFile.replace(FRAME_EXT, ".note.md");
|
|
382
|
+
/** Is this path a note file - the dev watcher's "regenerate on change" predicate. */
|
|
383
|
+
const isNoteFile = (file) => /(^|[\\/])(_note|[^\\/]+\.note)\.md$/.test(file);
|
|
384
|
+
/** A note's text: BOM stripped, trimmed, capped at NOTE_MAX. Undefined when the file is missing,
|
|
385
|
+
* empty, or not a regular file (a symlink is never followed - the `_brief.md` rule). */
|
|
386
|
+
function readNote(abs) {
|
|
387
|
+
try {
|
|
388
|
+
if (!lstatSync(abs).isFile()) return void 0;
|
|
389
|
+
const text = readFileSync(abs, "utf8").replace(/^/, "").trim();
|
|
390
|
+
return text ? text.slice(0, NOTE_MAX) : void 0;
|
|
391
|
+
} catch {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
378
395
|
/** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
|
|
379
396
|
function extractMeta(src) {
|
|
380
397
|
const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
|
|
@@ -526,6 +543,10 @@ function sceneBrief(root, scene) {
|
|
|
526
543
|
...description ? { description } : {}
|
|
527
544
|
};
|
|
528
545
|
}
|
|
546
|
+
/** A scene's sticky note: `design/scenes/<scene>/_note.md` (spec 18). */
|
|
547
|
+
function sceneNote(root, scene) {
|
|
548
|
+
return scene ? readNote(join(root, "design", "scenes", scene, "_note.md")) : void 0;
|
|
549
|
+
}
|
|
529
550
|
/** Write a scene's title into its brief's front matter - only that line changes; the body,
|
|
530
551
|
* every other field and the file's own line endings are kept. No brief yet = a front-matter-only
|
|
531
552
|
* brief. An empty title removes the line, and the file when nothing else is in it. The write
|
|
@@ -649,6 +670,8 @@ function scanFrames(root, project) {
|
|
|
649
670
|
kind,
|
|
650
671
|
scene
|
|
651
672
|
};
|
|
673
|
+
const note = readNote(noteFileFor(abs));
|
|
674
|
+
if (note) entry.note = note;
|
|
652
675
|
if (kind === "tsx") {
|
|
653
676
|
const src = readFileSync(abs, "utf8");
|
|
654
677
|
const meta = extractMeta(src);
|
|
@@ -684,11 +707,15 @@ function scanFrames(root, project) {
|
|
|
684
707
|
inferVariantGroups(frames);
|
|
685
708
|
const sceneCounts = /* @__PURE__ */ new Map();
|
|
686
709
|
for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
|
|
687
|
-
const scenes = [...sceneCounts.entries()].map(([name, n]) =>
|
|
688
|
-
name
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
710
|
+
const scenes = [...sceneCounts.entries()].map(([name, n]) => {
|
|
711
|
+
const note = sceneNote(root, name);
|
|
712
|
+
return {
|
|
713
|
+
name,
|
|
714
|
+
frames: n,
|
|
715
|
+
...sceneBrief(root, name),
|
|
716
|
+
...note ? { note } : {}
|
|
717
|
+
};
|
|
718
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
692
719
|
return {
|
|
693
720
|
...project && (project.name || project.description) ? { project: {
|
|
694
721
|
...project.name ? { name: project.name } : {},
|
|
@@ -779,4 +806,4 @@ function writeManifest(root, manifest) {
|
|
|
779
806
|
return true;
|
|
780
807
|
}
|
|
781
808
|
//#endregion
|
|
782
|
-
export {
|
|
809
|
+
export { toFrameId as a, loadConfig as c, readJson as d, setSceneTitle as i, detectAgent as l, isNoteFile as n, writeManifest as o, scanFrames as r, DEFAULTS as s, affectedFrameIds as t, detectHost as u };
|
|
@@ -2,7 +2,7 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
|
2
2
|
import { a as ROUTE, i as PKG, r as NAME } from "./cli.mjs";
|
|
3
3
|
import { a as listBoardFiles, c as BOARD_NAME, g as hash, h as validateWire, i as isRegularFile, l as FOLDERS_FILE, m as readTitle, n as checkBoardsDir, o as nodeExists, p as readDescription, s as readRegistry, t as boardFields } from "./boards-BmxcT3Lc.mjs";
|
|
4
4
|
import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { c as loadConfig, i as setSceneTitle, n as isNoteFile, o as writeManifest, r as scanFrames, t as affectedFrameIds } from "./manifest-B01PSyDc.mjs";
|
|
6
6
|
import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
7
7
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -422,7 +422,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
422
422
|
};
|
|
423
423
|
const asPng = url.searchParams.get("format") === "png";
|
|
424
424
|
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
425
|
-
const { shootFrame } = await import("./shot-
|
|
425
|
+
const { shootFrame } = await import("./shot-DMDvDbeP.mjs");
|
|
426
426
|
const r = await shootFrame({
|
|
427
427
|
root,
|
|
428
428
|
viewports: opts.viewports ?? {},
|
|
@@ -472,7 +472,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
472
472
|
if (typeof theme !== "string" || !/^[a-z0-9-]+$/i.test(theme)) return json(res, 400, { error: "invalid theme" });
|
|
473
473
|
const scale = body.scale === void 0 ? void 0 : body.scale;
|
|
474
474
|
if (scale !== void 0 && !(Number.isInteger(scale) && scale >= 1 && scale <= 4)) return json(res, 400, { error: "invalid scale" });
|
|
475
|
-
const { resolveFrames, shootBatch } = await import("./shot-
|
|
475
|
+
const { resolveFrames, shootBatch } = await import("./shot-DMDvDbeP.mjs");
|
|
476
476
|
const sel = resolveFrames(root, body);
|
|
477
477
|
if (!sel.ok) return json(res, sel.status, { error: sel.error });
|
|
478
478
|
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
@@ -500,7 +500,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
500
500
|
return json(res, 400, { error: "malformed JSON" });
|
|
501
501
|
}
|
|
502
502
|
if (!body || typeof body !== "object" || !Array.isArray(body.asks) || !body.asks.length || body.asks.length > 200) return json(res, 400, { error: "asks must list 1-200 frames" });
|
|
503
|
-
const { bakeBatch, ASK_MAX } = await import("./bake-
|
|
503
|
+
const { bakeBatch, ASK_MAX } = await import("./bake-kaf5kGZ7.mjs");
|
|
504
504
|
let manifest = {};
|
|
505
505
|
try {
|
|
506
506
|
manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
|
|
@@ -544,8 +544,8 @@ function apiMiddleware(root, opts = {}) {
|
|
|
544
544
|
if (path === "poster" && req.method === "GET") {
|
|
545
545
|
if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
|
|
546
546
|
const src = url.searchParams.get("src") ?? "";
|
|
547
|
-
const { ensurePoster, isLocalClip } = await import("./poster-
|
|
548
|
-
const { isLocalAssetRef } = await import("./build-
|
|
547
|
+
const { ensurePoster, isLocalClip } = await import("./poster-DNh6N27C.mjs");
|
|
548
|
+
const { isLocalAssetRef } = await import("./build-7ed5H2vT.mjs");
|
|
549
549
|
if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
|
|
550
550
|
const r = await ensurePoster(join(root, "design", "assets"), src);
|
|
551
551
|
if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
|
|
@@ -1027,7 +1027,7 @@ function marverPlugin(ctx) {
|
|
|
1027
1027
|
};
|
|
1028
1028
|
const prune = () => setTimeout(async () => {
|
|
1029
1029
|
try {
|
|
1030
|
-
(await import("./bake-
|
|
1030
|
+
(await import("./bake-kaf5kGZ7.mjs")).pruneBakes(root, bakeGen);
|
|
1031
1031
|
} catch {}
|
|
1032
1032
|
}, 500);
|
|
1033
1033
|
prune();
|
|
@@ -1046,7 +1046,7 @@ function marverPlugin(ctx) {
|
|
|
1046
1046
|
rescanTheme();
|
|
1047
1047
|
}
|
|
1048
1048
|
});
|
|
1049
|
-
server.watcher.on("change", (f) => inScope(f) && (/\.(tsx|jsx)$/.test(f) || f.endsWith("_brief.md")) && regen());
|
|
1049
|
+
server.watcher.on("change", (f) => inScope(f) && (/\.(tsx|jsx)$/.test(f) || f.endsWith("_brief.md") || isNoteFile(f)) && regen());
|
|
1050
1050
|
const configFile = join(root, "design", "config.ts");
|
|
1051
1051
|
server.watcher.on("change", (f) => {
|
|
1052
1052
|
if (f !== configFile) return;
|
|
@@ -1166,7 +1166,7 @@ function marverPlugin(ctx) {
|
|
|
1166
1166
|
});
|
|
1167
1167
|
return;
|
|
1168
1168
|
}
|
|
1169
|
-
const { shootFrame, shootBatch, resolveFrames } = await import("./shot-
|
|
1169
|
+
const { shootFrame, shootBatch, resolveFrames } = await import("./shot-DMDvDbeP.mjs");
|
|
1170
1170
|
if (ways[0] === "frame") {
|
|
1171
1171
|
if (typeof spec.frame !== "string") {
|
|
1172
1172
|
write({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capture, t as findChrome } from "./shot-
|
|
1
|
+
import { capture, t as findChrome } from "./shot-DMDvDbeP.mjs";
|
|
2
2
|
import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, sep } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { planShot, t as findChrome } from "./shot-
|
|
2
|
-
import { ASK_MAX, bakeBatch } from "./bake-
|
|
1
|
+
import { planShot, t as findChrome } from "./shot-DMDvDbeP.mjs";
|
|
2
|
+
import { ASK_MAX, bakeBatch } from "./bake-kaf5kGZ7.mjs";
|
|
3
3
|
import { MIME } from "./serve-z5qtj_wJ.mjs";
|
|
4
4
|
import { copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -337,8 +337,8 @@ async function shootFrame(opts) {
|
|
|
337
337
|
};
|
|
338
338
|
const plan = planShot(frame, viewports, size);
|
|
339
339
|
if (frame.kind !== "html") try {
|
|
340
|
-
const { scanAssetRefs } = await import("./build-
|
|
341
|
-
const { ensurePoster } = await import("./poster-
|
|
340
|
+
const { scanAssetRefs } = await import("./build-7ed5H2vT.mjs");
|
|
341
|
+
const { ensurePoster } = await import("./poster-DNh6N27C.mjs");
|
|
342
342
|
const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
|
|
343
343
|
for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
|
|
344
344
|
} catch {}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Sticky notes
|
|
2
|
+
|
|
3
|
+
A sticky note is the aside beside a frame: a yellow note left of it on the canvas, in dev and on
|
|
4
|
+
every published or shared canvas, that says what the screen cannot - what the frame is for, how
|
|
5
|
+
two variations differ, how a mechanism works, an open question. Reviewers read it, click its links,
|
|
6
|
+
and comment on its text exactly as they comment on a frame.
|
|
7
|
+
|
|
8
|
+
## Writing one
|
|
9
|
+
|
|
10
|
+
One markdown file, nothing to declare:
|
|
11
|
+
|
|
12
|
+
| For | File |
|
|
13
|
+
|---|---|
|
|
14
|
+
| a frame `checkout/cart` (`cart.tsx`, `.jsx` or `.html`) | `design/scenes/checkout/cart.note.md` |
|
|
15
|
+
| a scene `checkout` | `design/scenes/checkout/_note.md` (beside the scene's first frame on the board) |
|
|
16
|
+
| a component frame | `design/components/<name>.note.md` |
|
|
17
|
+
|
|
18
|
+
Markdown with the Md block's rules: headings, lists, tables, emphasis, code; `[text](goto:scene/frame)`
|
|
19
|
+
links jump to a frame; `http(s)` links open a new tab; images come from `design/assets/`; raw HTML is
|
|
20
|
+
inert. A scene note is 380 wide, a frame note 260; when a frame has both, they stack in one column,
|
|
21
|
+
scene first. An edit lands on the canvas as you save, without reloading the frame.
|
|
22
|
+
|
|
23
|
+
## Diagrams
|
|
24
|
+
|
|
25
|
+
A ```mermaid fence renders hand-sketched on the paper: rough boxes, hatched fills, handwriting
|
|
26
|
+
labels, one ink. Write plain mermaid - no `%%{init}%%`, theme, colours or `style` lines, no URLs or
|
|
27
|
+
images in the source. Every family works; flowchart, sequence, state, class, ER, pie and mindmap
|
|
28
|
+
read best at note width, while gantt, journey, timeline, quadrant and git graphs are wide by nature
|
|
29
|
+
and want few items. This look is the note's alone: the `Diagram` block in content frames keeps its
|
|
30
|
+
own theme.
|
|
31
|
+
|
|
32
|
+
## Reading one
|
|
33
|
+
|
|
34
|
+
- Fold: the dog-ear at the note's corner folds the column to a tab; the tab brings it back.
|
|
35
|
+
- `N` hides every note on the canvas and shows them again.
|
|
36
|
+
- Both are per viewer, in the browser - never saved to the board, never shared.
|
|
37
|
+
- Comments: in comment mode (`C`) click any element of a note; the pin sits on the note, follows a
|
|
38
|
+
fold onto the tab, and the thread card opens beside the column.
|
|
39
|
+
|
|
40
|
+
## What it is not
|
|
41
|
+
|
|
42
|
+
A note is an aside, a screen's worth of reading at most. Specs, flows and mood boards stay content
|
|
43
|
+
frames on the board.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
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 - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"marked": "^16.0.0",
|
|
54
54
|
"mermaid": "^11.6.0",
|
|
55
55
|
"react-zoom-pan-pinch": "^3.6.1",
|
|
56
|
+
"roughjs": "^4.6.6",
|
|
56
57
|
"vite": "^8.0.0",
|
|
57
58
|
"zustand": "^5.0.0"
|
|
58
59
|
},
|
|
@@ -67,6 +67,32 @@ export function withFamilies(src: string): string {
|
|
|
67
67
|
return `${src}\n${defs}`
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** The zero-external-request boundary, BEFORE render: mermaid's image shapes fetch their URL
|
|
71
|
+
* during render(), so post-render SVG sanitizing alone is too late. Rejected: any URL shape
|
|
72
|
+
* (scheme + // or scheme + \\, protocol-relative //), and the `img:` shape data of the
|
|
73
|
+
* flowchart node syntax `A@{ img: ... }` - the one construct that loads a resource at all. */
|
|
74
|
+
export function guardDiagramSource(src: string): void {
|
|
75
|
+
// judge the DECODED text: mermaid resolves \uXXXX, \xXX and HTML entities in shape data and
|
|
76
|
+
// labels before it acts on them, so an escaped `//` or a quoted, escaped `"img"` key is the
|
|
77
|
+
// same request in the end. Directives and front matter are gone by now (cleanSource) - a
|
|
78
|
+
// themeCSS with url() never reaches the renderer either way, url( is refused here too.
|
|
79
|
+
const text = decodeEscapes(src)
|
|
80
|
+
if (/(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(text) || /[a-z][a-z0-9+.-]*:\\/i.test(text)) throw new Error('URLs are not allowed in diagram source - use local design/assets/ images in an Img block instead')
|
|
81
|
+
if (/url\s*\(/i.test(text) || /@import\b/i.test(text)) throw new Error('external resources are not allowed in diagram source')
|
|
82
|
+
if (/@\s*\{/.test(text) && /["'`]?\s*img\s*["'`]?\s*:/i.test(text)) throw new Error('image shapes are not allowed in diagram source')
|
|
83
|
+
if (/%%\s*\{/.test(text) || /^\s*---/.test(text)) throw new Error('directives are not allowed in diagram source')
|
|
84
|
+
}
|
|
85
|
+
/** \uXXXX, \u{...}, \xXX and numeric/named HTML entities -> the characters they stand for. */
|
|
86
|
+
export function decodeEscapes(src: string): string {
|
|
87
|
+
return src
|
|
88
|
+
.replace(/\\u\{([0-9a-f]{1,6})\}/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
89
|
+
.replace(/\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
|
90
|
+
.replace(/\\x([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
|
91
|
+
.replace(/&#x([0-9a-f]{1,6});/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
92
|
+
.replace(/&#(\d{1,7});/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
93
|
+
.replace(///gi, '/').replace(/\/gi, '\\').replace(/:/gi, ':').replace(/"/gi, '"').replace(/'/gi, "'")
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
/** Remove external URL references from rendered SVG (images, links, href attrs). */
|
|
71
97
|
export function sanitizeSvg(svg: string): string {
|
|
72
98
|
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
|
@@ -100,7 +126,7 @@ export function Diagram({ title, children }: { title?: string; children?: ReactN
|
|
|
100
126
|
// shapes fetch their URL during render(), so post-render SVG sanitizing alone
|
|
101
127
|
// would be too late. Reject ANY URL shape - absolute (scheme://) and
|
|
102
128
|
// protocol-relative (//host) alike; neither has a place in diagram source.
|
|
103
|
-
|
|
129
|
+
guardDiagramSource(src)
|
|
104
130
|
const mermaid = (await import('mermaid')).default
|
|
105
131
|
if (!live || mySeq !== seq) return
|
|
106
132
|
mermaid.initialize({
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
11
11
|
import { useInSlide } from './slide.tsx'
|
|
12
12
|
import { CONTENT_WIDTH } from '../const.ts'
|
|
13
|
-
import { assetUrl, renderMarkdown, FAMILIES } from './md.ts'
|
|
13
|
+
import { assetUrl, renderMarkdown, sanitizeMarkdownHtml, FAMILIES } from './md.ts'
|
|
14
14
|
import { lodSupported, registerLodImage } from './img-lod.ts'
|
|
15
15
|
|
|
16
16
|
// D3: family color classes for inline Md (`:blue[...]`), theme-aware (frames carry .dark + [data-theme])
|
|
@@ -87,7 +87,7 @@ export function Space({ n = 1 }: { n?: number }) {
|
|
|
87
87
|
export function Md({ children }: { children?: ReactNode }) {
|
|
88
88
|
ensureStyles()
|
|
89
89
|
const src = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? '')
|
|
90
|
-
const html = useMemo(() => renderMarkdown(src), [src])
|
|
90
|
+
const html = useMemo(() => sanitizeMarkdownHtml(renderMarkdown(src)), [src])
|
|
91
91
|
return <div className="mv-md" dangerouslySetInnerHTML={{ __html: html }} />
|
|
92
92
|
}
|
|
93
93
|
|
package/src/client/content/md.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
import { Marked } from 'marked'
|
|
8
8
|
|
|
9
9
|
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
10
|
+
/** Prose text: markup escaped, but an entity the author wrote (`©`, `A`) keeps its
|
|
11
|
+
* meaning - marked's own rule; a raw `<` is still never a tag. */
|
|
12
|
+
const escapeText = (s: string) => s.replace(/&(?!#?\w+;)|[<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
10
13
|
|
|
11
14
|
/** D3: named color families for inline Md - the SAME families the diagrams use, so prose and
|
|
12
15
|
* the diagram beside it speak one color language. Theme pairs (light/dark). Bound to classes,
|
|
@@ -38,6 +41,12 @@ const marked = new Marked({
|
|
|
38
41
|
renderer: {
|
|
39
42
|
// raw HTML (block or inline) renders as its literal text - inert by construction
|
|
40
43
|
html(token: any) { return escapeHtml(String(token.text ?? '')) },
|
|
44
|
+
// marked leaves the text INSIDE a raw block (<script>, <style>, <pre>, <textarea>) unescaped
|
|
45
|
+
// (`token.escaped`); here every text token is escaped - there is no raw block to serve
|
|
46
|
+
text(token: any) {
|
|
47
|
+
if (token.tokens) return this.parser.parseInline(token.tokens)
|
|
48
|
+
return escapeText(String(token.text ?? ''))
|
|
49
|
+
},
|
|
41
50
|
link(token: any) {
|
|
42
51
|
const href = String(token.href ?? '')
|
|
43
52
|
const inner = this.parser.parseInline(token.tokens ?? [])
|
|
@@ -77,3 +86,42 @@ marked.use({
|
|
|
77
86
|
export function renderMarkdown(src: string): string {
|
|
78
87
|
return marked.parse(src, { async: false }) as string
|
|
79
88
|
}
|
|
89
|
+
|
|
90
|
+
// ---- the second gate: an allowlist over the rendered DOM ----------------------------------
|
|
91
|
+
// renderMarkdown is built to emit only these; this pass makes that a property of the OUTPUT,
|
|
92
|
+
// not of every renderer branch - the shell realm (sticky notes) is more privileged than a
|
|
93
|
+
// frame, so the HTML it inserts is checked element by element, attribute by attribute.
|
|
94
|
+
const TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'del', 's', 'a', 'img', 'hr', 'br', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'input', 'div', 'sup', 'sub'])
|
|
95
|
+
export const ATTR_POLICY: Record<string, (v: string) => boolean> = {
|
|
96
|
+
href: (v) => v === '#' || /^https?:\/\//i.test(v) || /^mailto:/i.test(v),
|
|
97
|
+
// a frame id: any path text without markup, quotes, spaces or a scheme (ids are unicode-free
|
|
98
|
+
// by convention but not by law - `checkout/café` is a real id)
|
|
99
|
+
'data-goto': (v) => v.length > 0 && v.length <= 300 && !/[\s<>"'`:\\]/.test(v) && !v.split('/').some((seg) => seg === '..' || seg === ''),
|
|
100
|
+
target: (v) => v === '_blank',
|
|
101
|
+
rel: (v) => v === 'noopener noreferrer',
|
|
102
|
+
// exactly what assetUrl emits: inside design/assets, no traversal SEGMENT (`flow..png` is a name)
|
|
103
|
+
src: (v) => v.startsWith('/design/assets/') && !v.slice('/design/assets/'.length).split('/').some((seg) => seg === '..' || seg === '.' || seg === ''),
|
|
104
|
+
alt: () => true, title: () => true, loading: (v) => v === 'lazy',
|
|
105
|
+
class: (v) => v.split(' ').every((c) => /^(mv-|language-)[^\s"'<>]+$/.test(c)),
|
|
106
|
+
align: (v) => /^(left|center|right)$/.test(v),
|
|
107
|
+
type: (v) => v === 'checkbox', checked: () => true, disabled: () => true,
|
|
108
|
+
start: (v) => /^\d+$/.test(v), colspan: (v) => /^\d+$/.test(v), rowspan: (v) => /^\d+$/.test(v),
|
|
109
|
+
}
|
|
110
|
+
/** Rendered markdown -> the same HTML with every element and attribute outside the allowlist
|
|
111
|
+
* removed (an element goes with its subtree; an attribute alone). Browser only (DOMParser). */
|
|
112
|
+
export function sanitizeMarkdownHtml(html: string): string {
|
|
113
|
+
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
|
114
|
+
const walk = (el: Element) => {
|
|
115
|
+
for (const child of [...el.children]) {
|
|
116
|
+
if (!TAGS.has(child.tagName.toLowerCase())) { child.remove(); continue }
|
|
117
|
+
for (const a of [...child.attributes]) {
|
|
118
|
+
const ok = ATTR_POLICY[a.name]
|
|
119
|
+
if (!ok || !ok(a.value)) child.removeAttribute(a.name)
|
|
120
|
+
}
|
|
121
|
+
if (child.tagName === 'INPUT') child.setAttribute('disabled', '')
|
|
122
|
+
walk(child)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
walk(doc.body)
|
|
126
|
+
return doc.body.innerHTML
|
|
127
|
+
}
|