Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

502 linesCodeBlameRaw
1/**
2 * Markdown into a page's Yjs document: how templates, agents' edits and
3 * accepted suggestions become blocks. Pure (yjs only).
4 *
5 * `parseMarkdown` reads the Markdown agents write (CommonMark plus GitHub's
6 * tables, task lists and alerts, `$$` math and Mermaid fences) into block
7 * specs; `writeBlocks` turns specs into BlockNote's `blockContainer`
8 * elements, with the attributes the editor gives its own blocks, so the
9 * editor shows them exactly as if typed. The inverse is markdown.ts.
10 */
11import * as Y from "yjs";
12
13export type Marks = Record<string, object>;
14export type Run = { text: string; marks?: Marks } | { node: "mention"; attrs: Record<string, string> };
15
16export type BlockSpec = {
17 type: string;
18 props?: Record<string, string | number | boolean>;
19 /** Inline content; a code block's text is one plain run. */
20 content?: Run[];
21 /** A table's cells: rows of cells of runs. */
22 rows?: Run[][][];
23 children?: BlockSpec[];
24};
25
26const ALERT_KINDS: Record<string, string> = { NOTE: "info", IMPORTANT: "info", TIP: "success", WARNING: "warning", CAUTION: "danger" };
27
28// ── Inline ────────────────────────────────────────────────────────────────
29
30/** Inline Markdown as runs: bold, italic, strike, underline, code, links. */
31export function parseInline(text: string, marks: Marks = {}): Run[] {
32 const runs: Run[] = [];
33 let plain = "";
34 const flush = () => {
35 if (plain) runs.push(Object.keys(marks).length ? { text: plain, marks: { ...marks } } : { text: plain });
36 plain = "";
37 };
38 let i = 0;
39 while (i < text.length) {
40 const rest = text.slice(i);
41 // Escapes.
42 if (rest[0] === "\\" && rest.length > 1 && /[\\`*_{}[\]()#+\-.!~|<>$]/.test(rest[1]!)) {
43 plain += rest[1];
44 i += 2;
45 continue;
46 }
47 // Hard break: a backslash at the end of a line.
48 if (rest.startsWith("\\\n")) {
49 plain += "\n";
50 i += 2;
51 continue;
52 }
53 // Code.
54 const code = /^(`+)([\s\S]*?[^`])\1(?!`)/.exec(rest);
55 if (code) {
56 flush();
57 runs.push({ text: code[2]!.replace(/^ (.*) $/, "$1"), marks: { ...marks, code: {} } });
58 i += code[0].length;
59 continue;
60 }
61 // Images inline: keep as a link to the image.
62 const link = /^!?\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.exec(rest);
63 if (link) {
64 flush();
65 runs.push(...parseInline(link[1]! || link[2]!, { ...marks, link: { href: link[2]! } }));
66 i += link[0].length;
67 continue;
68 }
69 const auto = /^<(https?:\/\/[^>\s]+)>/.exec(rest);
70 if (auto) {
71 flush();
72 runs.push({ text: auto[1]!, marks: { ...marks, link: { href: auto[1]! } } });
73 i += auto[0].length;
74 continue;
75 }
76 const underline = /^<u>([\s\S]+?)<\/u>/.exec(rest);
77 if (underline) {
78 flush();
79 runs.push(...parseInline(underline[1]!, { ...marks, underline: {} }));
80 i += underline[0].length;
81 continue;
82 }
83 const strong = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(rest);
84 if (strong) {
85 flush();
86 runs.push(...parseInline(strong[2]!, { ...marks, bold: {} }));
87 i += strong[0].length;
88 continue;
89 }
90 const strike = /^~~(?=\S)([\s\S]*?\S)~~/.exec(rest);
91 if (strike) {
92 flush();
93 runs.push(...parseInline(strike[1]!, { ...marks, strike: {} }));
94 i += strike[0].length;
95 continue;
96 }
97 // `_` only at a word boundary, so snake_case stays as written.
98 const em = /^\*(?=\S)([\s\S]*?\S)\*(?!\*)/.exec(rest) ?? (/[A-Za-z0-9]$/.test(text.slice(0, i)) ? null : /^_(?=\S)([\s\S]*?\S)_(?![A-Za-z0-9])/.exec(rest));
99 if (em) {
100 flush();
101 runs.push(...parseInline(em[1]!, { ...marks, italic: {} }));
102 i += em[0].length;
103 continue;
104 }
105 plain += rest[0];
106 i += 1;
107 }
108 flush();
109 return merge(runs);
110}
111
112function sameMarks(a: Marks | undefined, b: Marks | undefined): boolean {
113 return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
114}
115
116function merge(runs: Run[]): Run[] {
117 const out: Run[] = [];
118 for (const run of runs) {
119 const last = out[out.length - 1];
120 if (last && "text" in last && "text" in run && sameMarks(last.marks, run.marks)) last.text += run.text;
121 else out.push("text" in run ? { ...run } : run);
122 }
123 return out.filter((r) => !("text" in r) || r.text !== "");
124}
125
126// ── Blocks ────────────────────────────────────────────────────────────────
127
128type Line = { indent: number; text: string };
129
130function lineOf(raw: string): Line {
131 const expanded = raw.replace(/\t/g, " ");
132 const indent = /^ */.exec(expanded)![0].length;
133 return { indent, text: expanded.slice(indent) };
134}
135
136const LIST_ITEM = /^([-*+]|\d{1,9}[.)])\s+(.*)$/;
137const TABLE_RULE = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
138
139function splitRow(line: string): string[] {
140 let s = line.trim();
141 if (s.startsWith("|")) s = s.slice(1);
142 if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
143 const cells: string[] = [];
144 let cell = "";
145 for (let i = 0; i < s.length; i++) {
146 if (s[i] === "\\" && s[i + 1] === "|") {
147 cell += "|";
148 i++;
149 } else if (s[i] === "|") {
150 cells.push(cell.trim());
151 cell = "";
152 } else cell += s[i];
153 }
154 cells.push(cell.trim());
155 return cells;
156}
157
158function isBlockStart(text: string): boolean {
159 return /^(#{1,6}\s|>|```|~~~|\$\$|<details>|(-{3,}|\*{3,}|_{3,})\s*$)/.test(text) || LIST_ITEM.test(text) || /^!\[[^\]]*\]\([^)]+\)\s*$/.test(text);
160}
161
162/** Markdown as block specs. */
163export function parseMarkdown(markdown: string): BlockSpec[] {
164 const lines = String(markdown ?? "")
165 .replace(/\r\n?/g, "\n")
166 .split("\n");
167 return parseLines(lines.map(lineOf), 0, lines.length);
168}
169
170function parseLines(lines: Line[], from: number, to: number): BlockSpec[] {
171 const out: BlockSpec[] = [];
172 let i = from;
173 while (i < to) {
174 const line = lines[i]!;
175 const text = line.text;
176 if (!text.trim()) {
177 i++;
178 continue;
179 }
180 // Fenced code, Mermaid.
181 const fenceOpen = /^(`{3,}|~{3,})\s*([^\s`]*)/.exec(text);
182 if (fenceOpen) {
183 const marker = fenceOpen[1]!;
184 const lang = fenceOpen[2]!.toLowerCase();
185 const body: string[] = [];
186 i++;
187 while (i < to && !lines[i]!.text.startsWith(marker)) {
188 body.push(" ".repeat(Math.max(0, lines[i]!.indent - line.indent)) + lines[i]!.text);
189 i++;
190 }
191 i++;
192 const code = body.join("\n");
193 if (lang === "mermaid") out.push({ type: "mermaid", props: { code } });
194 else if (lang === "math" || lang === "latex" || lang === "katex") out.push({ type: "math", props: { expression: code } });
195 else out.push({ type: "codeBlock", props: { language: lang || "text" }, content: code ? [{ text: code }] : [] });
196 continue;
197 }
198 // Math.
199 if (text.startsWith("$$")) {
200 const same = /^\$\$(.+)\$\$\s*$/.exec(text);
201 if (same) {
202 out.push({ type: "math", props: { expression: same[1]!.trim() } });
203 i++;
204 continue;
205 }
206 const body: string[] = [];
207 i++;
208 while (i < to && !lines[i]!.text.startsWith("$$")) body.push(lines[i++]!.text);
209 i++;
210 out.push({ type: "math", props: { expression: body.join("\n").trim() } });
211 continue;
212 }
213 // Headings.
214 const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(text);
215 if (heading) {
216 out.push({ type: "heading", props: { level: Math.min(heading[1]!.length, 6) }, content: parseInline(heading[2]!) });
217 i++;
218 continue;
219 }
220 // Dividers.
221 if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(text)) {
222 out.push({ type: "divider" });
223 i++;
224 continue;
225 }
226 // Toggles.
227 if (text.startsWith("<details>")) {
228 let summary = "";
229 const inner: Line[] = [];
230 let depth = 0;
231 const first = text.slice("<details>".length);
232 const queue = first.trim() ? [{ indent: 0, text: first.trim() }] : [];
233 i++;
234 const take = (l: Line): boolean => {
235 const sm = /^<summary>([\s\S]*?)<\/summary>\s*(.*)$/.exec(l.text);
236 if (sm && !summary) {
237 summary = sm[1]!;
238 if (sm[2]) inner.push({ indent: 0, text: sm[2] });
239 return false;
240 }
241 if (l.text.startsWith("<details>")) depth++;
242 if (l.text.startsWith("</details>")) {
243 if (depth === 0) return true;
244 depth--;
245 }
246 inner.push(l);
247 return false;
248 };
249 let closed = false;
250 for (const l of queue) if (take(l)) closed = true;
251 while (!closed && i < to) {
252 closed = take(lines[i]!);
253 i++;
254 }
255 out.push({ type: "toggleListItem", content: parseInline(summary.trim()), children: parseLines(inner, 0, inner.length) });
256 continue;
257 }
258 // Quotes and callouts.
259 if (text.startsWith(">")) {
260 const body: string[] = [];
261 while (i < to && lines[i]!.text.startsWith(">")) {
262 body.push(lines[i]!.text.replace(/^>\s?/, ""));
263 i++;
264 }
265 const alert = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i.exec(body[0] ?? "");
266 if (alert) {
267 const rest = [alert[2]!, ...body.slice(1)]
268 .filter((l, n) => n > 0 || l.trim())
269 .join("\n")
270 .trim();
271 out.push({ type: "callout", props: { kind: ALERT_KINDS[alert[1]!.toUpperCase()] ?? "info" }, content: parseInline(joinParagraph(rest)) });
272 } else {
273 out.push({ type: "quote", content: parseInline(joinParagraph(body.join("\n").trim())) });
274 }
275 continue;
276 }
277 // Images on a line of their own.
278 const image = /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\s*$/.exec(text);
279 if (image) {
280 out.push({ type: "image", props: { url: image[2]!, caption: image[1]!, name: image[1]! } });
281 i++;
282 continue;
283 }
284 // Tables.
285 if (text.includes("|") && i + 1 < to && TABLE_RULE.test(lines[i + 1]!.text)) {
286 const rows: Run[][][] = [splitRow(text).map((c) => parseInline(c))];
287 i += 2;
288 while (i < to && lines[i]!.text.includes("|") && lines[i]!.text.trim()) {
289 rows.push(splitRow(lines[i]!.text).map((c) => parseInline(c)));
290 i++;
291 }
292 out.push({ type: "table", rows });
293 continue;
294 }
295 // Lists.
296 const item = LIST_ITEM.exec(text);
297 if (item) {
298 const base = line.indent;
299 const marker = item[1]!;
300 const contentIndent = base + marker.length + 1;
301 let first = item[2]!;
302 let type = /^\d/.test(marker) ? "numberedListItem" : "bulletListItem";
303 const props: Record<string, string | number | boolean> = {};
304 const task = /^\[([ xX])\]\s+(.*)$/.exec(first);
305 if (task && type === "bulletListItem") {
306 type = "checkListItem";
307 props.checked = task[1] !== " ";
308 first = task[2]!;
309 }
310 if (type === "numberedListItem") {
311 const start = parseInt(marker, 10);
312 if (start !== 1 && !out.some((b) => b.type === "numberedListItem")) props.start = start;
313 }
314 i++;
315 // Continuation lines of the item's own paragraph.
316 const own = [first];
317 while (i < to && lines[i]!.text.trim() && lines[i]!.indent >= contentIndent - 1 && !isBlockStart(lines[i]!.text) && !LIST_ITEM.test(lines[i]!.text)) {
318 own.push(lines[i]!.text);
319 i++;
320 }
321 // Nested blocks: lines indented past the marker (blank lines allowed between).
322 const nested: Line[] = [];
323 while (i < to) {
324 const l = lines[i]!;
325 if (!l.text.trim()) {
326 // A blank line ends the item unless more indented content follows.
327 let j = i + 1;
328 while (j < to && !lines[j]!.text.trim()) j++;
329 if (j < to && lines[j]!.indent > base && lines[j]!.indent >= Math.min(contentIndent, base + 2)) {
330 for (; i < j; i++) nested.push({ indent: 0, text: "" });
331 continue;
332 }
333 break;
334 }
335 if (l.indent > base && l.indent >= Math.min(contentIndent, base + 2)) {
336 nested.push({ indent: l.indent - Math.min(contentIndent, l.indent), text: l.text });
337 i++;
338 continue;
339 }
340 break;
341 }
342 out.push({ type, props, content: parseInline(joinParagraph(own.join("\n"))), children: parseLines(nested, 0, nested.length) });
343 continue;
344 }
345 // A paragraph: lines up to a blank line or another block.
346 const para = [text];
347 i++;
348 while (
349 i < to &&
350 lines[i]!.text.trim() &&
351 !isBlockStart(lines[i]!.text) &&
352 !(lines[i]!.text.includes("|") && i + 1 < to && TABLE_RULE.test(lines[i + 1]!.text))
353 ) {
354 para.push(lines[i]!.text);
355 i++;
356 }
357 out.push({ type: "paragraph", content: parseInline(joinParagraph(para.join("\n"))) });
358 }
359 return out;
360}
361
362/** Soft line breaks become spaces; a trailing backslash or two spaces stay a hard break. */
363function joinParagraph(text: string): string {
364 return text.replace(/( {2,}|\\)\n/g, "\\\n").replace(/([^\\])\n/g, "$1 ");
365}
366
367// ── Into Yjs ──────────────────────────────────────────────────────────────
368
369/** The attributes BlockNote gives each of its blocks when it makes them. */
370function defaults(type: string): Record<string, unknown> {
371 const colors = { backgroundColor: "default", textColor: "default" };
372 switch (type) {
373 case "paragraph":
374 case "bulletListItem":
375 case "numberedListItem":
376 case "toggleListItem":
377 return { ...colors, textAlignment: "left" };
378 case "checkListItem":
379 return { ...colors, textAlignment: "left", checked: false };
380 case "heading":
381 return { ...colors, textAlignment: "left", level: 1, isToggleable: false };
382 case "quote":
383 return { ...colors };
384 case "codeBlock":
385 return { language: "text" };
386 case "image":
387 return { textAlignment: "left", backgroundColor: "default", name: "", url: "", caption: "", showPreview: true };
388 case "table":
389 return { textColor: "default" };
390 case "callout":
391 return { ...colors, kind: "info" };
392 case "mermaid":
393 return { code: "" };
394 case "math":
395 return { expression: "" };
396 default:
397 return {};
398 }
399}
400
401function newBlockId(): string {
402 return crypto.randomUUID();
403}
404
405function textOf(runs: Run[] | undefined): (Y.XmlText | Y.XmlElement)[] {
406 const out: (Y.XmlText | Y.XmlElement)[] = [];
407 let text: Y.XmlText | null = null;
408 const pending: Run[] = [];
409 const flush = () => {
410 if (!pending.length) return;
411 text = new Y.XmlText();
412 let at = 0;
413 for (const run of pending) {
414 if (!("text" in run)) continue;
415 text.insert(at, run.text, run.marks && Object.keys(run.marks).length ? run.marks : {});
416 at += run.text.length;
417 }
418 out.push(text);
419 pending.length = 0;
420 };
421 for (const run of runs ?? []) {
422 if ("text" in run) pending.push(run);
423 else {
424 flush();
425 const el = new Y.XmlElement(run.node);
426 for (const [k, v] of Object.entries(run.attrs)) el.setAttribute(k, v);
427 out.push(el);
428 }
429 }
430 flush();
431 return out;
432}
433
434function tableNode(rows: Run[][][]): Y.XmlElement {
435 const table = new Y.XmlElement("table");
436 for (const [k, v] of Object.entries(defaults("table"))) table.setAttribute(k, v as string);
437 const width = Math.max(1, ...rows.map((r) => r.length));
438 table.insert(
439 0,
440 rows.map((row) => {
441 const tr = new Y.XmlElement("tableRow");
442 tr.insert(
443 0,
444 Array.from({ length: width }, (_, n) => {
445 const cell = new Y.XmlElement("tableCell");
446 for (const [k, v] of Object.entries({ textColor: "default", backgroundColor: "default", textAlignment: "left", colspan: 1, rowspan: 1 }))
447 cell.setAttribute(k, v as unknown as string);
448 const p = new Y.XmlElement("tableParagraph");
449 p.insert(0, textOf(row[n] ?? []));
450 cell.insert(0, [p]);
451 return cell;
452 }),
453 );
454 return tr;
455 }),
456 );
457 return table;
458}
459
460/** One block spec as a `blockContainer` (with its children), ready to insert. */
461export function containerOf(spec: BlockSpec): Y.XmlElement {
462 const container = new Y.XmlElement("blockContainer");
463 container.setAttribute("id", newBlockId());
464 let content: Y.XmlElement;
465 if (spec.type === "table") content = tableNode(spec.rows ?? []);
466 else {
467 content = new Y.XmlElement(spec.type);
468 for (const [k, v] of Object.entries({ ...defaults(spec.type), ...(spec.props ?? {}) })) content.setAttribute(k, v as string);
469 if (spec.content?.length) content.insert(0, textOf(spec.content));
470 }
471 const parts: Y.XmlElement[] = [content];
472 if (spec.children?.length) {
473 const group = new Y.XmlElement("blockGroup");
474 group.insert(0, spec.children.map(containerOf));
475 parts.push(group);
476 }
477 container.insert(0, parts);
478 return container;
479}
480
481/** Block specs as containers. An empty list becomes one empty paragraph, as the editor keeps. */
482export function writeBlocks(specs: BlockSpec[]): Y.XmlElement[] {
483 return (specs.length ? specs : [{ type: "paragraph" }]).map(containerOf);
484}
485
486/** The fragment's block group, made when the document is new. */
487export function blockGroupOf(fragment: Y.XmlFragment): Y.XmlElement {
488 const found = fragment.toArray().find((n): n is Y.XmlElement => n instanceof Y.XmlElement && n.nodeName === "blockGroup");
489 if (found) return found;
490 const group = new Y.XmlElement("blockGroup");
491 fragment.insert(fragment.length, [group]);
492 return group;
493}
494
495/** Fills an empty document from Markdown (a template, an agent's new page). */
496export function seed(doc: Y.Doc, fragment: Y.XmlFragment, markdown: string): void {
497 doc.transact(() => {
498 const group = blockGroupOf(fragment);
499 if (group.length) group.delete(0, group.length);
500 group.insert(0, writeBlocks(parseMarkdown(markdown)));
501 });
502}