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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/viteboard.mjs +59 -0
- package/content/docs/assets/image-mqjpqlyw.png +0 -0
- package/content/docs/assets/image-mqjsmenr.png +0 -0
- package/content/docs/boards/product-roadmap.board.json +2130 -0
- package/content/docs/capabilities.md +89 -0
- package/content/docs/docs.md +84 -0
- package/content/docs/guide/boards-in-markdown.md +48 -0
- package/content/docs/guide/boards.md +92 -0
- package/content/docs/guide/getting-started.md +71 -0
- package/content/docs/guide/test.md +6 -0
- package/content/docs/index.md +47 -0
- package/content/docs/product-roadmap.board.json +2130 -0
- package/content/docs/test.md +219 -0
- package/content/docs/tetst-dir/test-board.board.json +15 -0
- package/content/docs/tetst-dir/test-test.md +1 -0
- package/content/docs/workspace.md +73 -0
- package/dist/assets/index-CEpxLM2o.css +1 -0
- package/dist/assets/index-D4xvJdEQ.js +60 -0
- package/dist/index.html +13 -0
- package/index.html +13 -0
- package/package.json +52 -0
- package/src/app/AppController.ts +955 -0
- package/src/app/BoardState.ts +130 -0
- package/src/app/ClipboardService.ts +159 -0
- package/src/app/CommandManager.ts +66 -0
- package/src/app/ElementActions.ts +152 -0
- package/src/app/EmbedRegionSelector.ts +103 -0
- package/src/app/ExportPng.ts +107 -0
- package/src/app/ImageInsertService.ts +141 -0
- package/src/app/KeyboardShortcuts.ts +124 -0
- package/src/app/main.ts +6 -0
- package/src/board/BoardPreview.ts +189 -0
- package/src/board/BoardService.ts +222 -0
- package/src/canvas/CanvasRenderer.ts +253 -0
- package/src/canvas/CanvasSurface.ts +70 -0
- package/src/canvas/HitTester.ts +110 -0
- package/src/canvas/ImageCache.ts +123 -0
- package/src/canvas/PerformanceMonitor.ts +31 -0
- package/src/canvas/RenderScheduler.ts +26 -0
- package/src/canvas/Viewport.ts +77 -0
- package/src/content/ContentApi.ts +258 -0
- package/src/content/Markdown.ts +431 -0
- package/src/content/MarkdownView.ts +70 -0
- package/src/editor/DocEditor.ts +799 -0
- package/src/editor/htmlToMarkdown.ts +333 -0
- package/src/elements/renderElement.ts +509 -0
- package/src/elements/types.ts +118 -0
- package/src/shell/Shell.ts +2950 -0
- package/src/shell/Sidebar.ts +1352 -0
- package/src/storage/AssetStore.ts +86 -0
- package/src/storage/BoardSerializer.ts +114 -0
- package/src/storage/ImportExportService.ts +153 -0
- package/src/storage/IndexedDbStore.ts +92 -0
- package/src/storage/LocalBoardStore.ts +104 -0
- package/src/styles.css +3257 -0
- package/src/templates/helpers.ts +124 -0
- package/src/templates/index.ts +65 -0
- package/src/templates/journeyMapTemplate.ts +52 -0
- package/src/templates/opportunityTreeTemplate.ts +45 -0
- package/src/templates/personaTemplate.ts +41 -0
- package/src/templates/prioritizationMatrixTemplate.ts +44 -0
- package/src/templates/requirementsFlowTemplate.ts +45 -0
- package/src/templates/screenshotReviewTemplate.ts +52 -0
- package/src/templates/systemDiagramTemplate.ts +41 -0
- package/src/testing/StressTestGenerator.ts +134 -0
- package/src/testing/StressTestPanel.ts +64 -0
- package/src/tools/ArrowTool.ts +87 -0
- package/src/tools/ImageTool.ts +39 -0
- package/src/tools/PanTool.ts +34 -0
- package/src/tools/SelectTool.ts +377 -0
- package/src/tools/ShapeTool.ts +106 -0
- package/src/tools/StickyTool.ts +69 -0
- package/src/tools/TaskTool.ts +52 -0
- package/src/tools/TextTool.ts +45 -0
- package/src/tools/ToolContext.ts +44 -0
- package/src/tools/ToolController.ts +178 -0
- package/src/ui/Modal.ts +58 -0
- package/src/ui/PerformanceOverlay.ts +75 -0
- package/src/ui/StorageManager.ts +94 -0
- package/src/ui/TemplatePicker.ts +67 -0
- package/src/ui/TextEditorOverlay.ts +137 -0
- package/src/ui/Toolbar.ts +151 -0
- package/src/ui/TopControls.ts +137 -0
- package/src/ui/icons.ts +50 -0
- package/tsconfig.json +19 -0
- 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: 
|
|
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, "&")
|
|
417
|
+
.replace(/</g, "<")
|
|
418
|
+
.replace(/>/g, ">");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function escapeAttr(s: string): string {
|
|
422
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
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
|
+
}
|