drafted 1.17.12 → 1.17.14

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/mcp/server.mjs CHANGED
@@ -307,8 +307,12 @@ const TOOL_ANNOTATIONS = {
307
307
  // name describing the work before any other tool call succeeds.
308
308
  session: { title: 'Session', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab — pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes. Dispatch by `action`: `name` (set/rename with the `name` param).' },
309
309
 
310
+ // Comments — the review loop agents could previously only reach over raw HTTP
311
+ comment: { title: 'Comments', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Read and write review comments on frames. Comments are notes ABOUT THE WORK: they attach to a frame, optionally to one element inside it, and anyone who can see the frame sees them. Dispatch by `action`: list (paginated, compact mode), add (with an optional element anchor or a reply), resolve, reopen, delete. Use this to leave findings a human can action in place, and to read the feedback they left you.' },
312
+
310
313
  // Canvas / view
311
314
  focus: { title: 'Focus on target', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Pan the canvas viewport for connected clients to a frame, lane, or layer.' },
315
+ tour: { title: 'Guided tours', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Play or stop an agent-authored guided tour (driver.js) on the project surface for all connected clients. Tours live on a frame\'s metadata.tour — author them with fs(write, metadata={tour: {title, steps}}). Dispatch by `action`: play (frameId) starts the walkthrough, stop (projectId) ends it. Use for presentations and collaborative walkthroughs.' },
312
316
  screenshot: { title: 'Screenshot', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Render a PNG via headless browser. `scope=frame` for a single frame, `scope=canvas` for a region of the project surface.' },
313
317
 
314
318
  // Minions — checklist-driven intake surfaces bound to a project
@@ -418,7 +422,7 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
418
422
  const v = args[0][k];
419
423
  if (typeof v === 'string' && v.trim()) {
420
424
  try { args[0][k] = JSON.parse(v); }
421
- catch { return err(new Error(`Param "${k}" must be a JSON-encoded ${['excalidraw_data', 'frontmatter', 'target', 'output', 'state'].includes(k) ? 'object' : 'array'} string; JSON.parse failed.`)); }
425
+ catch { return err(new Error(`Param "${k}" must be a JSON-encoded ${['excalidraw_data', 'frontmatter', 'target', 'output', 'state', 'metadata'].includes(k) ? 'object' : 'array'} string; JSON.parse failed.`)); }
422
426
  }
423
427
  }
424
428
  }
@@ -2299,6 +2303,122 @@ tool('trigger', {
2299
2303
  } catch (error) { return err(error); }
2300
2304
  });
2301
2305
 
2306
+ tool('tour', {
2307
+ action: z.enum(['play', 'stop']).describe('play: start a guided tour on the project surface for all connected clients. stop: end the currently playing tour.'),
2308
+ frameId: z.string().optional().describe('[play] Tour frame: a frame path (/projects/<project>/<layer>/<lane>/<file>), frame URL, or frame UUID. The frame must carry metadata.tour (steps: [{target, title?, body?, side?, advanceOnClick?}]).'),
2309
+ projectId: z.string().optional().describe('[stop] Project UUID or slug owning the tour.'),
2310
+ }, async ({ action, frameId, projectId }) => {
2311
+ try {
2312
+ if (action === 'play') {
2313
+ if (!frameId) throw new Error('frameId is required for tour play');
2314
+ const { frameId: fid } = await resolveFsFramePath(frameId);
2315
+ const result = await api('POST', '/api/tour/play', { frameId: fid });
2316
+ return ok(result);
2317
+ }
2318
+ if (action === 'stop') {
2319
+ if (!projectId) throw new Error('projectId is required for tour stop');
2320
+ const result = await api('POST', '/api/tour/stop', { projectId });
2321
+ return ok(result);
2322
+ }
2323
+ throw new Error(`Unknown tour action: ${action}`);
2324
+ } catch (error) { return err(error); }
2325
+ });
2326
+
2327
+ tool('comment', {
2328
+ action: z.enum(['list', 'add', 'resolve', 'reopen', 'delete']).describe('list: read comments (paginated). add: post a comment or a reply. resolve/reopen: flip a comment\'s state. delete: remove one.'),
2329
+ target: z.string().optional().describe('[list, add] The frame: a path (/projects/<project>/<layer>/<lane>/<file>), a frame URL, or a frame UUID. Required for add. For list, omit it to read the whole project.'),
2330
+ projectId: z.string().optional().describe('[list] Project to read when no target is given — UUID, slug, or /projects/<name>. Defaults to the session\'s bound project.'),
2331
+ body: z.string().optional().describe('[add] The comment text. Be specific and actionable: quote what you mean and say what should change. Prefix with [MUST] / [NICE] / [Q] so a reviewer can triage.'),
2332
+ replyTo: z.string().optional().describe('[add] Comment UUID this replies to. A reply cannot carry its own anchor — it inherits the thread\'s.'),
2333
+ anchorRef: z.string().optional().describe('[add] CSS selector for the element this comment is about, e.g. "h1" or ".email-body > p:nth-of-type(3)". The frame must load /vendor/frame-guide.js (markdown and HTML content frames get it automatically). Without it the comment lands on the whole frame.'),
2334
+ anchorText: z.string().optional().describe('[add] The text of the anchored element, stored as a snapshot. Supply it whenever you pass anchorRef: a selector is an nth-of-type path that breaks when the frame is re-authored, and this snapshot is the only way to re-find what you meant.'),
2335
+ commentId: z.string().optional().describe('[resolve, reopen, delete] The comment UUID.'),
2336
+ status: z.enum(['open', 'resolved', 'all']).optional().describe('[list] Filter. Defaults to open — the comments that still need action.'),
2337
+ limit: z.number().optional().describe('[list] Max comments to return (default 25).'),
2338
+ offset: z.number().optional().describe('[list] Skip this many before returning (default 0).'),
2339
+ compact: z.boolean().optional().describe('[list] Return only id, frame, author, status and a truncated body — enough to pick one to act on, without the full text of every thread.'),
2340
+ }, async ({ action, target, projectId, body, replyTo, anchorRef, anchorText, commentId, status, limit, offset, compact }) => {
2341
+ try {
2342
+ if (action === 'add') {
2343
+ if (!target) throw new Error('target is required for comment add');
2344
+ if (!body || !String(body).trim()) throw new Error('body is required for comment add');
2345
+ const { frameId } = await resolveFsFramePath(target);
2346
+ // A reply inherits its parent's anchor; sending both would silently drop one.
2347
+ const anchor = (!replyTo && anchorRef)
2348
+ ? { type: 'selector', ref: anchorRef, ...(anchorText ? { text: anchorText } : {}) }
2349
+ : null;
2350
+ const result = await api('POST', `/api/frames/${frameId}/comments`, {
2351
+ body: String(body),
2352
+ parentId: replyTo || null,
2353
+ anchor,
2354
+ });
2355
+ return ok(result);
2356
+ }
2357
+ if (action === 'resolve' || action === 'reopen') {
2358
+ if (!commentId) throw new Error(`commentId is required for comment ${action}`);
2359
+ const result = await api('PATCH', `/api/comments/${commentId}`, { resolved: action === 'resolve' });
2360
+ return ok(result);
2361
+ }
2362
+ if (action === 'delete') {
2363
+ if (!commentId) throw new Error('commentId is required for comment delete');
2364
+ // DELETE answers 204 with an empty body, so there is nothing to echo back.
2365
+ await api('DELETE', `/api/comments/${commentId}`);
2366
+ return ok({ deleted: commentId });
2367
+ }
2368
+ if (action === 'list') {
2369
+ let rows;
2370
+ let scope;
2371
+ if (target) {
2372
+ // /api/frames/:id/comments does not join designs, so its rows carry no frameLabel.
2373
+ // Name the frame once in the scope instead of emitting `frame: null` on every row.
2374
+ const { frameId } = await resolveFsFramePath(target);
2375
+ rows = (await api('GET', `/api/frames/${frameId}/comments`)).comments || [];
2376
+ scope = { frameId, frame: target };
2377
+ } else {
2378
+ const meta = await resolveProjectArg(projectId || getState().projectId, { required: true });
2379
+ rows = (await api('GET', `/api/projects/${meta.id}/comments`)).comments || [];
2380
+ scope = { projectId: meta.id, project: meta.name };
2381
+ }
2382
+ const want = status || 'open';
2383
+ const filtered = rows.filter((c) => (want === 'all' ? true : want === 'resolved' ? !!c.resolvedAt : !c.resolvedAt));
2384
+ const total = filtered.length;
2385
+ const start = Math.max(0, Number(offset) || 0);
2386
+ const size = Math.max(1, Math.min(Number(limit) || 25, 200));
2387
+ const page = filtered.slice(start, start + size);
2388
+ const shape = (c) => compact
2389
+ ? {
2390
+ id: c.id,
2391
+ ...(c.frameLabel ? { frame: c.frameLabel } : {}),
2392
+ author: c.authorName || c.authorEmail || null,
2393
+ status: c.resolvedAt ? 'resolved' : 'open',
2394
+ replyTo: c.parentId || null,
2395
+ body: String(c.body || '').slice(0, 120),
2396
+ }
2397
+ : {
2398
+ id: c.id,
2399
+ frameId: c.frameId,
2400
+ ...(c.frameLabel ? { frame: c.frameLabel, layer: c.layer || null, lane: c.lane || null } : {}),
2401
+ author: c.authorName || c.authorEmail || null,
2402
+ status: c.resolvedAt ? 'resolved' : 'open',
2403
+ replyTo: c.parentId || null,
2404
+ anchor: c.anchor || null,
2405
+ body: c.body,
2406
+ createdAt: c.createdAt,
2407
+ };
2408
+ return ok({
2409
+ ...scope,
2410
+ status: want,
2411
+ comments: page.map(shape),
2412
+ totalAvailable: total,
2413
+ returned: page.length,
2414
+ truncated: start + page.length < total,
2415
+ ...(start + page.length < total ? { nextOffset: start + page.length } : {}),
2416
+ });
2417
+ }
2418
+ throw new Error(`Unknown comment action: ${action}`);
2419
+ } catch (error) { return err(error); }
2420
+ });
2421
+
2302
2422
  tool('focus', {
2303
2423
  target: z.string().describe('What to pan the canvas to: a frame path (/projects/<project>/<layer>/<lane>/<file>), a frame URL (any URL containing /f/{uuid} or /o/<org>/projects/...), or a frame ID (UUID). When a user shares a Drafted link, pass it directly here.'),
2304
2424
  }, async ({ target }) => {
@@ -2565,7 +2685,7 @@ server.resource('info', 'drafted://info', {
2565
2685
  skills: '/o/<org>/skills/<slug>',
2566
2686
  projects: '/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>',
2567
2687
  },
2568
- tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'screenshot', 'minion'],
2688
+ tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'tour', 'screenshot', 'minion'],
2569
2689
  }, null, 2),
2570
2690
  }],
2571
2691
  };
@@ -2583,13 +2703,14 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2583
2703
  title: z.string().optional().describe('[write + googleType] title for a new native file'),
2584
2704
  ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames. Apply ONLY against a fresh fs(read) of the frame: each op = {type, lineHash, newContent} where type is one of replace | delete | insertAfter | insertBefore, and lineHash is the FULL anchor from the read output (line number + hash, e.g. the token left of the |, like 42srt) — NOT a bare hash. delete/replace consume the target line; at most one replace/delete per line per edit (combine into one replace). If an op targets a line that changed since read it is rejected — re-read and retry. Element ops for excalidraw ({id,x,y,...}), or structured ops for office'),
2585
2705
  state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
2706
+ metadata: z.any().optional().describe('[write] JSON metadata to attach to the written frame (e.g. {"tour": {title, auto?, steps}} to define a guided tour on this frame). Max 64KB.'),
2586
2707
  recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
2587
2708
  lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
2588
2709
  pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
2589
2710
  org: z.string().optional().describe('Org (id or name) for bare /wiki and /skills paths (org-scoped /o/<org>/... paths don\'t need it). Per-request only; project paths self-derive org from the project row.'),
2590
2711
  projectId: PROJECT_OVERRIDE_PARAM,
2591
2712
  }, async (args) => {
2592
- const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, recursive, pattern, lines, org } = args;
2713
+ const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, metadata, recursive, pattern, lines, org } = args;
2593
2714
  // Org-scoped path (Shape A): /o/<org>/<root>/... — strip the org segment here,
2594
2715
  // carry it as the per-request org scope (orgHeader), validate per-root below.
2595
2716
  // The PATH is the address; the org= param is legacy for bare roots only, and a
@@ -2969,6 +3090,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2969
3090
  }
2970
3091
  else if (base64) body.base64 = base64;
2971
3092
  else return err(new Error('write requires one of content / file_path / base64 / googleType / state'));
3093
+ if (metadata !== undefined) body.metadata = metadata;
2972
3094
  const result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
2973
3095
  if (state !== undefined && result?.id) {
2974
3096
  try { await api('POST', `/api/file/${result.id}/state`, { state }); } catch { /* state best-effort */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.17.12",
3
+ "version": "1.17.14",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [
@@ -64,6 +64,7 @@
64
64
  "mdast-util-from-markdown": "^2.0.3",
65
65
  "multer": "^2.1.1",
66
66
  "nodemailer": "^8.0.2",
67
+ "parse5": "^8.0.1",
67
68
  "pg": "^8.20.0",
68
69
  "playwright": "^1.58.2",
69
70
  "puppeteer": "^24.37.5",
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Conformance of org-defined record types.
3
+ *
4
+ * Drafted deliberately has NO built-in schema for "Process" or anything else —
5
+ * an org's agents invent their own vocabulary by writing typed wiki pages, and
6
+ * `wiki_pages.type` accepts any string. The cost of that freedom is drift: an
7
+ * agent declares a Process needs `steps`, then six months of writes quietly
8
+ * omit it, and structural queries return partial answers that LOOK complete.
9
+ *
10
+ * This is the drift report. It rejects nothing — a type definition is usually
11
+ * wrong on its first draft, so gating writes on one would be worse than the
12
+ * drift. Enforcement is opt-in per type (`enforced: true`), read by callers
13
+ * that choose to act on it; this module only observes.
14
+ *
15
+ * ONE reserved word: a page typed `RecordType` defines a record type. That is
16
+ * the single fixed point the org does not get to invent — you cannot discover
17
+ * definitions without agreeing on how a definition announces itself. Everything
18
+ * else (the type names, the field names, what they mean) belongs to the org.
19
+ */
20
+
21
+ export const RECORD_TYPE = 'RecordType';
22
+
23
+ /** How many same-typed pages before an undefined type looks like a record type
24
+ * someone forgot to declare. Low enough to catch an ingest early, high enough
25
+ * that a handful of Notes or Concepts never trips it. */
26
+ export const UNDECLARED_CLUSTER_MIN = 5;
27
+
28
+ function isBlank(v) {
29
+ if (v === null || v === undefined) return true;
30
+ if (typeof v === 'string') return v.trim() === '';
31
+ if (Array.isArray(v)) return v.length === 0;
32
+ if (typeof v === 'object') return Object.keys(v).length === 0;
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Normalize a `fields` declaration. Both forms are accepted because agents
38
+ * write both:
39
+ * fields: [owner, steps] -> known, not required
40
+ * fields: [{ name: owner, required: true }] -> required
41
+ * A bare string is deliberately NOT treated as required: shorthand should be
42
+ * the cheap way to sketch a type, not a way to accidentally mass-flag pages.
43
+ */
44
+ export function normalizeFields(fields) {
45
+ if (!Array.isArray(fields)) return [];
46
+ const out = [];
47
+ for (const f of fields) {
48
+ if (typeof f === 'string' && f.trim()) {
49
+ out.push({ name: f.trim(), required: false });
50
+ } else if (f && typeof f === 'object' && typeof f.name === 'string' && f.name.trim()) {
51
+ out.push({ name: f.name.trim(), required: f.required === true });
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** The type name a definition page defines: explicit `defines`, else its title. */
58
+ export function definedTypeName(page) {
59
+ const fm = (page && page.frontmatter) || {};
60
+ const explicit = typeof fm.defines === 'string' ? fm.defines.trim() : '';
61
+ if (explicit) return explicit;
62
+ const title = typeof page?.title === 'string' ? page.title.trim() : '';
63
+ return title || null;
64
+ }
65
+
66
+ /**
67
+ * @param pages - every wiki page for the org ({ path, title, type, frontmatter })
68
+ * @returns {{ types: Array, summary: object }}
69
+ * types[]: { type, definedAt, enforced, fields, instances, violations[] }
70
+ * violations[]: { path, missing: string[] }
71
+ *
72
+ * Only types that HAVE a definition are checked. Untyped and ad-hoc pages are
73
+ * none of this report's business — flagging them would bury the real drift.
74
+ */
75
+ export function checkRecordConformance(pages) {
76
+ const all = Array.isArray(pages) ? pages : [];
77
+ const definitions = all.filter((p) => String(p?.type || '').trim() === RECORD_TYPE);
78
+
79
+ const types = [];
80
+ const seen = new Set();
81
+ for (const def of definitions) {
82
+ const typeName = definedTypeName(def);
83
+ if (!typeName) {
84
+ types.push({
85
+ type: null,
86
+ definedAt: def.path,
87
+ enforced: false,
88
+ fields: [],
89
+ instances: 0,
90
+ violations: [],
91
+ error: 'definition declares no type name (set frontmatter `defines`, or give the page a title)',
92
+ });
93
+ continue;
94
+ }
95
+ // Two pages defining the same type is itself drift worth surfacing —
96
+ // otherwise whichever sorts first silently wins.
97
+ if (seen.has(typeName)) {
98
+ types.push({
99
+ type: typeName,
100
+ definedAt: def.path,
101
+ enforced: false,
102
+ fields: [],
103
+ instances: 0,
104
+ violations: [],
105
+ error: 'duplicate definition for this type',
106
+ });
107
+ continue;
108
+ }
109
+ seen.add(typeName);
110
+
111
+ const fm = def.frontmatter || {};
112
+ const fields = normalizeFields(fm.fields);
113
+ const required = fields.filter((f) => f.required).map((f) => f.name);
114
+ const instances = all.filter((p) => String(p?.type || '').trim() === typeName);
115
+
116
+ const violations = [];
117
+ for (const inst of instances) {
118
+ const ifm = inst.frontmatter || {};
119
+ const missing = required.filter((name) => isBlank(ifm[name]));
120
+ if (missing.length) violations.push({ path: inst.path, missing });
121
+ }
122
+
123
+ types.push({
124
+ type: typeName,
125
+ definedAt: def.path,
126
+ enforced: fm.enforced === true,
127
+ fields,
128
+ instances: instances.length,
129
+ violations,
130
+ });
131
+ }
132
+
133
+ types.sort((a, b) => String(a.type || '').localeCompare(String(b.type || '')));
134
+
135
+ // The blind spot this closes: a report that only checks DEFINED types is
136
+ // silent in the most likely failure — an agent asked to "ingest all our X"
137
+ // writes 200 prose pages typed `Page`, defines nothing, and every number
138
+ // below reads zero. Health looks perfect precisely when nothing is
139
+ // queryable. So surface the clusters that LOOK like undeclared record types.
140
+ const defined = new Set(types.map((t) => t.type).filter(Boolean));
141
+ const counts = new Map();
142
+ for (const p of all) {
143
+ const t = String(p?.type || '').trim();
144
+ if (!t || t === RECORD_TYPE || defined.has(t)) continue;
145
+ counts.set(t, (counts.get(t) || 0) + 1);
146
+ }
147
+ const undeclared = [...counts.entries()]
148
+ .filter(([, n]) => n >= UNDECLARED_CLUSTER_MIN)
149
+ .sort((a, b) => b[1] - a[1])
150
+ .map(([type, pages]) => ({
151
+ type,
152
+ pages,
153
+ hint: `${pages} pages share type "${type}" with no RecordType definition — if these get compared or audited, define one so the fields become queryable`,
154
+ }));
155
+
156
+ return {
157
+ types,
158
+ undeclared,
159
+ summary: {
160
+ typesDefined: types.length,
161
+ instances: types.reduce((n, t) => n + t.instances, 0),
162
+ violations: types.reduce((n, t) => n + t.violations.length, 0),
163
+ definitionErrors: types.filter((t) => t.error).length,
164
+ undeclaredClusters: undeclared.length,
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Excalidraw diagrams embedded in wiki page markdown.
3
+ *
4
+ * A diagram is a fenced block whose language is `excalidraw` and whose body is
5
+ * an Excalidraw scene. Keeping it inside the page (rather than as a separate
6
+ * frame referenced by link) means the process model and its diagram version,
7
+ * export and move together, and cannot drift apart.
8
+ *
9
+ * The fence pattern here MUST stay identical to the one in renderMarkdown()
10
+ * (server/lib/markdown.mjs) — what we find has to be exactly what renders, or
11
+ * an edit writes back to a block the reader never saw.
12
+ *
13
+ * Addressing: the diagram id lives INSIDE the scene JSON as `draftedDiagramId`,
14
+ * not in the fence info string. The shared renderer's fence regex captures the
15
+ * language as `\w*`, so `\`\`\`excalidraw id=d1` would not match as a fence at
16
+ * all and the block would stop rendering everywhere. Ordinal position was the
17
+ * other option and was rejected: inserting a paragraph above a diagram would
18
+ * silently repoint every id after it.
19
+ */
20
+
21
+ const FENCE_RE = /```(\w*)\n([\s\S]*?)```/g;
22
+ export const DIAGRAM_ID_KEY = 'draftedDiagramId';
23
+
24
+ /** Stable-ish id for a new diagram. Callers pass an existing set to avoid collisions. */
25
+ export function nextDiagramId(taken = new Set()) {
26
+ for (let n = 1; n < 10000; n++) {
27
+ const id = 'd' + n;
28
+ if (!taken.has(id)) return id;
29
+ }
30
+ throw new Error('Too many diagrams on one page');
31
+ }
32
+
33
+ /**
34
+ * Every excalidraw fence in the document, in order.
35
+ * Returns `[{ id, scene, raw, start, end }]`. A block whose body is not valid
36
+ * JSON is still reported (scene null) so a caller can surface it rather than
37
+ * silently skipping a block the reader can see.
38
+ */
39
+ export function findExcalidrawBlocks(md) {
40
+ const text = String(md || '');
41
+ const out = [];
42
+ FENCE_RE.lastIndex = 0;
43
+ let m;
44
+ while ((m = FENCE_RE.exec(text)) !== null) {
45
+ if (m[1] !== 'excalidraw') continue;
46
+ const body = m[2];
47
+ let scene = null;
48
+ try { scene = JSON.parse(body); } catch { /* malformed — reported with scene null */ }
49
+ const id = scene && typeof scene[DIAGRAM_ID_KEY] === 'string' ? scene[DIAGRAM_ID_KEY] : null;
50
+ out.push({ id, scene, raw: body, start: m.index, end: m.index + m[0].length });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function getExcalidrawBlock(md, diagramId) {
56
+ return findExcalidrawBlocks(md).find((b) => b.id === diagramId) || null;
57
+ }
58
+
59
+ /** Ids already used on this page — pass to nextDiagramId when inserting. */
60
+ export function usedDiagramIds(md) {
61
+ return new Set(findExcalidrawBlocks(md).map((b) => b.id).filter(Boolean));
62
+ }
63
+
64
+ function serialize(scene, diagramId) {
65
+ // Id first so it survives a human hand-editing the block and is visible at a glance.
66
+ const { [DIAGRAM_ID_KEY]: _drop, ...rest } = scene || {};
67
+ return JSON.stringify({ [DIAGRAM_ID_KEY]: diagramId, ...rest }, null, 2);
68
+ }
69
+
70
+ /**
71
+ * Replace one diagram's scene, leaving every other byte of the page untouched.
72
+ * Throws when the id is absent — a save must never silently no-op or append a
73
+ * duplicate block the user did not ask for.
74
+ */
75
+ export function replaceExcalidrawBlock(md, diagramId, scene) {
76
+ const text = String(md || '');
77
+ const block = getExcalidrawBlock(text, diagramId);
78
+ if (!block) throw new Error(`No excalidraw block with id "${diagramId}" on this page`);
79
+ const fence = '```excalidraw\n' + serialize(scene, diagramId) + '\n```';
80
+ return text.slice(0, block.start) + fence + text.slice(block.end);
81
+ }
82
+
83
+ /** Append a new diagram block and return `{ content, id }`. */
84
+ export function appendExcalidrawBlock(md, scene) {
85
+ const text = String(md || '');
86
+ const id = nextDiagramId(usedDiagramIds(text));
87
+ const fence = '```excalidraw\n' + serialize(scene, id) + '\n```';
88
+ const sep = text && !text.endsWith('\n\n') ? (text.endsWith('\n') ? '\n' : '\n\n') : '';
89
+ return { content: text + sep + fence + '\n', id };
90
+ }