viteboard 0.1.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin/viteboard.mjs +59 -0
  4. package/content/docs/assets/image-mqjpqlyw.png +0 -0
  5. package/content/docs/assets/image-mqjsmenr.png +0 -0
  6. package/content/docs/boards/product-roadmap.board.json +2130 -0
  7. package/content/docs/capabilities.md +89 -0
  8. package/content/docs/docs.md +84 -0
  9. package/content/docs/guide/boards-in-markdown.md +48 -0
  10. package/content/docs/guide/boards.md +92 -0
  11. package/content/docs/guide/getting-started.md +71 -0
  12. package/content/docs/guide/test.md +6 -0
  13. package/content/docs/index.md +47 -0
  14. package/content/docs/product-roadmap.board.json +2130 -0
  15. package/content/docs/test.md +219 -0
  16. package/content/docs/tetst-dir/test-board.board.json +15 -0
  17. package/content/docs/tetst-dir/test-test.md +1 -0
  18. package/content/docs/workspace.md +73 -0
  19. package/dist/assets/index-CEpxLM2o.css +1 -0
  20. package/dist/assets/index-D4xvJdEQ.js +60 -0
  21. package/dist/index.html +13 -0
  22. package/index.html +13 -0
  23. package/package.json +52 -0
  24. package/src/app/AppController.ts +955 -0
  25. package/src/app/BoardState.ts +130 -0
  26. package/src/app/ClipboardService.ts +159 -0
  27. package/src/app/CommandManager.ts +66 -0
  28. package/src/app/ElementActions.ts +152 -0
  29. package/src/app/EmbedRegionSelector.ts +103 -0
  30. package/src/app/ExportPng.ts +107 -0
  31. package/src/app/ImageInsertService.ts +141 -0
  32. package/src/app/KeyboardShortcuts.ts +124 -0
  33. package/src/app/main.ts +6 -0
  34. package/src/board/BoardPreview.ts +189 -0
  35. package/src/board/BoardService.ts +222 -0
  36. package/src/canvas/CanvasRenderer.ts +253 -0
  37. package/src/canvas/CanvasSurface.ts +70 -0
  38. package/src/canvas/HitTester.ts +110 -0
  39. package/src/canvas/ImageCache.ts +123 -0
  40. package/src/canvas/PerformanceMonitor.ts +31 -0
  41. package/src/canvas/RenderScheduler.ts +26 -0
  42. package/src/canvas/Viewport.ts +77 -0
  43. package/src/content/ContentApi.ts +258 -0
  44. package/src/content/Markdown.ts +431 -0
  45. package/src/content/MarkdownView.ts +70 -0
  46. package/src/editor/DocEditor.ts +799 -0
  47. package/src/editor/htmlToMarkdown.ts +333 -0
  48. package/src/elements/renderElement.ts +509 -0
  49. package/src/elements/types.ts +118 -0
  50. package/src/shell/Shell.ts +2950 -0
  51. package/src/shell/Sidebar.ts +1352 -0
  52. package/src/storage/AssetStore.ts +86 -0
  53. package/src/storage/BoardSerializer.ts +114 -0
  54. package/src/storage/ImportExportService.ts +153 -0
  55. package/src/storage/IndexedDbStore.ts +92 -0
  56. package/src/storage/LocalBoardStore.ts +104 -0
  57. package/src/styles.css +3257 -0
  58. package/src/templates/helpers.ts +124 -0
  59. package/src/templates/index.ts +65 -0
  60. package/src/templates/journeyMapTemplate.ts +52 -0
  61. package/src/templates/opportunityTreeTemplate.ts +45 -0
  62. package/src/templates/personaTemplate.ts +41 -0
  63. package/src/templates/prioritizationMatrixTemplate.ts +44 -0
  64. package/src/templates/requirementsFlowTemplate.ts +45 -0
  65. package/src/templates/screenshotReviewTemplate.ts +52 -0
  66. package/src/templates/systemDiagramTemplate.ts +41 -0
  67. package/src/testing/StressTestGenerator.ts +134 -0
  68. package/src/testing/StressTestPanel.ts +64 -0
  69. package/src/tools/ArrowTool.ts +87 -0
  70. package/src/tools/ImageTool.ts +39 -0
  71. package/src/tools/PanTool.ts +34 -0
  72. package/src/tools/SelectTool.ts +377 -0
  73. package/src/tools/ShapeTool.ts +106 -0
  74. package/src/tools/StickyTool.ts +69 -0
  75. package/src/tools/TaskTool.ts +52 -0
  76. package/src/tools/TextTool.ts +45 -0
  77. package/src/tools/ToolContext.ts +44 -0
  78. package/src/tools/ToolController.ts +178 -0
  79. package/src/ui/Modal.ts +58 -0
  80. package/src/ui/PerformanceOverlay.ts +75 -0
  81. package/src/ui/StorageManager.ts +94 -0
  82. package/src/ui/TemplatePicker.ts +67 -0
  83. package/src/ui/TextEditorOverlay.ts +137 -0
  84. package/src/ui/Toolbar.ts +151 -0
  85. package/src/ui/TopControls.ts +137 -0
  86. package/src/ui/icons.ts +50 -0
  87. package/tsconfig.json +19 -0
  88. package/vite.config.ts +694 -0
@@ -0,0 +1,431 @@
1
+ import { ContentApi, resolveDocPath } from "./ContentApi";
2
+
3
+ /**
4
+ * A small, dependency-free Markdown renderer.
5
+ *
6
+ * Supports: frontmatter, ATX headings, fenced code (with a special `board`
7
+ * block), blockquotes, ordered/unordered/task lists (nested), GFM tables,
8
+ * horizontal rules, paragraphs, and inline bold/italic/code/links/images.
9
+ *
10
+ * Output is plain HTML strings. All text is HTML-escaped before inline
11
+ * formatting is applied, so untrusted markdown cannot inject markup.
12
+ */
13
+
14
+ export type ParsedMarkdown = {
15
+ frontmatter: Record<string, string>;
16
+ html: string;
17
+ };
18
+
19
+ export type BoardEmbed = {
20
+ src: string;
21
+ region: [number, number, number, number] | null;
22
+ height: number;
23
+ title: string | null;
24
+ };
25
+
26
+ export type MarkdownRenderOptions = {
27
+ rootId?: string;
28
+ docPath?: string;
29
+ };
30
+
31
+ export function renderMarkdown(input: string, options: MarkdownRenderOptions = {}): ParsedMarkdown {
32
+ const { frontmatter, body } = extractFrontmatter(input);
33
+ // Error boundary: a malformed document must never take down the app. Any
34
+ // unexpected parser error degrades to a visible, escaped error block.
35
+ try {
36
+ const html = renderBlocks(body.split("\n"), options);
37
+ return { frontmatter, html };
38
+ } catch (err) {
39
+ console.error("Markdown render failed", err);
40
+ return {
41
+ frontmatter,
42
+ html:
43
+ `<div class="md-error"><strong>Could not render this document.</strong>` +
44
+ `<pre>${escapeHtml(String(err))}</pre></div>`,
45
+ };
46
+ }
47
+ }
48
+
49
+ // ---------- frontmatter ----------
50
+
51
+ function extractFrontmatter(input: string): {
52
+ frontmatter: Record<string, string>;
53
+ body: string;
54
+ } {
55
+ const fm: Record<string, string> = {};
56
+ if (!input.startsWith("---")) return { frontmatter: fm, body: input };
57
+ const end = input.indexOf("\n---", 3);
58
+ if (end === -1) return { frontmatter: fm, body: input };
59
+ const block = input.slice(3, end).trim();
60
+ for (const line of block.split("\n")) {
61
+ const idx = line.indexOf(":");
62
+ if (idx === -1) continue;
63
+ fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
64
+ }
65
+ const rest = input.slice(end + 4).replace(/^\n+/, "");
66
+ return { frontmatter: fm, body: rest };
67
+ }
68
+
69
+ // ---------- block parsing ----------
70
+
71
+ function renderBlocks(lines: string[], options: MarkdownRenderOptions): string {
72
+ const out: string[] = [];
73
+ let i = 0;
74
+
75
+ // Defense-in-depth: the index `i` must strictly advance every iteration. If a
76
+ // future change ever breaks that invariant, fail loudly instead of hanging.
77
+ let guard = 0;
78
+ const maxIterations = lines.length * 4 + 1000;
79
+
80
+ while (i < lines.length) {
81
+ if (++guard > maxIterations) {
82
+ throw new Error(
83
+ `renderBlocks exceeded ${maxIterations} iterations at line ${i} — aborting to avoid an infinite loop`
84
+ );
85
+ }
86
+ const line = lines[i];
87
+
88
+ // blank
89
+ if (line.trim() === "") {
90
+ i++;
91
+ continue;
92
+ }
93
+
94
+ // fenced code / board block (3+ backticks; closes on a run of >= that many)
95
+ const fence = line.match(/^(`{3,})\s*(\w+)?\s*$/);
96
+ if (fence) {
97
+ const fenceLen = fence[1].length;
98
+ const lang = (fence[2] ?? "").toLowerCase();
99
+ const closeRe = new RegExp("^`{" + fenceLen + ",}\\s*$");
100
+ const buf: string[] = [];
101
+ i++;
102
+ while (i < lines.length && !closeRe.test(lines[i])) {
103
+ buf.push(lines[i]);
104
+ i++;
105
+ }
106
+ i++; // consume closing fence
107
+ if (lang === "board") {
108
+ out.push(renderBoardPlaceholder(parseBoardEmbed(buf.join("\n"))));
109
+ } else {
110
+ out.push(
111
+ `<pre class="code-block"><code${
112
+ lang ? ` class="language-${lang}"` : ""
113
+ }>${escapeHtml(buf.join("\n"))}</code></pre>`
114
+ );
115
+ }
116
+ continue;
117
+ }
118
+
119
+ // heading
120
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
121
+ if (heading) {
122
+ const level = heading[1].length;
123
+ const text = inline(heading[2].trim(), options);
124
+ const id = slug(heading[2]);
125
+ out.push(`<h${level} id="${id}">${text}</h${level}>`);
126
+ i++;
127
+ continue;
128
+ }
129
+
130
+ // horizontal rule
131
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
132
+ out.push("<hr />");
133
+ i++;
134
+ continue;
135
+ }
136
+
137
+ // blockquote
138
+ if (/^>\s?/.test(line)) {
139
+ const buf: string[] = [];
140
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
141
+ buf.push(lines[i].replace(/^>\s?/, ""));
142
+ i++;
143
+ }
144
+ out.push(`<blockquote>${renderBlocks(buf, options)}</blockquote>`);
145
+ continue;
146
+ }
147
+
148
+ // table (GFM): header row followed by separator row
149
+ if (line.includes("|") && i + 1 < lines.length && isTableSep(lines[i + 1])) {
150
+ const buf: string[] = [];
151
+ while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") {
152
+ buf.push(lines[i]);
153
+ i++;
154
+ }
155
+ out.push(renderTable(buf, options));
156
+ continue;
157
+ }
158
+
159
+ // list (unordered / ordered / task)
160
+ if (isListItem(line)) {
161
+ const buf: string[] = [];
162
+ while (
163
+ i < lines.length &&
164
+ (isListItem(lines[i]) || /^\s+\S/.test(lines[i]) || lines[i].trim() === "")
165
+ ) {
166
+ // stop a trailing blank line that precedes a non-list block
167
+ if (lines[i].trim() === "") {
168
+ const next = lines[i + 1];
169
+ if (next === undefined || (!isListItem(next) && !/^\s+\S/.test(next))) break;
170
+ }
171
+ buf.push(lines[i]);
172
+ i++;
173
+ }
174
+ out.push(renderList(buf, options));
175
+ continue;
176
+ }
177
+
178
+ // paragraph — always consumes at least the current line so `i` advances
179
+ const buf: string[] = [lines[i]];
180
+ i++;
181
+ while (
182
+ i < lines.length &&
183
+ lines[i].trim() !== "" &&
184
+ !/^`{3,}/.test(lines[i]) &&
185
+ !/^#{1,6}\s/.test(lines[i]) &&
186
+ !/^>\s?/.test(lines[i]) &&
187
+ !isListItem(lines[i]) &&
188
+ !/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i])
189
+ ) {
190
+ buf.push(lines[i]);
191
+ i++;
192
+ }
193
+ out.push(`<p>${inline(buf.join(" ").trim(), options)}</p>`);
194
+ }
195
+
196
+ return out.join("\n");
197
+ }
198
+
199
+ // ---------- lists ----------
200
+
201
+ function isListItem(line: string): boolean {
202
+ return /^\s*([-*+]|\d+\.)\s+/.test(line);
203
+ }
204
+
205
+ type ListItem = { ordered: boolean; content: string[]; checked: boolean | null };
206
+
207
+ function renderList(lines: string[], options: MarkdownRenderOptions): string {
208
+ // group by top-level items at the minimum indentation
209
+ const indents = lines.filter((l) => isListItem(l)).map((l) => l.match(/^\s*/)![0].length);
210
+ // No list items (shouldn't happen via the caller, but stay safe): render as text.
211
+ if (indents.length === 0) return inline(lines.join(" ").trim(), options);
212
+ const minIndent = Math.min(...indents);
213
+ const items: ListItem[] = [];
214
+ let ordered = false;
215
+ let current: ListItem | null = null;
216
+
217
+ for (const raw of lines) {
218
+ const indent = raw.match(/^\s*/)![0].length;
219
+ const m = raw.match(/^\s*([-*+]|\d+\.)\s+(.*)$/);
220
+ if (m && indent === minIndent) {
221
+ ordered = /\d+\./.test(m[1]);
222
+ let text = m[2];
223
+ let checked: boolean | null = null;
224
+ const task = text.match(/^\[([ xX])\]\s+(.*)$/);
225
+ if (task) {
226
+ checked = task[1].toLowerCase() === "x";
227
+ text = task[2];
228
+ }
229
+ current = { ordered, content: [text], checked };
230
+ items.push(current);
231
+ } else if (current) {
232
+ // continuation / nested content for the current item
233
+ current.content.push(raw.slice(minIndent));
234
+ }
235
+ }
236
+
237
+ const tag = ordered ? "ol" : "ul";
238
+ const body = items
239
+ .map((item) => {
240
+ const first = item.content[0];
241
+ const rest = item.content.slice(1);
242
+ let inner = inline(first, options);
243
+ const nested = rest.filter((l) => l.trim() !== "");
244
+ if (nested.some((l) => isListItem(l))) {
245
+ inner += renderList(rest, options);
246
+ } else if (nested.length) {
247
+ inner += " " + inline(nested.join(" ").trim(), options);
248
+ }
249
+ if (item.checked !== null) {
250
+ const box = `<input type="checkbox" disabled${item.checked ? " checked" : ""} /> `;
251
+ return `<li class="task">${box}${inner}</li>`;
252
+ }
253
+ return `<li>${inner}</li>`;
254
+ })
255
+ .join("");
256
+ return `<${tag}>${body}</${tag}>`;
257
+ }
258
+
259
+ // ---------- tables ----------
260
+
261
+ function isTableSep(line: string): boolean {
262
+ return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line);
263
+ }
264
+
265
+ function splitRow(line: string): string[] {
266
+ return line
267
+ .trim()
268
+ .replace(/^\|/, "")
269
+ .replace(/\|$/, "")
270
+ .split("|")
271
+ .map((c) => c.trim());
272
+ }
273
+
274
+ function renderTable(lines: string[], options: MarkdownRenderOptions): string {
275
+ const header = splitRow(lines[0]);
276
+ const aligns = splitRow(lines[1]).map((c) => {
277
+ const left = c.startsWith(":");
278
+ const right = c.endsWith(":");
279
+ if (left && right) return "center";
280
+ if (right) return "right";
281
+ if (left) return "left";
282
+ return "";
283
+ });
284
+ const rows = lines.slice(2).map(splitRow);
285
+ const th = header
286
+ .map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, options)}</th>`)
287
+ .join("");
288
+ const trs = rows
289
+ .map(
290
+ (r) =>
291
+ `<tr>${r
292
+ .map(
293
+ (c, idx) =>
294
+ `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, options)}</td>`
295
+ )
296
+ .join("")}</tr>`
297
+ )
298
+ .join("");
299
+ return `<table><thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`;
300
+ }
301
+
302
+ // ---------- board embeds ----------
303
+
304
+ export function parseBoardEmbed(body: string): BoardEmbed {
305
+ const fields: Record<string, string> = {};
306
+ for (const line of body.split("\n")) {
307
+ const clean = line.replace(/#.*$/, "").trim(); // strip trailing comments
308
+ if (!clean) continue;
309
+ const idx = clean.indexOf(":");
310
+ if (idx === -1) continue;
311
+ fields[clean.slice(0, idx).trim()] = clean.slice(idx + 1).trim();
312
+ }
313
+ let region: [number, number, number, number] | null = null;
314
+ if (fields.region) {
315
+ const nums = fields.region.split(",").map((n) => parseFloat(n.trim()));
316
+ if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
317
+ region = [nums[0], nums[1], nums[2], nums[3]];
318
+ }
319
+ }
320
+ return {
321
+ src: fields.src ?? "",
322
+ region,
323
+ height: fields.height ? Math.max(120, parseInt(fields.height, 10) || 360) : 360,
324
+ title: fields.title ?? null,
325
+ };
326
+ }
327
+
328
+ function renderBoardPlaceholder(embed: BoardEmbed): string {
329
+ const region = embed.region ? embed.region.join(",") : "";
330
+ return (
331
+ `<figure class="board-embed" data-src="${escapeAttr(embed.src)}" ` +
332
+ `data-region="${region}" data-height="${embed.height}" ` +
333
+ `data-title="${escapeAttr(embed.title ?? "")}"></figure>`
334
+ );
335
+ }
336
+
337
+ // ---------- inline ----------
338
+
339
+ function inline(text: string, options: MarkdownRenderOptions): string {
340
+ let s = escapeHtml(text);
341
+
342
+ // Preserve escaped markdown punctuation as literal text before applying
343
+ // formatting rules, then restore it after inline parsing.
344
+ const escapedLiterals: string[] = [];
345
+ s = s.replace(/\\([\\`*_{}\[\]()#+\-.!|])/g, (_m, char) => {
346
+ escapedLiterals.push(char);
347
+ return `\u0001${escapedLiterals.length - 1}\u0001`;
348
+ });
349
+
350
+ // protect inline code spans
351
+ const codes: string[] = [];
352
+ s = s.replace(/`([^`]+)`/g, (_m, code) => {
353
+ codes.push(`<code>${code}</code>`);
354
+ return `\u0000${codes.length - 1}\u0000`;
355
+ });
356
+
357
+ // images: ![alt](src)
358
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src) => {
359
+ const resolved = resolveLocalPath(src, options);
360
+ const rendered = resolveAsset(src, options);
361
+ const dataAttr = resolved === src ? "" : ` data-md-src="${escapeAttr(src)}"`;
362
+ return `<img src="${rendered}" alt="${escapeAttr(alt)}" loading="lazy"${dataAttr} />`;
363
+ });
364
+
365
+ // links: [text](href)
366
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, href) => {
367
+ const rendered = resolveLinkHref(href, options);
368
+ const external = /^(https?:|mailto:|tel:)/.test(href);
369
+ const rel = external ? ' target="_blank" rel="noopener noreferrer"' : "";
370
+ const dataAttr = rendered === href ? "" : ` data-md-href="${escapeAttr(href)}"`;
371
+ return `<a href="${escapeAttr(rendered)}"${rel}${dataAttr}>${label}</a>`;
372
+ });
373
+
374
+ // bold then italic. Bold uses a non-greedy body so it can WRAP inner italics,
375
+ // e.g. `**bold with *italic* inside**`. The italic pass then runs on the
376
+ // already-bolded text and converts the remaining single-* spans.
377
+ s = s.replace(/\*\*([\s\S]+?)\*\*/g, "<strong>$1</strong>");
378
+ s = s.replace(/__([\s\S]+?)__/g, "<strong>$1</strong>");
379
+ s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>");
380
+ s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, "$1<em>$2</em>");
381
+
382
+ // restore code spans
383
+ s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => codes[Number(n)]);
384
+ s = s.replace(/\u0001(\d+)\u0001/g, (_m, n) => escapedLiterals[Number(n)]);
385
+ return s;
386
+ }
387
+
388
+ function resolveAsset(src: string, options: MarkdownRenderOptions): string {
389
+ if (/^(https?:|data:)/.test(src)) return src;
390
+ if (!options.rootId || !options.docPath) return src;
391
+ const resolved = resolveLocalPath(src, options);
392
+ if (!resolved) return src;
393
+ return ContentApi.assetUrl(options.rootId, resolved);
394
+ }
395
+
396
+ function resolveLinkHref(href: string, options: MarkdownRenderOptions): string {
397
+ if (/^(https?:|mailto:|tel:|#|data:)/.test(href)) return href;
398
+ if (!options.rootId || !options.docPath) return href;
399
+ const resolved = resolveLocalPath(href, options);
400
+ if (!resolved) return href;
401
+ if (resolved.endsWith(".md")) {
402
+ return `#/root/${encodeURIComponent(options.rootId)}/docs/${encodeURIComponent(resolved)}`;
403
+ }
404
+ return ContentApi.assetUrl(options.rootId, resolved);
405
+ }
406
+
407
+ function resolveLocalPath(target: string, options: MarkdownRenderOptions): string {
408
+ if (!options.docPath) return target;
409
+ return resolveDocPath(options.docPath, target);
410
+ }
411
+
412
+ // ---------- utils ----------
413
+
414
+ function escapeHtml(s: string): string {
415
+ return s
416
+ .replace(/&/g, "&amp;")
417
+ .replace(/</g, "&lt;")
418
+ .replace(/>/g, "&gt;");
419
+ }
420
+
421
+ function escapeAttr(s: string): string {
422
+ return escapeHtml(s).replace(/"/g, "&quot;");
423
+ }
424
+
425
+ export function slug(text: string): string {
426
+ return text
427
+ .toLowerCase()
428
+ .replace(/[^\w\s-]/g, "")
429
+ .trim()
430
+ .replace(/\s+/g, "-");
431
+ }
@@ -0,0 +1,70 @@
1
+ import { renderMarkdown } from "./Markdown";
2
+ import { BoardPreview } from "../board/BoardPreview";
3
+ import { resolveBoardIdCandidatesFromDoc } from "../board/BoardService";
4
+
5
+ export function currentTheme(): "light" | "dark" {
6
+ return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
7
+ }
8
+
9
+ export type MarkdownRenderContext = {
10
+ rootId: string;
11
+ docPath: string;
12
+ };
13
+
14
+ /**
15
+ * Renders markdown into `container` and upgrades any `board` fenced blocks
16
+ * (emitted as <figure class="board-embed">) into live, read-only BoardPreviews.
17
+ * Returns a cleanup function that tears down the preview render loops/observers.
18
+ */
19
+ export function renderMarkdownInto(
20
+ container: HTMLElement,
21
+ markdown: string,
22
+ context: MarkdownRenderContext
23
+ ): { frontmatter: Record<string, string>; destroy: () => void } {
24
+ const { html, frontmatter } = renderMarkdown(markdown, context);
25
+
26
+ const body = document.createElement("article");
27
+ body.className = "markdown-body";
28
+ body.innerHTML = html;
29
+ container.replaceChildren(body);
30
+
31
+ const previews: BoardPreview[] = [];
32
+ body.querySelectorAll<HTMLElement>("figure.board-embed").forEach((figure) => {
33
+ const src = figure.dataset.src ?? "";
34
+ if (!src) return;
35
+ const boardIds = resolveBoardIdCandidatesFromDoc(src, context.docPath);
36
+ const boardId = boardIds[0] ?? "";
37
+ const regionStr = figure.dataset.region ?? "";
38
+ const nums = regionStr ? regionStr.split(",").map(Number) : [];
39
+ const region =
40
+ nums.length === 4 && nums.every((n) => Number.isFinite(n))
41
+ ? ([nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number])
42
+ : null;
43
+ const height = parseInt(figure.dataset.height ?? "360", 10) || 360;
44
+ const title = figure.dataset.title || null;
45
+ // A single broken embed must not break the whole document.
46
+ try {
47
+ previews.push(
48
+ new BoardPreview(figure, {
49
+ rootId: context.rootId,
50
+ src: boardId,
51
+ srcCandidates: boardIds,
52
+ region,
53
+ height,
54
+ title,
55
+ theme: currentTheme(),
56
+ })
57
+ );
58
+ } catch (err) {
59
+ console.error("Board embed failed to mount", src, err);
60
+ figure.textContent = `Could not load board "${src}".`;
61
+ }
62
+ });
63
+
64
+ return {
65
+ frontmatter,
66
+ destroy: () => {
67
+ for (const p of previews) p.destroy();
68
+ },
69
+ };
70
+ }