@abraca/dabra 1.8.1 → 1.9.1

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.
@@ -0,0 +1,1707 @@
1
+ /**
2
+ * Document content converters — markdown ↔ Y.js.
3
+ *
4
+ * Canonical implementations for converting between Markdown text and TipTap-
5
+ * compatible Y.XmlFragment structures. Previously lived in
6
+ * `mcp/converters/` — now the authoritative home is the provider package so
7
+ * any consumer of `@abraca/dabra` can use them without pulling in MCP deps.
8
+ */
9
+ import * as Y from "yjs";
10
+ import type { PageMeta } from "./DocTypes.ts";
11
+
12
+ // ══════════════════════════════════════════════════════════════════════════════
13
+ // Y.XmlFragment → Markdown (yjsToMarkdown)
14
+ // ══════════════════════════════════════════════════════════════════════════════
15
+
16
+ // ── Inline text serialization ────────────────────────────────────────────────
17
+
18
+ function deltaToMarkdown(
19
+ delta: { insert: string; attributes?: Record<string, any> }[],
20
+ ): string {
21
+ return delta
22
+ .map((op) => {
23
+ let text = op.insert as string;
24
+ if (!op.attributes) return text;
25
+
26
+ const a = op.attributes;
27
+
28
+ if (a.code) text = `\`${text}\``;
29
+ if (a.bold) text = `**${text}**`;
30
+ if (a.italic) text = `*${text}*`;
31
+ if (a.strike) text = `~~${text}~~`;
32
+ if (a.link?.href) text = `[${text}](${a.link.href})`;
33
+ if (a.badge) text = `:badge[${a.badge.label || text}]`;
34
+ if (a.kbd) text = `:kbd{value="${a.kbd.value || text}"}`;
35
+ if (a.proseIcon) text = `:icon{name="${a.proseIcon.name}"}`;
36
+
37
+ return text;
38
+ })
39
+ .join("");
40
+ }
41
+
42
+ function xmlTextToMarkdown(xmlText: Y.XmlText): string {
43
+ const delta = xmlText.toDelta();
44
+ return deltaToMarkdown(delta);
45
+ }
46
+
47
+ function elementTextContent(el: Y.XmlElement | Y.XmlFragment): string {
48
+ const parts: string[] = [];
49
+ for (let i = 0; i < el.length; i++) {
50
+ const child = el.get(i);
51
+ if (child instanceof Y.XmlText) {
52
+ parts.push(xmlTextToMarkdown(child));
53
+ } else if (child instanceof Y.XmlElement) {
54
+ if (child.nodeName === "docLink") {
55
+ const docId = child.getAttribute("docId");
56
+ if (docId) parts.push(`[[${docId}]]`);
57
+ continue;
58
+ }
59
+ parts.push(elementTextContent(child));
60
+ }
61
+ }
62
+ return parts.join("");
63
+ }
64
+
65
+ // ── Block serialization ──────────────────────────────────────────────────────
66
+
67
+ function serializeElement(el: Y.XmlElement, indent = ""): string {
68
+ const name = el.nodeName;
69
+
70
+ switch (name) {
71
+ case "documentHeader":
72
+ case "documentMeta":
73
+ return "";
74
+
75
+ case "heading": {
76
+ const level = Number(el.getAttribute("level")) || 1;
77
+ const prefix = "#".repeat(level);
78
+ return `${prefix} ${elementTextContent(el)}`;
79
+ }
80
+
81
+ case "paragraph":
82
+ return elementTextContent(el);
83
+
84
+ case "bulletList":
85
+ return serializeList(el, "bullet", indent);
86
+
87
+ case "orderedList":
88
+ return serializeList(el, "ordered", indent);
89
+
90
+ case "taskList":
91
+ return serializeTaskList(el, indent);
92
+
93
+ case "codeBlock": {
94
+ const lang = el.getAttribute("language") || "";
95
+ const code = elementTextContent(el);
96
+ return `\`\`\`${lang}\n${code}\n\`\`\``;
97
+ }
98
+
99
+ case "blockquote": {
100
+ const lines: string[] = [];
101
+ for (let i = 0; i < el.length; i++) {
102
+ const child = el.get(i);
103
+ if (child instanceof Y.XmlElement) {
104
+ lines.push(serializeElement(child, indent));
105
+ }
106
+ }
107
+ return lines.map((l) => `> ${l}`).join("\n");
108
+ }
109
+
110
+ case "horizontalRule":
111
+ return "---";
112
+
113
+ case "table":
114
+ return serializeTable(el);
115
+
116
+ case "docEmbed": {
117
+ const docId = el.getAttribute("docId");
118
+ if (!docId) return "";
119
+ const seamlessAttr = el.getAttribute("seamless");
120
+ const seamless =
121
+ seamlessAttr === true || seamlessAttr === "true";
122
+ return seamless ? `![[${docId}]]{seamless}` : `![[${docId}]]`;
123
+ }
124
+
125
+ case "svgEmbed": {
126
+ const svg = el.getAttribute("svg") || "";
127
+ const svgTitle = el.getAttribute("title") || "";
128
+ if (!svg) return "";
129
+ return `\`\`\`svg${svgTitle ? ` ${svgTitle}` : ""}\n${svg}\n\`\`\``;
130
+ }
131
+
132
+ case "image": {
133
+ const src = el.getAttribute("src") || "";
134
+ const alt = el.getAttribute("alt") || "";
135
+ const w = el.getAttribute("width");
136
+ const h = el.getAttribute("height");
137
+ let attrs = "";
138
+ if (w || h) {
139
+ const imgParts: string[] = [];
140
+ if (w) imgParts.push(`width="${w}"`);
141
+ if (h) imgParts.push(`height="${h}"`);
142
+ attrs = `{${imgParts.join(" ")}}`;
143
+ }
144
+ return `![${alt}](${src})${attrs}`;
145
+ }
146
+
147
+ case "callout": {
148
+ const type = el.getAttribute("type") || "note";
149
+ const inner = serializeChildren(el, indent);
150
+ return `::${type}\n${inner}\n::`;
151
+ }
152
+
153
+ case "collapsible": {
154
+ const label = el.getAttribute("label") || "Details";
155
+ const open = el.getAttribute("open");
156
+ const props: string[] = [`label="${label}"`];
157
+ if (open === true || open === "true") props.push('open="true"');
158
+ const inner = serializeChildren(el, indent);
159
+ return `::collapsible{${props.join(" ")}}\n${inner}\n::`;
160
+ }
161
+
162
+ case "steps": {
163
+ const inner = serializeChildren(el, indent);
164
+ return `::steps\n${inner}\n::`;
165
+ }
166
+
167
+ case "card": {
168
+ const title = el.getAttribute("title") || "";
169
+ const icon = el.getAttribute("icon") || "";
170
+ const to = el.getAttribute("to") || "";
171
+ const props: string[] = [];
172
+ if (title) props.push(`title="${title}"`);
173
+ if (icon) props.push(`icon="${icon}"`);
174
+ if (to) props.push(`to="${to}"`);
175
+ const inner = serializeChildren(el, indent);
176
+ return `::card{${props.join(" ")}}\n${inner}\n::`;
177
+ }
178
+
179
+ case "cardGroup": {
180
+ const inner = serializeChildren(el, indent);
181
+ return `::card-group\n${inner}\n::`;
182
+ }
183
+
184
+ case "codeCollapse": {
185
+ const inner = serializeChildren(el, indent);
186
+ return `::code-collapse\n${inner}\n::`;
187
+ }
188
+
189
+ case "codeGroup": {
190
+ const inner = serializeChildren(el, indent);
191
+ return `::code-group\n${inner}\n::`;
192
+ }
193
+
194
+ case "codePreview": {
195
+ const inner = serializeChildren(el, indent);
196
+ return `::code-preview\n${inner}\n::`;
197
+ }
198
+
199
+ case "codeTree": {
200
+ const files = el.getAttribute("files") || "[]";
201
+ return `::code-tree{files="${files}"}\n::`;
202
+ }
203
+
204
+ case "accordion":
205
+ return serializeSlottedComponent(
206
+ el,
207
+ "accordion",
208
+ "accordionItem",
209
+ "item",
210
+ );
211
+
212
+ case "tabs":
213
+ return serializeSlottedComponent(el, "tabs", "tabsItem", "tab");
214
+
215
+ case "field": {
216
+ const fieldName = el.getAttribute("name") || "";
217
+ const fieldType = el.getAttribute("type") || "string";
218
+ const required = el.getAttribute("required");
219
+ const props: string[] = [];
220
+ if (fieldName) props.push(`name="${fieldName}"`);
221
+ props.push(`type="${fieldType}"`);
222
+ if (required === true || required === "true")
223
+ props.push('required="true"');
224
+ const inner = serializeChildren(el, indent);
225
+ return `::field{${props.join(" ")}}\n${inner}\n::`;
226
+ }
227
+
228
+ case "fieldGroup": {
229
+ const inner = serializeChildren(el, indent);
230
+ return `::field-group\n${inner}\n::`;
231
+ }
232
+
233
+ default: {
234
+ return serializeChildren(el, indent);
235
+ }
236
+ }
237
+ }
238
+
239
+ function serializeList(
240
+ el: Y.XmlElement,
241
+ type: "bullet" | "ordered",
242
+ indent: string,
243
+ ): string {
244
+ const lines: string[] = [];
245
+ for (let i = 0; i < el.length; i++) {
246
+ const item = el.get(i);
247
+ if (
248
+ item instanceof Y.XmlElement &&
249
+ item.nodeName === "listItem"
250
+ ) {
251
+ const prefix = type === "bullet" ? "- " : `${i + 1}. `;
252
+ const content = elementTextContent(item);
253
+ lines.push(`${indent}${prefix}${content}`);
254
+ }
255
+ }
256
+ return lines.join("\n");
257
+ }
258
+
259
+ function serializeTaskList(el: Y.XmlElement, indent: string): string {
260
+ const lines: string[] = [];
261
+ for (let i = 0; i < el.length; i++) {
262
+ const item = el.get(i);
263
+ if (
264
+ item instanceof Y.XmlElement &&
265
+ item.nodeName === "taskItem"
266
+ ) {
267
+ const checked = item.getAttribute("checked");
268
+ const marker =
269
+ checked === true || checked === "true" ? "[x]" : "[ ]";
270
+ const content = elementTextContent(item);
271
+ lines.push(`${indent}- ${marker} ${content}`);
272
+ }
273
+ }
274
+ return lines.join("\n");
275
+ }
276
+
277
+ function serializeTable(el: Y.XmlElement): string {
278
+ const rows: string[][] = [];
279
+
280
+ for (let i = 0; i < el.length; i++) {
281
+ const row = el.get(i);
282
+ if (
283
+ !(row instanceof Y.XmlElement) ||
284
+ row.nodeName !== "tableRow"
285
+ )
286
+ continue;
287
+
288
+ const cells: string[] = [];
289
+ for (let j = 0; j < row.length; j++) {
290
+ const cell = row.get(j);
291
+ if (cell instanceof Y.XmlElement) {
292
+ cells.push(elementTextContent(cell));
293
+ }
294
+ }
295
+ rows.push(cells);
296
+ }
297
+
298
+ if (!rows.length) return "";
299
+
300
+ const headerRow = rows[0]!;
301
+ const lines = [`| ${headerRow.join(" | ")} |`];
302
+
303
+ // Separator
304
+ lines.push(`| ${headerRow.map(() => "---").join(" | ")} |`);
305
+
306
+ // Data rows
307
+ for (let i = 1; i < rows.length; i++) {
308
+ lines.push(`| ${rows[i]!.join(" | ")} |`);
309
+ }
310
+
311
+ return lines.join("\n");
312
+ }
313
+
314
+ function serializeSlottedComponent(
315
+ el: Y.XmlElement,
316
+ componentName: string,
317
+ childNodeName: string,
318
+ slotName: string,
319
+ ): string {
320
+ const parts: string[] = [`::${componentName}`];
321
+
322
+ for (let i = 0; i < el.length; i++) {
323
+ const child = el.get(i);
324
+ if (
325
+ !(child instanceof Y.XmlElement) ||
326
+ child.nodeName !== childNodeName
327
+ )
328
+ continue;
329
+
330
+ const label = child.getAttribute("label") || `Item ${i + 1}`;
331
+ const icon = child.getAttribute("icon") || "";
332
+ const props: string[] = [`label="${label}"`];
333
+ if (icon) props.push(`icon="${icon}"`);
334
+ parts.push(`#${slotName}{${props.join(" ")}}`);
335
+
336
+ const inner = serializeChildren(child, "");
337
+ if (inner) parts.push(inner);
338
+ }
339
+
340
+ parts.push("::");
341
+ return parts.join("\n");
342
+ }
343
+
344
+ function serializeChildren(
345
+ el: Y.XmlElement | Y.XmlFragment,
346
+ indent: string,
347
+ ): string {
348
+ const parts: string[] = [];
349
+ for (let i = 0; i < el.length; i++) {
350
+ const child = el.get(i);
351
+ if (child instanceof Y.XmlElement) {
352
+ const serialized = serializeElement(child, indent);
353
+ if (serialized) parts.push(serialized);
354
+ } else if (child instanceof Y.XmlText) {
355
+ const text = xmlTextToMarkdown(child);
356
+ if (text) parts.push(text);
357
+ }
358
+ }
359
+ return parts.join("\n\n");
360
+ }
361
+
362
+ // ── Public: Y.js → Markdown ─────────────────────────────────────────────────
363
+
364
+ /**
365
+ * Converts a Y.XmlFragment (TipTap document) to markdown.
366
+ * Extracts the title from the documentHeader element.
367
+ *
368
+ * @returns `{ title, markdown }` where title is the H1/header text
369
+ */
370
+ export function yjsToMarkdown(
371
+ fragment: Y.XmlFragment,
372
+ ): { title: string; markdown: string } {
373
+ let title = "Untitled";
374
+ const bodyParts: string[] = [];
375
+
376
+ for (let i = 0; i < fragment.length; i++) {
377
+ const child = fragment.get(i);
378
+ if (!(child instanceof Y.XmlElement)) continue;
379
+
380
+ if (child.nodeName === "documentHeader") {
381
+ title = elementTextContent(child) || "Untitled";
382
+ continue;
383
+ }
384
+
385
+ if (child.nodeName === "documentMeta") {
386
+ continue;
387
+ }
388
+
389
+ const serialized = serializeElement(child);
390
+ if (serialized !== "") {
391
+ bodyParts.push(serialized);
392
+ }
393
+ }
394
+
395
+ return { title, markdown: bodyParts.join("\n\n") };
396
+ }
397
+
398
+ // ══════════════════════════════════════════════════════════════════════════════
399
+ // Markdown → Y.XmlFragment (populateYDocFromMarkdown)
400
+ // ══════════════════════════════════════════════════════════════════════════════
401
+
402
+ // ── Filename → readable label ────────────────────────────────────────────────
403
+
404
+ export function filenameToLabel(raw: string): string {
405
+ const base = raw.replace(/\.[^.]+$/, "");
406
+ const spaced = base.replace(/([a-z])([A-Z])/g, "$1 $2");
407
+ const clean = spaced
408
+ .replace(/[-_.]+/g, " ")
409
+ .replace(/\s+/g, " ")
410
+ .trim();
411
+ return clean.charAt(0).toUpperCase() + clean.slice(1);
412
+ }
413
+
414
+ // ── YAML frontmatter parser ──────────────────────────────────────────────────
415
+
416
+ export interface FrontmatterResult {
417
+ title?: string;
418
+ meta: Partial<PageMeta>;
419
+ body: string;
420
+ }
421
+
422
+ function parseInlineArray(raw: string): string[] {
423
+ return raw
424
+ .slice(1, -1)
425
+ .split(",")
426
+ .map((s) => s.trim())
427
+ .filter(Boolean);
428
+ }
429
+
430
+ export function parseFrontmatter(markdown: string): FrontmatterResult {
431
+ const noResult: FrontmatterResult = { meta: {}, body: markdown };
432
+
433
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
434
+ if (!match) return noResult;
435
+
436
+ const yamlBlock = match[1]!;
437
+ const body = markdown.slice(match[0].length);
438
+
439
+ const raw: Record<string, string | string[]> = {};
440
+ const lines = yamlBlock.split("\n");
441
+ let i = 0;
442
+ while (i < lines.length) {
443
+ const line = lines[i]!;
444
+ const blockSeqKey = line.match(/^(\w[\w-]*):\s*$/);
445
+ if (
446
+ blockSeqKey &&
447
+ i + 1 < lines.length &&
448
+ /^\s+-\s/.test(lines[i + 1]!)
449
+ ) {
450
+ const key = blockSeqKey[1]!;
451
+ const items: string[] = [];
452
+ i++;
453
+ while (i < lines.length && /^\s+-\s/.test(lines[i]!)) {
454
+ items.push(lines[i]!.replace(/^\s+-\s/, "").trim());
455
+ i++;
456
+ }
457
+ raw[key] = items;
458
+ continue;
459
+ }
460
+ const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
461
+ if (kvMatch) {
462
+ const key = kvMatch[1]!;
463
+ const val = kvMatch[2]!.trim();
464
+ if (val.startsWith("[") && val.endsWith("]")) {
465
+ raw[key] = parseInlineArray(val);
466
+ } else {
467
+ raw[key] = val;
468
+ }
469
+ }
470
+ i++;
471
+ }
472
+
473
+ const meta: Partial<PageMeta> = {};
474
+
475
+ const getStr = (keys: string[]): string | undefined => {
476
+ for (const k of keys) {
477
+ const v = raw[k];
478
+ if (typeof v === "string" && v) return v;
479
+ }
480
+ };
481
+
482
+ if (raw["tags"])
483
+ meta.tags = Array.isArray(raw["tags"])
484
+ ? raw["tags"]
485
+ : [raw["tags"] as string];
486
+ const color = getStr(["color"]);
487
+ if (color) meta.color = color;
488
+ const icon = getStr(["icon"]);
489
+ if (icon) meta.icon = icon;
490
+ const status = getStr(["status"]);
491
+ if (status) meta.status = status;
492
+
493
+ const priorityRaw = getStr(["priority"]);
494
+ if (priorityRaw !== undefined) {
495
+ const map: Record<string, number> = {
496
+ low: 1,
497
+ medium: 2,
498
+ high: 3,
499
+ urgent: 4,
500
+ };
501
+ meta.priority =
502
+ map[priorityRaw.toLowerCase()] ?? (Number(priorityRaw) || 0);
503
+ }
504
+
505
+ const checkedRaw = raw["checked"] ?? raw["done"];
506
+ if (checkedRaw !== undefined)
507
+ meta.checked = checkedRaw === "true" || checkedRaw === true;
508
+
509
+ const dateStart = getStr(["date", "created"]);
510
+ if (dateStart) meta.dateStart = dateStart;
511
+ const dateEnd = getStr(["due"]);
512
+ if (dateEnd) meta.dateEnd = dateEnd;
513
+
514
+ const subtitle = getStr(["description", "subtitle"]);
515
+ if (subtitle) meta.subtitle = subtitle;
516
+ const url = getStr(["url"]);
517
+ if (url) meta.url = url;
518
+ const email = getStr(["email"]);
519
+ if (email) meta.email = email;
520
+ const phone = getStr(["phone"]);
521
+ if (phone) meta.phone = phone;
522
+
523
+ const ratingRaw = getStr(["rating"]);
524
+ if (ratingRaw !== undefined) {
525
+ const n = Number(ratingRaw);
526
+ if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n));
527
+ }
528
+
529
+ // Datetime fields
530
+ const datetimeStart = getStr(["datetimeStart"]);
531
+ if (datetimeStart) meta.datetimeStart = datetimeStart;
532
+ const datetimeEnd = getStr(["datetimeEnd"]);
533
+ if (datetimeEnd) meta.datetimeEnd = datetimeEnd;
534
+ const allDayRaw = raw["allDay"];
535
+ if (allDayRaw !== undefined)
536
+ meta.allDay = allDayRaw === "true" || allDayRaw === true;
537
+
538
+ // Geo fields
539
+ const geoLatRaw = getStr(["geoLat"]);
540
+ if (geoLatRaw !== undefined) {
541
+ const n = Number(geoLatRaw);
542
+ if (!Number.isNaN(n)) meta.geoLat = n;
543
+ }
544
+ const geoLngRaw = getStr(["geoLng"]);
545
+ if (geoLngRaw !== undefined) {
546
+ const n = Number(geoLngRaw);
547
+ if (!Number.isNaN(n)) meta.geoLng = n;
548
+ }
549
+ const geoType = getStr(["geoType"]);
550
+ if (
551
+ geoType &&
552
+ (geoType === "marker" || geoType === "line" || geoType === "measure")
553
+ ) {
554
+ meta.geoType = geoType;
555
+ }
556
+ const geoDescription = getStr(["geoDescription"]);
557
+ if (geoDescription) meta.geoDescription = geoDescription;
558
+
559
+ // Numeric fields
560
+ const numberRaw = getStr(["number"]);
561
+ if (numberRaw !== undefined) {
562
+ const n = Number(numberRaw);
563
+ if (!Number.isNaN(n)) meta.number = n;
564
+ }
565
+ const unit = getStr(["unit"]);
566
+ if (unit) meta.unit = unit;
567
+
568
+ // Note
569
+ const note = getStr(["note"]);
570
+ if (note) meta.note = note;
571
+
572
+ const title = typeof raw["title"] === "string" ? raw["title"] : undefined;
573
+
574
+ return { title, meta, body };
575
+ }
576
+
577
+ // ── Inline token parsing ─────────────────────────────────────────────────────
578
+
579
+ interface InlineToken {
580
+ text: string;
581
+ attrs?: Record<string, unknown>;
582
+ /** If set, emit an inline Y.XmlElement with this nodeName instead of text+marks. */
583
+ node?: string;
584
+ /** Attributes for the inline node (when node is set). */
585
+ nodeAttrs?: Record<string, string>;
586
+ }
587
+
588
+ function parseMdcProps(
589
+ propsStr: string | undefined,
590
+ ): Record<string, string> {
591
+ if (!propsStr) return {};
592
+ const result: Record<string, string> = {};
593
+ const re = /(\w[\w-]*)="([^"]*)"/g;
594
+ let m: RegExpExecArray | null;
595
+ while ((m = re.exec(propsStr)) !== null) {
596
+ result[m[1]!] = m[2]!;
597
+ }
598
+ return result;
599
+ }
600
+
601
+ function parseInline(text: string): InlineToken[] {
602
+ const stripped = text
603
+ .replace(/\{lang="[^"]*"\}/g, "")
604
+ .replace(
605
+ /:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g,
606
+ "$2",
607
+ )
608
+ .replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, "");
609
+
610
+ const tokens: InlineToken[] = [];
611
+ const re =
612
+ /:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|!?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g;
613
+ let lastIndex = 0;
614
+ let match: RegExpExecArray | null;
615
+
616
+ while ((match = re.exec(stripped)) !== null) {
617
+ if (match.index > lastIndex) {
618
+ tokens.push({ text: stripped.slice(lastIndex, match.index) });
619
+ }
620
+ if (match[1] !== undefined) {
621
+ const badgeProps = parseMdcProps(match[2]);
622
+ tokens.push({
623
+ text: match[1] || "Badge",
624
+ attrs: {
625
+ badge: {
626
+ label: match[1] || "Badge",
627
+ color: badgeProps["color"] || "neutral",
628
+ variant: badgeProps["variant"] || "subtle",
629
+ },
630
+ },
631
+ });
632
+ } else if (match[3] !== undefined) {
633
+ const iconProps = parseMdcProps(`{${match[3]}}`);
634
+ tokens.push({
635
+ text: "\u200B",
636
+ attrs: {
637
+ proseIcon: {
638
+ name: iconProps["name"] || "i-lucide-star",
639
+ },
640
+ },
641
+ });
642
+ } else if (match[4] !== undefined) {
643
+ const kbdProps = parseMdcProps(`{${match[4]}}`);
644
+ tokens.push({
645
+ text: kbdProps["value"] || "",
646
+ attrs: { kbd: { value: kbdProps["value"] || "" } },
647
+ });
648
+ } else if (match[5] !== undefined) {
649
+ tokens.push({
650
+ text: "",
651
+ node: "docLink",
652
+ nodeAttrs: { docId: match[5]! },
653
+ });
654
+ } else if (match[7] !== undefined) {
655
+ tokens.push({ text: match[7], attrs: { strike: true } });
656
+ } else if (match[8] !== undefined) {
657
+ tokens.push({ text: match[8], attrs: { bold: true } });
658
+ } else if (match[9] !== undefined) {
659
+ tokens.push({ text: match[9], attrs: { italic: true } });
660
+ } else if (match[10] !== undefined) {
661
+ tokens.push({ text: match[10], attrs: { italic: true } });
662
+ } else if (match[11] !== undefined) {
663
+ tokens.push({ text: match[11], attrs: { code: true } });
664
+ } else if (match[12] !== undefined && match[13] !== undefined) {
665
+ tokens.push({
666
+ text: match[12],
667
+ attrs: { link: { href: match[13] } },
668
+ });
669
+ }
670
+ lastIndex = match.index + match[0].length;
671
+ }
672
+
673
+ if (lastIndex < stripped.length) {
674
+ tokens.push({ text: stripped.slice(lastIndex) });
675
+ }
676
+ return tokens.filter((t) => t.node || t.text.length > 0);
677
+ }
678
+
679
+ // ── Block-level parser ───────────────────────────────────────────────────────
680
+
681
+ interface TaskItem {
682
+ text: string;
683
+ checked: boolean;
684
+ }
685
+
686
+ type Block =
687
+ | { type: "heading"; level: number; text: string }
688
+ | { type: "paragraph"; text: string }
689
+ | { type: "bulletList"; items: string[] }
690
+ | { type: "orderedList"; items: string[] }
691
+ | { type: "taskList"; items: TaskItem[] }
692
+ | { type: "codeBlock"; lang: string; code: string }
693
+ | { type: "blockquote"; lines: string[] }
694
+ | { type: "table"; headerRow: string[]; dataRows: string[][] }
695
+ | { type: "hr" }
696
+ | { type: "callout"; calloutType: string; innerBlocks: Block[] }
697
+ | {
698
+ type: "collapsible";
699
+ label: string;
700
+ open: boolean;
701
+ innerBlocks: Block[];
702
+ }
703
+ | { type: "steps"; innerBlocks: Block[] }
704
+ | {
705
+ type: "card";
706
+ title: string;
707
+ icon: string;
708
+ to: string;
709
+ innerBlocks: Block[];
710
+ }
711
+ | { type: "cardGroup"; cards: Block[] }
712
+ | { type: "codeCollapse"; codeBlocks: Block[] }
713
+ | { type: "codeGroup"; codeBlocks: Block[] }
714
+ | { type: "codePreview"; innerBlocks: Block[]; codeBlocks: Block[] }
715
+ | { type: "codeTree"; files: string }
716
+ | {
717
+ type: "accordion";
718
+ items: { label: string; icon: string; innerBlocks: Block[] }[];
719
+ }
720
+ | {
721
+ type: "tabs";
722
+ items: { label: string; icon: string; innerBlocks: Block[] }[];
723
+ }
724
+ | {
725
+ type: "field";
726
+ name: string;
727
+ fieldType: string;
728
+ required: boolean;
729
+ innerBlocks: Block[];
730
+ }
731
+ | { type: "fieldGroup"; fields: Block[] }
732
+ | {
733
+ type: "image";
734
+ src: string;
735
+ alt: string;
736
+ width?: string;
737
+ height?: string;
738
+ }
739
+ | { type: "docEmbed"; docId: string; seamless?: boolean }
740
+ | { type: "svgEmbed"; svg: string; title: string };
741
+
742
+ function parseTableRow(line: string): string[] {
743
+ const parts = line.split("|");
744
+ return parts.slice(1, parts.length - 1).map((c) => c.trim());
745
+ }
746
+
747
+ function isTableSeparator(line: string): boolean {
748
+ return /^\|[\s|:-]+\|$/.test(line.trim());
749
+ }
750
+
751
+ function extractFencedCode(lines: string[]): Block[] {
752
+ const result: Block[] = [];
753
+ let i = 0;
754
+ while (i < lines.length) {
755
+ const line = lines[i]!;
756
+ const fenceMatch = line.match(/^(`{3,})(\w*)/);
757
+ if (fenceMatch) {
758
+ const fence = fenceMatch[1]!;
759
+ const lang = fenceMatch[2] ?? "";
760
+ const codeLines: string[] = [];
761
+ i++;
762
+ while (i < lines.length && !lines[i]!.startsWith(fence)) {
763
+ codeLines.push(lines[i]!);
764
+ i++;
765
+ }
766
+ i++;
767
+ result.push({
768
+ type: "codeBlock",
769
+ lang,
770
+ code: codeLines.join("\n"),
771
+ });
772
+ continue;
773
+ }
774
+ i++;
775
+ }
776
+ return result;
777
+ }
778
+
779
+ function parseMdcChildren(
780
+ innerLines: string[],
781
+ slotPrefix: string,
782
+ ): { label: string; icon: string; innerBlocks: Block[] }[] {
783
+ const items: { label: string; icon: string; lines: string[] }[] = [];
784
+ let current: { label: string; icon: string; lines: string[] } | null =
785
+ null;
786
+ const slotRe = new RegExp(`^#${slotPrefix}(\\{[^}]*\\})?\\s*$`);
787
+
788
+ for (const line of innerLines) {
789
+ const slotMatch = line.match(slotRe);
790
+ if (slotMatch) {
791
+ if (current) items.push(current);
792
+ const props = parseMdcProps(slotMatch[1]);
793
+ current = {
794
+ label:
795
+ props["label"] ||
796
+ props["title"] ||
797
+ `Item ${items.length + 1}`,
798
+ icon: props["icon"] || "",
799
+ lines: [],
800
+ };
801
+ continue;
802
+ }
803
+ if (current) {
804
+ current.lines.push(line);
805
+ } else {
806
+ if (!items.length && !current) {
807
+ current = { label: "Item 1", icon: "", lines: [line] };
808
+ }
809
+ }
810
+ }
811
+ if (current) items.push(current);
812
+
813
+ return items.map((item) => ({
814
+ label: item.label,
815
+ icon: item.icon,
816
+ innerBlocks: parseBlocks(item.lines.join("\n")),
817
+ }));
818
+ }
819
+
820
+ const TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)/;
821
+
822
+ function parseBlocks(markdown: string): Block[] {
823
+ const rawLines = markdown.split("\n");
824
+ let firstContentLine = 0;
825
+ while (firstContentLine < rawLines.length) {
826
+ const l = rawLines[firstContentLine]!;
827
+ if (l.trim() === "" || /^import\s/.test(l) || /^export\s/.test(l)) {
828
+ firstContentLine++;
829
+ } else {
830
+ break;
831
+ }
832
+ }
833
+ const stripped = rawLines.slice(firstContentLine).join("\n");
834
+
835
+ const blocks: Block[] = [];
836
+ const lines = stripped.split("\n");
837
+ let i = 0;
838
+
839
+ while (i < lines.length) {
840
+ const line = lines[i]!;
841
+
842
+ const fenceBlockMatch = line.match(/^(`{3,})(.*)$/);
843
+ if (fenceBlockMatch) {
844
+ const fence = fenceBlockMatch[1]!;
845
+ const lang = fenceBlockMatch[2]!
846
+ .trim()
847
+ .replace(/\{[^}]*\}$/, "")
848
+ .replace(/\s*\[.*\]$/, "")
849
+ .trim();
850
+ const codeLines: string[] = [];
851
+ i++;
852
+ while (i < lines.length && !lines[i]!.startsWith(fence)) {
853
+ codeLines.push(lines[i]!);
854
+ i++;
855
+ }
856
+ i++;
857
+ if (lang === "svg" || lang.startsWith("svg ")) {
858
+ const svgTitle =
859
+ lang === "svg" ? "" : lang.slice(4).trim();
860
+ blocks.push({
861
+ type: "svgEmbed",
862
+ svg: codeLines.join("\n"),
863
+ title: svgTitle,
864
+ });
865
+ } else {
866
+ blocks.push({
867
+ type: "codeBlock",
868
+ lang,
869
+ code: codeLines.join("\n"),
870
+ });
871
+ }
872
+ continue;
873
+ }
874
+
875
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
876
+ if (headingMatch) {
877
+ blocks.push({
878
+ type: "heading",
879
+ level: headingMatch[1]!.length,
880
+ text: headingMatch[2]!.trim(),
881
+ });
882
+ i++;
883
+ continue;
884
+ }
885
+
886
+ if (/^[-*_]{3,}\s*$/.test(line)) {
887
+ blocks.push({ type: "hr" });
888
+ i++;
889
+ continue;
890
+ }
891
+
892
+ const docEmbedMatch = line.match(
893
+ /^!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\](\{[^}]*\})?\s*$/,
894
+ );
895
+ if (docEmbedMatch) {
896
+ const props = parseMdcProps(docEmbedMatch[2]);
897
+ const seamless =
898
+ "seamless" in props ||
899
+ props["seamless"] === "true" ||
900
+ /\{[^}]*\bseamless\b[^}]*\}/.test(docEmbedMatch[2] ?? "");
901
+ blocks.push({
902
+ type: "docEmbed",
903
+ docId: docEmbedMatch[1]!,
904
+ seamless: seamless || undefined,
905
+ });
906
+ i++;
907
+ continue;
908
+ }
909
+
910
+ const imgMatch = line.match(
911
+ /^!\[([^\]]*)\]\(([^)]+)\)(\{[^}]*\})?\s*$/,
912
+ );
913
+ if (imgMatch) {
914
+ const alt = imgMatch[1] ?? "";
915
+ const src = imgMatch[2] ?? "";
916
+ const attrs = parseMdcProps(imgMatch[3]);
917
+ blocks.push({
918
+ type: "image",
919
+ src,
920
+ alt,
921
+ width: attrs["width"],
922
+ height: attrs["height"],
923
+ });
924
+ i++;
925
+ continue;
926
+ }
927
+
928
+ if (line.startsWith("> ") || line === ">") {
929
+ const bqLines: string[] = [];
930
+ while (
931
+ i < lines.length &&
932
+ (lines[i]!.startsWith("> ") || lines[i] === ">")
933
+ ) {
934
+ bqLines.push(lines[i]!.replace(/^>\s?/, ""));
935
+ i++;
936
+ }
937
+ blocks.push({ type: "blockquote", lines: bqLines });
938
+ continue;
939
+ }
940
+
941
+ if (/^\s*\|/.test(line)) {
942
+ const tableLines: string[] = [];
943
+ while (i < lines.length && /^\s*\|/.test(lines[i]!)) {
944
+ tableLines.push(lines[i]!);
945
+ i++;
946
+ }
947
+ if (
948
+ tableLines.length >= 2 &&
949
+ isTableSeparator(tableLines[1]!)
950
+ ) {
951
+ const headerRow = parseTableRow(tableLines[0]!);
952
+ const dataRows = tableLines
953
+ .slice(2)
954
+ .filter((l) => !isTableSeparator(l))
955
+ .map(parseTableRow);
956
+ blocks.push({ type: "table", headerRow, dataRows });
957
+ } else {
958
+ for (const l of tableLines)
959
+ blocks.push({ type: "paragraph", text: l });
960
+ }
961
+ continue;
962
+ }
963
+
964
+ const MDC_OPEN = /^\s*(:{2,})(\w[\w-]*)(\{[^}]*\})?\s*$/;
965
+ if (MDC_OPEN.test(line)) {
966
+ const colons =
967
+ line.match(/^\s*(:+)/)?.[1]?.length ?? 2;
968
+ const componentName =
969
+ line.match(/^\s*:{2,}(\w[\w-]*)/)?.[1] ?? "";
970
+ const innerLines: string[] = [];
971
+ i++;
972
+ while (i < lines.length) {
973
+ const l = lines[i]!;
974
+ if (
975
+ new RegExp(`^\\s*:{${colons}}\\s*$`).test(l)
976
+ ) {
977
+ i++;
978
+ break;
979
+ }
980
+ const innerFence = l.match(/^(\s*`{3,})/);
981
+ if (innerFence) {
982
+ const fenceStr = innerFence[1]!.trimStart();
983
+ innerLines.push(l);
984
+ i++;
985
+ while (
986
+ i < lines.length &&
987
+ !lines[i]!.trimStart().startsWith(fenceStr)
988
+ ) {
989
+ innerLines.push(lines[i]!);
990
+ i++;
991
+ }
992
+ if (i < lines.length) {
993
+ innerLines.push(lines[i]!);
994
+ i++;
995
+ }
996
+ continue;
997
+ }
998
+ innerLines.push(l);
999
+ i++;
1000
+ }
1001
+
1002
+ const nonBlank = innerLines.filter(
1003
+ (l) => l.trim().length > 0,
1004
+ );
1005
+ if (nonBlank.length) {
1006
+ const minIndent = Math.min(
1007
+ ...nonBlank.map(
1008
+ (l) => l.match(/^(\s*)/)?.[1]?.length ?? 0,
1009
+ ),
1010
+ );
1011
+ if (minIndent > 0) {
1012
+ for (let j = 0; j < innerLines.length; j++) {
1013
+ innerLines[j] = innerLines[j]!.slice(
1014
+ Math.min(minIndent, innerLines[j]!.length),
1015
+ );
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ let contentStart = 0;
1021
+ if (innerLines[0]?.trim() === "---") {
1022
+ const fmEnd = innerLines.findIndex(
1023
+ (l, idx) => idx > 0 && l.trim() === "---",
1024
+ );
1025
+ if (fmEnd !== -1) contentStart = fmEnd + 1;
1026
+ }
1027
+ const contentLines = innerLines.slice(contentStart);
1028
+
1029
+ const defaultSlotLines: string[] = [];
1030
+ const codeSlotLines: string[] = [];
1031
+ let currentSlot: "default" | "code" | "other" = "default";
1032
+ for (const l of contentLines) {
1033
+ if (/^#code\s*$/.test(l)) {
1034
+ currentSlot = "code";
1035
+ continue;
1036
+ }
1037
+ if (/^#\w+/.test(l) && !/^#{2,}\s/.test(l)) {
1038
+ currentSlot = "other";
1039
+ continue;
1040
+ }
1041
+ if (currentSlot === "default") defaultSlotLines.push(l);
1042
+ else if (currentSlot === "code") codeSlotLines.push(l);
1043
+ }
1044
+ const innerBlocks = parseBlocks(defaultSlotLines.join("\n"));
1045
+
1046
+ const codeBlocks = extractFencedCode(codeSlotLines);
1047
+
1048
+ const CALLOUT_NAMES = new Set([
1049
+ "tip",
1050
+ "note",
1051
+ "info",
1052
+ "warning",
1053
+ "caution",
1054
+ "danger",
1055
+ "callout",
1056
+ "alert",
1057
+ ]);
1058
+ if (CALLOUT_NAMES.has(componentName.toLowerCase())) {
1059
+ blocks.push({
1060
+ type: "callout",
1061
+ calloutType: componentName.toLowerCase(),
1062
+ innerBlocks,
1063
+ });
1064
+ } else {
1065
+ const mdcProps = parseMdcProps(
1066
+ line.match(MDC_OPEN)?.[3],
1067
+ );
1068
+ const lc = componentName.toLowerCase();
1069
+
1070
+ if (lc === "collapsible") {
1071
+ blocks.push({
1072
+ type: "collapsible",
1073
+ label: mdcProps["label"] || "Details",
1074
+ open: mdcProps["open"] === "true",
1075
+ innerBlocks,
1076
+ });
1077
+ } else if (lc === "steps") {
1078
+ blocks.push({ type: "steps", innerBlocks });
1079
+ } else if (lc === "card") {
1080
+ blocks.push({
1081
+ type: "card",
1082
+ title: mdcProps["title"] || "",
1083
+ icon: mdcProps["icon"] || "",
1084
+ to: mdcProps["to"] || "",
1085
+ innerBlocks,
1086
+ });
1087
+ } else if (lc === "card-group") {
1088
+ const cards = innerBlocks.filter(
1089
+ (b) => b.type === "card",
1090
+ );
1091
+ if (cards.length) {
1092
+ blocks.push({ type: "cardGroup", cards });
1093
+ } else {
1094
+ blocks.push(...innerBlocks);
1095
+ }
1096
+ } else if (lc === "code-collapse") {
1097
+ blocks.push({
1098
+ type: "codeCollapse",
1099
+ codeBlocks: codeBlocks.length
1100
+ ? codeBlocks
1101
+ : innerBlocks.filter(
1102
+ (b) => b.type === "codeBlock",
1103
+ ),
1104
+ });
1105
+ } else if (lc === "code-group") {
1106
+ const allCode = [
1107
+ ...innerBlocks.filter(
1108
+ (b) => b.type === "codeBlock",
1109
+ ),
1110
+ ...codeBlocks,
1111
+ ];
1112
+ blocks.push({ type: "codeGroup", codeBlocks: allCode });
1113
+ } else if (lc === "code-preview") {
1114
+ blocks.push({
1115
+ type: "codePreview",
1116
+ innerBlocks,
1117
+ codeBlocks,
1118
+ });
1119
+ } else if (lc === "code-tree") {
1120
+ blocks.push({
1121
+ type: "codeTree",
1122
+ files: mdcProps["files"] || "[]",
1123
+ });
1124
+ } else if (lc === "accordion") {
1125
+ const items = parseMdcChildren(
1126
+ contentLines,
1127
+ "item",
1128
+ );
1129
+ if (items.length) {
1130
+ blocks.push({ type: "accordion", items });
1131
+ } else {
1132
+ blocks.push({
1133
+ type: "accordion",
1134
+ items: [
1135
+ {
1136
+ label: "Item 1",
1137
+ icon: "",
1138
+ innerBlocks,
1139
+ },
1140
+ ],
1141
+ });
1142
+ }
1143
+ } else if (lc === "tabs") {
1144
+ const items = parseMdcChildren(
1145
+ contentLines,
1146
+ "tab",
1147
+ );
1148
+ if (items.length) {
1149
+ blocks.push({ type: "tabs", items });
1150
+ } else {
1151
+ blocks.push({
1152
+ type: "tabs",
1153
+ items: [
1154
+ {
1155
+ label: "Tab 1",
1156
+ icon: "",
1157
+ innerBlocks,
1158
+ },
1159
+ ],
1160
+ });
1161
+ }
1162
+ } else if (lc === "field") {
1163
+ blocks.push({
1164
+ type: "field",
1165
+ name: mdcProps["name"] || "",
1166
+ fieldType: mdcProps["type"] || "string",
1167
+ required: mdcProps["required"] === "true",
1168
+ innerBlocks,
1169
+ });
1170
+ } else if (lc === "field-group") {
1171
+ const fields = innerBlocks.filter(
1172
+ (b) => b.type === "field",
1173
+ );
1174
+ if (fields.length) {
1175
+ blocks.push({ type: "fieldGroup", fields });
1176
+ } else {
1177
+ blocks.push(...innerBlocks);
1178
+ }
1179
+ } else {
1180
+ blocks.push(...innerBlocks);
1181
+ blocks.push(...codeBlocks);
1182
+ }
1183
+ }
1184
+ continue;
1185
+ }
1186
+
1187
+ if (TASK_RE.test(line)) {
1188
+ const items: TaskItem[] = [];
1189
+ while (i < lines.length && TASK_RE.test(lines[i]!)) {
1190
+ const m = lines[i]!.match(TASK_RE)!;
1191
+ items.push({
1192
+ checked: m[1]!.toLowerCase() === "x",
1193
+ text: m[2]!,
1194
+ });
1195
+ i++;
1196
+ }
1197
+ blocks.push({ type: "taskList", items });
1198
+ continue;
1199
+ }
1200
+
1201
+ if (/^[-*+]\s+/.test(line)) {
1202
+ const items: string[] = [];
1203
+ while (
1204
+ i < lines.length &&
1205
+ /^[-*+]\s+/.test(lines[i]!) &&
1206
+ !TASK_RE.test(lines[i]!)
1207
+ ) {
1208
+ items.push(lines[i]!.replace(/^[-*+]\s+/, ""));
1209
+ i++;
1210
+ }
1211
+ if (items.length) {
1212
+ blocks.push({ type: "bulletList", items });
1213
+ continue;
1214
+ }
1215
+ }
1216
+
1217
+ if (/^\d+\.\s+/.test(line)) {
1218
+ const items: string[] = [];
1219
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i]!)) {
1220
+ items.push(lines[i]!.replace(/^\d+\.\s+/, ""));
1221
+ i++;
1222
+ }
1223
+ blocks.push({ type: "orderedList", items });
1224
+ continue;
1225
+ }
1226
+
1227
+ if (line.trim() === "") {
1228
+ i++;
1229
+ continue;
1230
+ }
1231
+
1232
+ const paraLines: string[] = [];
1233
+ while (
1234
+ i < lines.length &&
1235
+ lines[i]!.trim() !== "" &&
1236
+ !/^(#{1,6}\s|[-*+]\s|\d+\.\s|>|`{3,}|\s*\||[-*_]{3,}\s*$|\s*:{2,}\w)/.test(
1237
+ lines[i]!,
1238
+ )
1239
+ ) {
1240
+ paraLines.push(lines[i]!);
1241
+ i++;
1242
+ }
1243
+ if (paraLines.length) {
1244
+ blocks.push({ type: "paragraph", text: paraLines.join(" ") });
1245
+ }
1246
+ }
1247
+
1248
+ return blocks;
1249
+ }
1250
+
1251
+ // ── Y.js content population helpers ──────────────────────────────────────────
1252
+
1253
+ function fillTextInto(
1254
+ el: Y.XmlElement,
1255
+ tokens: InlineToken[],
1256
+ ): void {
1257
+ const filtered = tokens.filter((t) => t.node || t.text.length > 0);
1258
+ if (!filtered.length) return;
1259
+
1260
+ const children: (Y.XmlText | Y.XmlElement)[] = filtered.map((tok) => {
1261
+ if (tok.node) {
1262
+ const xe = new Y.XmlElement(tok.node);
1263
+ if (tok.nodeAttrs) {
1264
+ for (const [k, v] of Object.entries(tok.nodeAttrs))
1265
+ xe.setAttribute(k, v);
1266
+ }
1267
+ return xe;
1268
+ }
1269
+ return new Y.XmlText();
1270
+ });
1271
+ el.insert(0, children);
1272
+
1273
+ filtered.forEach((tok, i) => {
1274
+ if (tok.node) return;
1275
+ const xt = children[i] as Y.XmlText;
1276
+ if (tok.attrs) {
1277
+ xt.insert(
1278
+ 0,
1279
+ tok.text,
1280
+ tok.attrs as Record<string, boolean | object>,
1281
+ );
1282
+ } else {
1283
+ xt.insert(0, tok.text);
1284
+ }
1285
+ });
1286
+ }
1287
+
1288
+ function blockElName(b: Block): string {
1289
+ switch (b.type) {
1290
+ case "heading":
1291
+ return "heading";
1292
+ case "paragraph":
1293
+ return "paragraph";
1294
+ case "bulletList":
1295
+ return "bulletList";
1296
+ case "orderedList":
1297
+ return "orderedList";
1298
+ case "taskList":
1299
+ return "taskList";
1300
+ case "codeBlock":
1301
+ return "codeBlock";
1302
+ case "blockquote":
1303
+ return "blockquote";
1304
+ case "table":
1305
+ return "table";
1306
+ case "hr":
1307
+ return "horizontalRule";
1308
+ case "callout":
1309
+ return "callout";
1310
+ case "collapsible":
1311
+ return "collapsible";
1312
+ case "steps":
1313
+ return "steps";
1314
+ case "card":
1315
+ return "card";
1316
+ case "cardGroup":
1317
+ return "cardGroup";
1318
+ case "codeCollapse":
1319
+ return "codeCollapse";
1320
+ case "codeGroup":
1321
+ return "codeGroup";
1322
+ case "codePreview":
1323
+ return "codePreview";
1324
+ case "codeTree":
1325
+ return "codeTree";
1326
+ case "accordion":
1327
+ return "accordion";
1328
+ case "tabs":
1329
+ return "tabs";
1330
+ case "field":
1331
+ return "field";
1332
+ case "fieldGroup":
1333
+ return "fieldGroup";
1334
+ case "image":
1335
+ return "image";
1336
+ case "docEmbed":
1337
+ return "docEmbed";
1338
+ case "svgEmbed":
1339
+ return "svgEmbed";
1340
+ }
1341
+ }
1342
+
1343
+ function fillBlock(el: Y.XmlElement, block: Block): void {
1344
+ switch (block.type) {
1345
+ case "heading": {
1346
+ el.setAttribute("level", block.level as any);
1347
+ fillTextInto(el, parseInline(block.text));
1348
+ break;
1349
+ }
1350
+ case "paragraph": {
1351
+ fillTextInto(el, parseInline(block.text));
1352
+ break;
1353
+ }
1354
+ case "bulletList":
1355
+ case "orderedList": {
1356
+ const listItemEls = block.items.map(
1357
+ () => new Y.XmlElement("listItem"),
1358
+ );
1359
+ el.insert(0, listItemEls);
1360
+ block.items.forEach((text, i) => {
1361
+ const paraEl = new Y.XmlElement("paragraph");
1362
+ listItemEls[i]!.insert(0, [paraEl]);
1363
+ fillTextInto(paraEl, parseInline(text));
1364
+ });
1365
+ break;
1366
+ }
1367
+ case "taskList": {
1368
+ const taskItemEls = block.items.map(
1369
+ () => new Y.XmlElement("taskItem"),
1370
+ );
1371
+ el.insert(0, taskItemEls);
1372
+ block.items.forEach((item, i) => {
1373
+ taskItemEls[i]!.setAttribute(
1374
+ "checked",
1375
+ item.checked as any,
1376
+ );
1377
+ const paraEl = new Y.XmlElement("paragraph");
1378
+ taskItemEls[i]!.insert(0, [paraEl]);
1379
+ fillTextInto(paraEl, parseInline(item.text));
1380
+ });
1381
+ break;
1382
+ }
1383
+ case "codeBlock": {
1384
+ if (block.lang) el.setAttribute("language", block.lang);
1385
+ const xt = new Y.XmlText();
1386
+ el.insert(0, [xt]);
1387
+ xt.insert(0, block.code);
1388
+ break;
1389
+ }
1390
+ case "blockquote": {
1391
+ const paraEls = block.lines.map(
1392
+ () => new Y.XmlElement("paragraph"),
1393
+ );
1394
+ el.insert(0, paraEls);
1395
+ block.lines.forEach((line, i) =>
1396
+ fillTextInto(paraEls[i]!, parseInline(line)),
1397
+ );
1398
+ break;
1399
+ }
1400
+ case "table": {
1401
+ const headerRowEl = new Y.XmlElement("tableRow");
1402
+ const dataRowEls = block.dataRows.map(
1403
+ () => new Y.XmlElement("tableRow"),
1404
+ );
1405
+ el.insert(0, [headerRowEl, ...dataRowEls]);
1406
+
1407
+ const headerCellEls = block.headerRow.map(
1408
+ () => new Y.XmlElement("tableHeader"),
1409
+ );
1410
+ headerRowEl.insert(0, headerCellEls);
1411
+ block.headerRow.forEach((cellText, i) => {
1412
+ const paraEl = new Y.XmlElement("paragraph");
1413
+ headerCellEls[i]!.insert(0, [paraEl]);
1414
+ fillTextInto(paraEl, parseInline(cellText));
1415
+ });
1416
+
1417
+ block.dataRows.forEach((row, ri) => {
1418
+ const cellEls = row.map(
1419
+ () => new Y.XmlElement("tableCell"),
1420
+ );
1421
+ dataRowEls[ri]!.insert(0, cellEls);
1422
+ row.forEach((cellText, ci) => {
1423
+ const paraEl = new Y.XmlElement("paragraph");
1424
+ cellEls[ci]!.insert(0, [paraEl]);
1425
+ fillTextInto(paraEl, parseInline(cellText));
1426
+ });
1427
+ });
1428
+ break;
1429
+ }
1430
+ case "hr":
1431
+ break;
1432
+ case "callout": {
1433
+ el.setAttribute("type", block.calloutType);
1434
+ if (!block.innerBlocks.length) {
1435
+ const paraEl = new Y.XmlElement("paragraph");
1436
+ el.insert(0, [paraEl]);
1437
+ break;
1438
+ }
1439
+ const innerEls = block.innerBlocks.map(
1440
+ (b) => new Y.XmlElement(blockElName(b)),
1441
+ );
1442
+ el.insert(0, innerEls);
1443
+ block.innerBlocks.forEach((b, i) =>
1444
+ fillBlock(innerEls[i]!, b),
1445
+ );
1446
+ break;
1447
+ }
1448
+ case "collapsible": {
1449
+ el.setAttribute("label", block.label);
1450
+ el.setAttribute("open", block.open as any);
1451
+ const inner = block.innerBlocks.length
1452
+ ? block.innerBlocks
1453
+ : [{ type: "paragraph" as const, text: "" }];
1454
+ const innerEls = inner.map(
1455
+ (b) => new Y.XmlElement(blockElName(b)),
1456
+ );
1457
+ el.insert(0, innerEls);
1458
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b));
1459
+ break;
1460
+ }
1461
+ case "steps": {
1462
+ const inner = block.innerBlocks.length
1463
+ ? block.innerBlocks
1464
+ : [{ type: "paragraph" as const, text: "" }];
1465
+ const innerEls = inner.map(
1466
+ (b) => new Y.XmlElement(blockElName(b)),
1467
+ );
1468
+ el.insert(0, innerEls);
1469
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b));
1470
+ break;
1471
+ }
1472
+ case "card": {
1473
+ if (block.title) el.setAttribute("title", block.title);
1474
+ if (block.icon) el.setAttribute("icon", block.icon);
1475
+ if (block.to) el.setAttribute("to", block.to);
1476
+ const inner = block.innerBlocks.length
1477
+ ? block.innerBlocks
1478
+ : [{ type: "paragraph" as const, text: "" }];
1479
+ const innerEls = inner.map(
1480
+ (b) => new Y.XmlElement(blockElName(b)),
1481
+ );
1482
+ el.insert(0, innerEls);
1483
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b));
1484
+ break;
1485
+ }
1486
+ case "cardGroup": {
1487
+ const cardEls = block.cards.map(
1488
+ (b) => new Y.XmlElement(blockElName(b)),
1489
+ );
1490
+ el.insert(0, cardEls);
1491
+ block.cards.forEach((b, i) => fillBlock(cardEls[i]!, b));
1492
+ break;
1493
+ }
1494
+ case "codeCollapse": {
1495
+ const codes = block.codeBlocks.length
1496
+ ? block.codeBlocks
1497
+ : [{ type: "codeBlock" as const, lang: "", code: "" }];
1498
+ const codeEl = new Y.XmlElement("codeBlock");
1499
+ el.insert(0, [codeEl]);
1500
+ fillBlock(codeEl, codes[0]!);
1501
+ break;
1502
+ }
1503
+ case "codeGroup": {
1504
+ const codes = block.codeBlocks.length
1505
+ ? block.codeBlocks
1506
+ : [{ type: "codeBlock" as const, lang: "", code: "" }];
1507
+ const codeEls = codes.map(() => new Y.XmlElement("codeBlock"));
1508
+ el.insert(0, codeEls);
1509
+ codes.forEach((b, i) => fillBlock(codeEls[i]!, b));
1510
+ break;
1511
+ }
1512
+ case "codePreview": {
1513
+ const all = [...block.innerBlocks, ...block.codeBlocks];
1514
+ const inner = all.length
1515
+ ? all
1516
+ : [{ type: "paragraph" as const, text: "" }];
1517
+ const innerEls = inner.map(
1518
+ (b) => new Y.XmlElement(blockElName(b)),
1519
+ );
1520
+ el.insert(0, innerEls);
1521
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b));
1522
+ break;
1523
+ }
1524
+ case "codeTree": {
1525
+ el.setAttribute("files", block.files);
1526
+ break;
1527
+ }
1528
+ case "accordion": {
1529
+ const itemEls = block.items.map(
1530
+ () => new Y.XmlElement("accordionItem"),
1531
+ );
1532
+ el.insert(0, itemEls);
1533
+ block.items.forEach((item, i) => {
1534
+ itemEls[i]!.setAttribute("label", item.label);
1535
+ if (item.icon)
1536
+ itemEls[i]!.setAttribute("icon", item.icon);
1537
+ const inner = item.innerBlocks.length
1538
+ ? item.innerBlocks
1539
+ : [{ type: "paragraph" as const, text: "" }];
1540
+ const childEls = inner.map(
1541
+ (b) => new Y.XmlElement(blockElName(b)),
1542
+ );
1543
+ itemEls[i]!.insert(0, childEls);
1544
+ inner.forEach((b, ci) => fillBlock(childEls[ci]!, b));
1545
+ });
1546
+ break;
1547
+ }
1548
+ case "tabs": {
1549
+ const itemEls = block.items.map(
1550
+ () => new Y.XmlElement("tabsItem"),
1551
+ );
1552
+ el.insert(0, itemEls);
1553
+ block.items.forEach((item, i) => {
1554
+ itemEls[i]!.setAttribute("label", item.label);
1555
+ if (item.icon)
1556
+ itemEls[i]!.setAttribute("icon", item.icon);
1557
+ const inner = item.innerBlocks.length
1558
+ ? item.innerBlocks
1559
+ : [{ type: "paragraph" as const, text: "" }];
1560
+ const childEls = inner.map(
1561
+ (b) => new Y.XmlElement(blockElName(b)),
1562
+ );
1563
+ itemEls[i]!.insert(0, childEls);
1564
+ inner.forEach((b, ci) => fillBlock(childEls[ci]!, b));
1565
+ });
1566
+ break;
1567
+ }
1568
+ case "field": {
1569
+ if (block.name) el.setAttribute("name", block.name);
1570
+ el.setAttribute("type", block.fieldType);
1571
+ el.setAttribute("required", block.required as any);
1572
+ const inner = block.innerBlocks.length
1573
+ ? block.innerBlocks
1574
+ : [{ type: "paragraph" as const, text: "" }];
1575
+ const innerEls = inner.map(
1576
+ (b) => new Y.XmlElement(blockElName(b)),
1577
+ );
1578
+ el.insert(0, innerEls);
1579
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b));
1580
+ break;
1581
+ }
1582
+ case "fieldGroup": {
1583
+ const fieldEls = block.fields.map(
1584
+ (b) => new Y.XmlElement(blockElName(b)),
1585
+ );
1586
+ el.insert(0, fieldEls);
1587
+ block.fields.forEach((b, i) => fillBlock(fieldEls[i]!, b));
1588
+ break;
1589
+ }
1590
+ case "image": {
1591
+ el.setAttribute("src", block.src);
1592
+ if (block.alt) el.setAttribute("alt", block.alt);
1593
+ if (block.width) el.setAttribute("width", block.width);
1594
+ if (block.height) el.setAttribute("height", block.height);
1595
+ break;
1596
+ }
1597
+ case "docEmbed": {
1598
+ el.setAttribute("docId", block.docId);
1599
+ if (block.seamless) el.setAttribute("seamless", "true");
1600
+ break;
1601
+ }
1602
+ case "svgEmbed": {
1603
+ el.setAttribute("svg", block.svg);
1604
+ if (block.title) el.setAttribute("title", block.title);
1605
+ break;
1606
+ }
1607
+ }
1608
+ }
1609
+
1610
+ // ── Public: Markdown → Y.js ─────────────────────────────────────────────────
1611
+
1612
+ export function populateYDocFromMarkdown(
1613
+ fragment: Y.XmlFragment,
1614
+ markdown: string,
1615
+ fallbackTitle = "Untitled",
1616
+ ): void {
1617
+ const ydoc = fragment.doc;
1618
+ if (!ydoc) {
1619
+ console.warn(
1620
+ "[markdownToYjs] fragment has no doc — skipping population",
1621
+ );
1622
+ return;
1623
+ }
1624
+
1625
+ const blocks = parseBlocks(markdown);
1626
+
1627
+ let title = fallbackTitle;
1628
+ let contentBlocks = blocks;
1629
+ const h1 = blocks.findIndex(
1630
+ (b) => b.type === "heading" && b.level === 1,
1631
+ );
1632
+ if (h1 !== -1) {
1633
+ title = (
1634
+ blocks[h1] as { type: "heading"; level: number; text: string }
1635
+ ).text;
1636
+ contentBlocks = blocks.filter((_, i) => i !== h1);
1637
+ }
1638
+ if (!contentBlocks.length)
1639
+ contentBlocks = [{ type: "paragraph", text: "" }];
1640
+
1641
+ ydoc.transact(() => {
1642
+ const headerEl = new Y.XmlElement("documentHeader");
1643
+ const metaEl = new Y.XmlElement("documentMeta");
1644
+ const bodyEls: Y.XmlElement[] = contentBlocks.map((b) => {
1645
+ switch (b.type) {
1646
+ case "heading":
1647
+ return new Y.XmlElement("heading");
1648
+ case "paragraph":
1649
+ return new Y.XmlElement("paragraph");
1650
+ case "bulletList":
1651
+ return new Y.XmlElement("bulletList");
1652
+ case "orderedList":
1653
+ return new Y.XmlElement("orderedList");
1654
+ case "taskList":
1655
+ return new Y.XmlElement("taskList");
1656
+ case "codeBlock":
1657
+ return new Y.XmlElement("codeBlock");
1658
+ case "blockquote":
1659
+ return new Y.XmlElement("blockquote");
1660
+ case "table":
1661
+ return new Y.XmlElement("table");
1662
+ case "hr":
1663
+ return new Y.XmlElement("horizontalRule");
1664
+ case "callout":
1665
+ return new Y.XmlElement("callout");
1666
+ case "collapsible":
1667
+ return new Y.XmlElement("collapsible");
1668
+ case "steps":
1669
+ return new Y.XmlElement("steps");
1670
+ case "card":
1671
+ return new Y.XmlElement("card");
1672
+ case "cardGroup":
1673
+ return new Y.XmlElement("cardGroup");
1674
+ case "codeCollapse":
1675
+ return new Y.XmlElement("codeCollapse");
1676
+ case "codeGroup":
1677
+ return new Y.XmlElement("codeGroup");
1678
+ case "codePreview":
1679
+ return new Y.XmlElement("codePreview");
1680
+ case "codeTree":
1681
+ return new Y.XmlElement("codeTree");
1682
+ case "accordion":
1683
+ return new Y.XmlElement("accordion");
1684
+ case "tabs":
1685
+ return new Y.XmlElement("tabs");
1686
+ case "field":
1687
+ return new Y.XmlElement("field");
1688
+ case "fieldGroup":
1689
+ return new Y.XmlElement("fieldGroup");
1690
+ case "image":
1691
+ return new Y.XmlElement("image");
1692
+ case "docEmbed":
1693
+ return new Y.XmlElement("docEmbed");
1694
+ case "svgEmbed":
1695
+ return new Y.XmlElement("svgEmbed");
1696
+ }
1697
+ });
1698
+
1699
+ fragment.insert(0, [headerEl, metaEl, ...bodyEls]);
1700
+
1701
+ const headerXt = new Y.XmlText();
1702
+ headerEl.insert(0, [headerXt]);
1703
+ headerXt.insert(0, title);
1704
+
1705
+ contentBlocks.forEach((block, i) => fillBlock(bodyEls[i]!, block));
1706
+ });
1707
+ }