@abraca/convert 2.3.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.
@@ -0,0 +1,1208 @@
1
+ import * as Y from 'yjs'
2
+ import type { DocPageMeta } from './types.ts'
3
+
4
+ // ── Filename → readable label ────────────────────────────────────────────────
5
+
6
+ /**
7
+ * Converts a filename (without extension) to a human-readable label.
8
+ *
9
+ * - `this-is-a-doc` → `"This is a doc"` (kebab/snake: sentence case)
10
+ * - `ThisIsADoc` → `"This Is A Doc"` (PascalCase: preserves word caps)
11
+ * - `thisIsADoc` → `"This Is A Doc"` (camelCase: preserves word caps)
12
+ */
13
+ export function filenameToLabel(raw: string): string {
14
+ const base = raw.replace(/\.[^.]+$/, '') // strip extension if present
15
+ const spaced = base.replace(/([a-z])([A-Z])/g, '$1 $2')
16
+ const clean = spaced.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim()
17
+ return clean.charAt(0).toUpperCase() + clean.slice(1)
18
+ }
19
+
20
+ // ── YAML frontmatter parser ──────────────────────────────────────────────────
21
+
22
+ export interface FrontmatterResult {
23
+ title?: string
24
+ type?: string
25
+ meta: Partial<DocPageMeta>
26
+ body: string
27
+ }
28
+
29
+ function coerceScalar(raw: string): string | number | boolean {
30
+ const trimmed = raw.trim()
31
+ if (trimmed === 'true') return true
32
+ if (trimmed === 'false') return false
33
+ const num = Number(trimmed)
34
+ if (!Number.isNaN(num) && trimmed !== '') return num
35
+ // Strip surrounding quotes
36
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
37
+ || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
38
+ return trimmed.slice(1, -1)
39
+ }
40
+ return trimmed
41
+ }
42
+
43
+ function parseInlineArray(raw: string): string[] {
44
+ // e.g. "[a, b, c]"
45
+ return raw.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean)
46
+ }
47
+
48
+ function stripQuotes(s: string): string {
49
+ if (s.length >= 2
50
+ && ((s.startsWith('"') && s.endsWith('"'))
51
+ || (s.startsWith('\'') && s.endsWith('\'')))) {
52
+ return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
53
+ }
54
+ return s
55
+ }
56
+
57
+ export function parseFrontmatter(markdown: string): FrontmatterResult {
58
+ const noResult: FrontmatterResult = { meta: {}, body: markdown }
59
+
60
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
61
+ if (!match) return noResult
62
+
63
+ const yamlBlock = match[1]!
64
+ const body = markdown.slice(match[0].length)
65
+
66
+ // Parse YAML into a raw map
67
+ const raw: Record<string, string | string[]> = {}
68
+ const lines = yamlBlock.split('\n')
69
+ let i = 0
70
+ while (i < lines.length) {
71
+ const line = lines[i]!
72
+ // Block sequence: "key:\n - a\n - b"
73
+ const blockSeqKey = line.match(/^(\w[\w-]*):\s*$/)
74
+ if (blockSeqKey && i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1]!)) {
75
+ const key = blockSeqKey[1]!
76
+ const items: string[] = []
77
+ i++
78
+ while (i < lines.length && /^\s+-\s/.test(lines[i]!)) {
79
+ items.push(lines[i]!.replace(/^\s+-\s/, '').trim())
80
+ i++
81
+ }
82
+ raw[key] = items
83
+ continue
84
+ }
85
+ // Scalar or inline array
86
+ const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/)
87
+ if (kvMatch) {
88
+ const key = kvMatch[1]!
89
+ const val = kvMatch[2]!.trim()
90
+ if (val.startsWith('[') && val.endsWith(']')) {
91
+ raw[key] = parseInlineArray(val).map(stripQuotes)
92
+ } else {
93
+ // Strip surrounding quotes up-front so downstream readers
94
+ // don't have to. The original quote state is irrelevant — we
95
+ // re-emit with our canonical quoting rules.
96
+ raw[key] = stripQuotes(val)
97
+ }
98
+ }
99
+ i++
100
+ }
101
+
102
+ // Map raw YAML keys → PageMeta
103
+ const meta: Partial<DocPageMeta> = {}
104
+
105
+ const getStr = (keys: string[]): string | undefined => {
106
+ for (const k of keys) {
107
+ const v = raw[k]
108
+ if (typeof v === 'string' && v) return v
109
+ }
110
+ }
111
+ const getArr = (keys: string[]): string[] | undefined => {
112
+ for (const k of keys) {
113
+ const v = raw[k]
114
+ if (Array.isArray(v)) return v
115
+ if (typeof v === 'string' && v) return [v]
116
+ }
117
+ }
118
+
119
+ if (raw['tags']) meta.tags = Array.isArray(raw['tags']) ? raw['tags'] : [raw['tags'] as string]
120
+ const color = getStr(['color'])
121
+ if (color) meta.color = color
122
+ const icon = getStr(['icon'])
123
+ if (icon) meta.icon = icon
124
+ const status = getStr(['status'])
125
+ if (status) meta.status = status
126
+
127
+ const priorityRaw = getStr(['priority'])
128
+ if (priorityRaw !== undefined) {
129
+ const map: Record<string, number> = { low: 1, medium: 2, high: 3, urgent: 4 }
130
+ meta.priority = map[priorityRaw.toLowerCase()] ?? (Number(priorityRaw) || 0)
131
+ }
132
+
133
+ const checkedRaw: unknown = raw['checked'] ?? raw['done']
134
+ if (checkedRaw !== undefined) meta.checked = checkedRaw === 'true' || checkedRaw === true
135
+
136
+ // Canonical keys are checked first, aliases follow — so files that
137
+ // already use the canonical name (dateStart, dateEnd, subtitle) keep
138
+ // round-tripping byte-stably while legacy aliases still parse.
139
+ const dateStart = getStr(['dateStart', 'date', 'created'])
140
+ if (dateStart) meta.dateStart = dateStart
141
+ const dateEnd = getStr(['dateEnd', 'due'])
142
+ if (dateEnd) meta.dateEnd = dateEnd
143
+
144
+ const subtitle = getStr(['subtitle', 'description'])
145
+ if (subtitle) meta.subtitle = subtitle
146
+ const url = getStr(['url'])
147
+ if (url) meta.url = url
148
+
149
+ const ratingRaw = getStr(['rating'])
150
+ if (ratingRaw !== undefined) {
151
+ const n = Number(ratingRaw)
152
+ if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n))
153
+ }
154
+
155
+ const rawTitle = typeof raw['title'] === 'string' ? raw['title'] : undefined
156
+ const title = rawTitle !== undefined ? stripQuotes(rawTitle) : undefined
157
+ const type = getStr(['type'])
158
+
159
+ return { title, type, meta, body }
160
+ }
161
+
162
+ // ── Inline token parsing ─────────────────────────────────────────────────────
163
+
164
+ interface InlineToken {
165
+ text: string
166
+ attrs?: Record<string, unknown>
167
+ }
168
+
169
+ function parseInline(text: string): InlineToken[] {
170
+ // Strip MDC attribute annotations on inline code: `string`{lang="ts-type"}
171
+ const stripped = text.replace(/\{lang="[^"]*"\}/g, '')
172
+ // Strip remaining unknown inline MDC components (but NOT badge/icon/kbd — handled below)
173
+ .replace(/:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g, '$2')
174
+ .replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, '')
175
+
176
+ const tokens: InlineToken[] = []
177
+ // Order matters: math (`$\u2026$`) before italic (so `$x$` doesn't match
178
+ // `$ \u2026 $` as italic-with-dollars); inline MDC first; mentions before
179
+ // plain `[\u2026]` links; wikilinks before plain brackets; strikethrough;
180
+ // bold before italic.
181
+ const re = /\$([^$\n]+?)\$|@\[([^\]]+?)\]\(user:([^)]+?)\)|:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g
182
+ let lastIndex = 0
183
+ let match: RegExpExecArray | null
184
+
185
+ while ((match = re.exec(stripped)) !== null) {
186
+ if (match.index > lastIndex) {
187
+ tokens.push({ text: stripped.slice(lastIndex, match.index) })
188
+ }
189
+ if (match[1] !== undefined) {
190
+ // $expression$ \u2014 inline math atom
191
+ tokens.push({ text: match[1], attrs: { mathInline: { expression: match[1] } } })
192
+ } else if (match[2] !== undefined && match[3] !== undefined) {
193
+ // @[label](user:uuid) \u2014 mention atom
194
+ tokens.push({ text: match[2], attrs: { mention: { userId: match[3], label: match[2] } } })
195
+ } else if (match[4] !== undefined) {
196
+ // :badge[text]{props}
197
+ const badgeProps = parseMdcProps(match[5])
198
+ tokens.push({ text: match[4] || 'Badge', attrs: { badge: { label: match[4] || 'Badge', color: badgeProps['color'] || 'neutral', variant: badgeProps['variant'] || 'subtle' } } })
199
+ } else if (match[6] !== undefined) {
200
+ // :icon{name="..."}
201
+ const iconProps = parseMdcProps(`{${match[6]}}`)
202
+ tokens.push({ text: '\u200B', attrs: { proseIcon: { name: iconProps['name'] || 'i-lucide-star' } } })
203
+ } else if (match[7] !== undefined) {
204
+ // :kbd{value="..."}
205
+ const kbdProps = parseMdcProps(`{${match[7]}}`)
206
+ tokens.push({ text: kbdProps['value'] || '', attrs: { kbd: { value: kbdProps['value'] || '' } } })
207
+ } else if (match[8] !== undefined) {
208
+ // [[uuid]] or [[uuid|label]] \u2014 inline doc link atom. The docId
209
+ // is the canonical anchor; the label is display-only. The
210
+ // serialiser may regenerate the label from a live doc registry
211
+ // (see SPEC.md \u00A76); when no resolver is supplied, the parser's
212
+ // stored display text is reused on emit.
213
+ const docId = match[8]
214
+ const label = match[9] ?? docId
215
+ tokens.push({ text: label, attrs: { docLink: { docId } } })
216
+ } else if (match[10] !== undefined) {
217
+ tokens.push({ text: match[10], attrs: { strike: true } })
218
+ } else if (match[11] !== undefined) {
219
+ tokens.push({ text: match[11], attrs: { bold: true } })
220
+ } else if (match[12] !== undefined) {
221
+ tokens.push({ text: match[12], attrs: { italic: true } })
222
+ } else if (match[13] !== undefined) {
223
+ tokens.push({ text: match[13], attrs: { italic: true } })
224
+ } else if (match[14] !== undefined) {
225
+ tokens.push({ text: match[14], attrs: { code: true } })
226
+ } else if (match[15] !== undefined && match[16] !== undefined) {
227
+ tokens.push({ text: match[15], attrs: { link: { href: match[16] } } })
228
+ }
229
+ lastIndex = match.index + match[0].length
230
+ }
231
+
232
+ if (lastIndex < stripped.length) {
233
+ tokens.push({ text: stripped.slice(lastIndex) })
234
+ }
235
+ return tokens.filter(t => t.text.length > 0)
236
+ }
237
+
238
+ // ── Block-level parser ───────────────────────────────────────────────────────
239
+
240
+ interface TaskItem {
241
+ text: string
242
+ checked: boolean
243
+ }
244
+
245
+ interface ListItemBlock {
246
+ text: string
247
+ innerBlocks?: Block[]
248
+ checked?: boolean
249
+ }
250
+
251
+ type Block
252
+ = | { type: 'heading', level: number, text: string }
253
+ | { type: 'paragraph', text: string }
254
+ | { type: 'bulletList', items: ListItemBlock[] }
255
+ | { type: 'orderedList', items: ListItemBlock[] }
256
+ | { type: 'taskList', items: ListItemBlock[] }
257
+ | { type: 'codeBlock', lang: string, code: string }
258
+ | { type: 'blockquote', lines: string[] }
259
+ | { type: 'table', headerRow: string[], dataRows: string[][] }
260
+ | { type: 'hr' }
261
+ | { type: 'callout', calloutType: string, innerBlocks: Block[] }
262
+ | { type: 'collapsible', label: string, open: boolean, innerBlocks: Block[] }
263
+ | { type: 'steps', innerBlocks: Block[] }
264
+ | { type: 'card', title: string, icon: string, to: string, innerBlocks: Block[] }
265
+ | { type: 'cardGroup', cards: Block[] }
266
+ | { type: 'codeCollapse', codeBlocks: Block[] }
267
+ | { type: 'codeGroup', codeBlocks: Block[] }
268
+ | { type: 'codePreview', innerBlocks: Block[], codeBlocks: Block[] }
269
+ | { type: 'codeTree', files: string }
270
+ | { type: 'accordion', items: { label: string, icon: string, innerBlocks: Block[] }[] }
271
+ | { type: 'tabs', items: { label: string, icon: string, innerBlocks: Block[] }[] }
272
+ | { type: 'field', name: string, fieldType: string, required: boolean, innerBlocks: Block[] }
273
+ | { type: 'fieldGroup', fields: Block[] }
274
+ | { type: 'image', src: string, alt: string, width?: string, height?: string }
275
+ | { type: 'docEmbed', docId: string, label: string, props: Record<string, string> }
276
+ | { type: 'mathBlock', expression: string }
277
+ | { type: 'fileBlock', src: string, mime: string, uploadId: string, filename: string }
278
+
279
+ function parseTableRow(line: string): string[] {
280
+ const parts = line.split('|')
281
+ // Remove the first and last (empty from leading/trailing |) and trim each
282
+ return parts.slice(1, parts.length - 1).map(c => c.trim())
283
+ }
284
+
285
+ function isTableSeparator(line: string): boolean {
286
+ return /^\|[\s|:-]+\|$/.test(line.trim())
287
+ }
288
+
289
+ /** Extract fenced code blocks from MDC #code slot lines. */
290
+ function extractFencedCode(lines: string[]): Block[] {
291
+ const result: Block[] = []
292
+ let i = 0
293
+ while (i < lines.length) {
294
+ const line = lines[i]!
295
+ // Match opening fence (```, ````, etc.)
296
+ const fenceMatch = line.match(/^(`{3,})(\w*)/)
297
+ if (fenceMatch) {
298
+ const fence = fenceMatch[1]!
299
+ const lang = fenceMatch[2] ?? ''
300
+ const codeLines: string[] = []
301
+ i++
302
+ while (i < lines.length && !lines[i]!.startsWith(fence)) {
303
+ codeLines.push(lines[i]!)
304
+ i++
305
+ }
306
+ i++ // skip closing fence
307
+ result.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
308
+ continue
309
+ }
310
+ i++
311
+ }
312
+ return result
313
+ }
314
+
315
+ /** Extract key="value" pairs from MDC prop syntax `{key="value" other="x"}` */
316
+ function parseMdcProps(propsStr: string | undefined): Record<string, string> {
317
+ if (!propsStr) return {}
318
+ const result: Record<string, string> = {}
319
+ // Drop the wrapping `{ … }` if present.
320
+ let s = propsStr.trim()
321
+ if (s.startsWith('{') && s.endsWith('}')) s = s.slice(1, -1)
322
+
323
+ // Three accepted shapes per SPEC.md §5:
324
+ // key="value" (quoted)
325
+ // key=value (bare, no whitespace)
326
+ // key (boolean shorthand → "true")
327
+ const re = /(\w[\w-]*)(?:=(?:"([^"]*)"|([^\s"}]+)))?/g
328
+ let m: RegExpExecArray | null
329
+ while ((m = re.exec(s)) !== null) {
330
+ const key = m[1]!
331
+ if (m[2] !== undefined) result[key] = m[2]
332
+ else if (m[3] !== undefined) result[key] = m[3]
333
+ else result[key] = 'true'
334
+ }
335
+ return result
336
+ }
337
+
338
+ /** Parse named child MDC blocks from inner lines (e.g. #item for accordion, #tab for tabs) */
339
+ function parseMdcChildren(innerLines: string[], slotPrefix: string): { label: string, icon: string, innerBlocks: Block[] }[] {
340
+ const items: { label: string, icon: string, lines: string[] }[] = []
341
+ let current: { label: string, icon: string, lines: string[] } | null = null
342
+ const slotRe = new RegExp(`^#${slotPrefix}(\\{[^}]*\\})?\\s*$`)
343
+
344
+ for (const line of innerLines) {
345
+ const slotMatch = line.match(slotRe)
346
+ if (slotMatch) {
347
+ if (current) items.push(current)
348
+ const props = parseMdcProps(slotMatch[1])
349
+ current = { label: props['label'] || props['title'] || `Item ${items.length + 1}`, icon: props['icon'] || '', lines: [] }
350
+ continue
351
+ }
352
+ if (current) {
353
+ current.lines.push(line)
354
+ } else {
355
+ // Content before first slot — treat as first unnamed item
356
+ if (!items.length && !current) {
357
+ current = { label: `Item 1`, icon: '', lines: [line] }
358
+ }
359
+ }
360
+ }
361
+ if (current) items.push(current)
362
+
363
+ return items.map(item => ({
364
+ label: item.label,
365
+ icon: item.icon,
366
+ innerBlocks: parseBlocks(item.lines.join('\n'))
367
+ }))
368
+ }
369
+
370
+ const TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)/
371
+
372
+ /**
373
+ * Consume a list (bullet / ordered / task) starting at `start`. Indented
374
+ * continuation lines and nested lists are captured into each item's
375
+ * `innerBlocks` so the parse → serialise → parse cycle preserves tree
376
+ * structure instead of flattening nested lists onto a single line.
377
+ *
378
+ * `indent` is the column of the item marker for the current list. A
379
+ * nested list starts ≥2 columns deeper. Lines with less indent than
380
+ * `indent` belong to the outer block and stop consumption.
381
+ */
382
+ function consumeList(
383
+ lines: string[],
384
+ start: number,
385
+ indent: number,
386
+ kind: 'bullet' | 'ordered' | 'task',
387
+ ): { items: ListItemBlock[], next: number } {
388
+ const items: ListItemBlock[] = []
389
+ let i = start
390
+ while (i < lines.length) {
391
+ const line = lines[i]!
392
+ if (line.trim() === '') {
393
+ // Blank line — peek ahead: if the next non-blank line still
394
+ // belongs to this list, treat the blank as an inter-item
395
+ // separator. Otherwise terminate.
396
+ let j = i + 1
397
+ while (j < lines.length && lines[j]!.trim() === '') j++
398
+ if (j >= lines.length) break
399
+ const lookahead = lines[j]!
400
+ if (leadingSpaces(lookahead) < indent) break
401
+ if (!matchMarker(lookahead.slice(indent), kind)) break
402
+ i = j
403
+ continue
404
+ }
405
+ const leading = leadingSpaces(line)
406
+ if (leading < indent) break
407
+ if (leading > indent) break // belongs to a nested list opened by the previous item
408
+ const deindented = line.slice(indent)
409
+ const m = matchMarker(deindented, kind)
410
+ if (!m) break
411
+
412
+ const item: ListItemBlock = { text: m.text }
413
+ if (kind === 'task') item.checked = m.checked
414
+ i++
415
+
416
+ // Consume continuation: lines indented deeper than `indent`.
417
+ const contLines: string[] = []
418
+ while (i < lines.length) {
419
+ const next = lines[i]!
420
+ if (next.trim() === '') {
421
+ // Blank inside a continuation block — could be part of the
422
+ // continuation if the next non-blank is still indented. Peek.
423
+ let k = i + 1
424
+ while (k < lines.length && lines[k]!.trim() === '') k++
425
+ if (k >= lines.length) break
426
+ const peekIndent = leadingSpaces(lines[k]!)
427
+ if (peekIndent <= indent) break
428
+ // Carry forward the blank line as a paragraph separator.
429
+ contLines.push('')
430
+ i++
431
+ continue
432
+ }
433
+ const nextIndent = leadingSpaces(next)
434
+ if (nextIndent <= indent) break
435
+ // De-indent by exactly 2 to match the canonical wire form.
436
+ const deindentBy = Math.min(nextIndent, indent + 2)
437
+ contLines.push(next.slice(deindentBy))
438
+ i++
439
+ }
440
+ if (contLines.length > 0) {
441
+ item.innerBlocks = parseBlocks(contLines.join('\n'))
442
+ }
443
+ items.push(item)
444
+ }
445
+ return { items, next: i }
446
+ }
447
+
448
+ function leadingSpaces(s: string): number {
449
+ let n = 0
450
+ while (n < s.length && s[n] === ' ') n++
451
+ return n
452
+ }
453
+
454
+ function matchMarker(
455
+ s: string,
456
+ kind: 'bullet' | 'ordered' | 'task',
457
+ ): { text: string, checked: boolean } | null {
458
+ if (kind === 'task') {
459
+ const m = s.match(TASK_RE)
460
+ if (!m) return null
461
+ return { text: m[2]!, checked: m[1]!.toLowerCase() === 'x' }
462
+ }
463
+ if (kind === 'bullet') {
464
+ if (TASK_RE.test(s)) return null
465
+ const m = s.match(/^[-*+]\s+(.*)$/)
466
+ if (!m) return null
467
+ return { text: m[1]!, checked: false }
468
+ }
469
+ // ordered
470
+ const m = s.match(/^\d+\.\s+(.*)$/)
471
+ if (!m) return null
472
+ return { text: m[1]!, checked: false }
473
+ }
474
+
475
+ function parseBlocks(markdown: string): Block[] {
476
+ // Strip top-level MDX import/export lines (only before the first content block).
477
+ // We must NOT strip `export default ...` etc. inside fenced code blocks.
478
+ const rawLines = markdown.split('\n')
479
+ let firstContentLine = 0
480
+ while (firstContentLine < rawLines.length) {
481
+ const l = rawLines[firstContentLine]!
482
+ if (l.trim() === '' || /^import\s/.test(l) || /^export\s/.test(l)) {
483
+ firstContentLine++
484
+ } else {
485
+ break
486
+ }
487
+ }
488
+ const stripped = rawLines.slice(firstContentLine).join('\n')
489
+
490
+ const blocks: Block[] = []
491
+ const lines = stripped.split('\n')
492
+ let i = 0
493
+
494
+ while (i < lines.length) {
495
+ const line = lines[i]!
496
+
497
+ // Fenced code block (supports variable-length fences: ```, ````, etc.)
498
+ const fenceBlockMatch = line.match(/^(`{3,})(.*)$/)
499
+ if (fenceBlockMatch) {
500
+ const fence = fenceBlockMatch[1]!
501
+ const lang = fenceBlockMatch[2]!.trim()
502
+ .replace(/\{[^}]*\}$/, '') // strip highlight annotations like {4-5}
503
+ .replace(/\s*\[.*\]$/, '') // strip filename hints like [nuxt.config.ts]
504
+ .trim()
505
+ const codeLines: string[] = []
506
+ i++
507
+ while (i < lines.length && !lines[i]!.startsWith(fence)) {
508
+ codeLines.push(lines[i]!)
509
+ i++
510
+ }
511
+ i++ // skip closing fence
512
+ const code = codeLines.join('\n')
513
+ if (lang === 'math') {
514
+ // ```math … ``` — block math atom (SPEC.md §6).
515
+ blocks.push({ type: 'mathBlock', expression: code })
516
+ }
517
+ else {
518
+ blocks.push({ type: 'codeBlock', lang, code })
519
+ }
520
+ continue
521
+ }
522
+
523
+ // Heading
524
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/)
525
+ if (headingMatch) {
526
+ blocks.push({ type: 'heading', level: headingMatch[1]!.length, text: headingMatch[2]!.trim() })
527
+ i++
528
+ continue
529
+ }
530
+
531
+ // Horizontal rule
532
+ if (/^[-*_]{3,}\s*$/.test(line)) {
533
+ blocks.push({ type: 'hr' })
534
+ i++
535
+ continue
536
+ }
537
+
538
+ // Doc embed: ![[uuid|label]]{collapsed tall seamless} — block-level,
539
+ // must be on its own line. UUID is required (the bracket syntax
540
+ // without one is reserved for inline doc-link).
541
+ const embedMatch = line.match(/^!\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+))?\]\](\{[^}]*\})?\s*$/)
542
+ if (embedMatch) {
543
+ const docId = embedMatch[1]!
544
+ const label = embedMatch[2] ?? ''
545
+ const props = parseMdcProps(embedMatch[3])
546
+ blocks.push({ type: 'docEmbed', docId, label, props })
547
+ i++
548
+ continue
549
+ }
550
+
551
+ // Image: ![alt](src){attrs} — must be on its own line
552
+ const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)(\{[^}]*\})?\s*$/)
553
+ if (imgMatch) {
554
+ const alt = imgMatch[1] ?? ''
555
+ const src = imgMatch[2] ?? ''
556
+ const attrs = parseMdcProps(imgMatch[3])
557
+ blocks.push({ type: 'image', src, alt, width: attrs['width'], height: attrs['height'] })
558
+ i++
559
+ continue
560
+ }
561
+
562
+ // Blockquote
563
+ if (line.startsWith('> ') || line === '>') {
564
+ const bqLines: string[] = []
565
+ while (i < lines.length && (lines[i]!.startsWith('> ') || lines[i] === '>')) {
566
+ bqLines.push(lines[i]!.replace(/^>\s?/, ''))
567
+ i++
568
+ }
569
+ blocks.push({ type: 'blockquote', lines: bqLines })
570
+ continue
571
+ }
572
+
573
+ // Table — detect by leading pipe
574
+ if (/^\s*\|/.test(line)) {
575
+ const tableLines: string[] = []
576
+ while (i < lines.length && /^\s*\|/.test(lines[i]!)) {
577
+ tableLines.push(lines[i]!)
578
+ i++
579
+ }
580
+ // Need at least: header row, separator row
581
+ if (tableLines.length >= 2 && isTableSeparator(tableLines[1]!)) {
582
+ const headerRow = parseTableRow(tableLines[0]!)
583
+ const dataRows = tableLines.slice(2)
584
+ .filter(l => !isTableSeparator(l))
585
+ .map(parseTableRow)
586
+ blocks.push({ type: 'table', headerRow, dataRows })
587
+ } else {
588
+ // Couldn't parse as table — emit as paragraphs
589
+ for (const l of tableLines) blocks.push({ type: 'paragraph', text: l })
590
+ }
591
+ continue
592
+ }
593
+
594
+ // MDC atom block (single-colon, no body): `:name{props}` on its
595
+ // own line. Currently recognised: `:file{…}` (fileBlock per
596
+ // SPEC.md §7). Other atom blocks (`:video{…}`, `:embed{…}`, etc.)
597
+ // are reserved for later workstreams.
598
+ const atomMatch = line.match(/^:(\w[\w-]*)(\{[^}]*\})?\s*$/)
599
+ if (atomMatch && atomMatch[1] === 'file') {
600
+ const props = parseMdcProps(atomMatch[2])
601
+ const uploadId = props['upload-id'] ?? props['uploadId'] ?? ''
602
+ const filename = props['filename'] ?? ''
603
+ const mime = props['mime'] ?? ''
604
+ const src = props['src'] ?? (uploadId && filename
605
+ ? `.abracadabra/files/${uploadId}-${filename}`
606
+ : '')
607
+ blocks.push({ type: 'fileBlock', src, mime, uploadId, filename })
608
+ i++
609
+ continue
610
+ }
611
+
612
+ // MDC component block (::component-name ... ::)
613
+ // Allow optional leading whitespace so indented nested blocks are recognised
614
+ const MDC_OPEN = /^\s*(:{2,})(\w[\w-]*)(\{[^}]*\})?\s*$/
615
+ if (MDC_OPEN.test(line)) {
616
+ const colons = line.match(/^\s*(:+)/)?.[1]?.length ?? 2
617
+ const componentName = line.match(/^\s*:{2,}(\w[\w-]*)/)?.[1] ?? ''
618
+ const innerLines: string[] = []
619
+ i++
620
+ // Collect inner lines, but skip over fenced code blocks so that
621
+ // `::` inside ```...``` or ````...```` doesn't close the MDC block
622
+ while (i < lines.length) {
623
+ const l = lines[i]!
624
+ // Closing tag: same colon count, possibly indented
625
+ if (new RegExp(`^\\s*:{${colons}}\\s*$`).test(l)) { i++; break }
626
+ // If we hit a fenced code block, consume it entirely
627
+ const innerFence = l.match(/^(\s*`{3,})/)
628
+ if (innerFence) {
629
+ const fenceStr = innerFence[1]!.trimStart()
630
+ innerLines.push(l)
631
+ i++
632
+ while (i < lines.length && !lines[i]!.trimStart().startsWith(fenceStr)) {
633
+ innerLines.push(lines[i]!)
634
+ i++
635
+ }
636
+ if (i < lines.length) { innerLines.push(lines[i]!); i++ } // push closing fence
637
+ continue
638
+ }
639
+ innerLines.push(l)
640
+ i++
641
+ }
642
+
643
+ // Strip common leading indentation from inner lines
644
+ const nonBlank = innerLines.filter(l => l.trim().length > 0)
645
+ if (nonBlank.length) {
646
+ const minIndent = Math.min(...nonBlank.map(l => l.match(/^(\s*)/)?.[1]?.length ?? 0))
647
+ if (minIndent > 0) {
648
+ for (let j = 0; j < innerLines.length; j++) {
649
+ innerLines[j] = innerLines[j]!.slice(Math.min(minIndent, innerLines[j]!.length))
650
+ }
651
+ }
652
+ }
653
+
654
+ // Strip inline frontmatter props (---...---)
655
+ let contentStart = 0
656
+ if (innerLines[0]?.trim() === '---') {
657
+ const fmEnd = innerLines.findIndex((l, idx) => idx > 0 && l.trim() === '---')
658
+ if (fmEnd !== -1) contentStart = fmEnd + 1
659
+ }
660
+ const contentLines = innerLines.slice(contentStart)
661
+
662
+ // Split into named slots: collect default slot and #code slot separately
663
+ const defaultSlotLines: string[] = []
664
+ const codeSlotLines: string[] = []
665
+ let currentSlot: 'default' | 'code' | 'other' = 'default'
666
+ for (const l of contentLines) {
667
+ if (/^#code\s*$/.test(l)) { currentSlot = 'code'; continue }
668
+ if (/^#\w+/.test(l) && !/^#{2,}\s/.test(l)) { currentSlot = 'other'; continue }
669
+ if (currentSlot === 'default') defaultSlotLines.push(l)
670
+ else if (currentSlot === 'code') codeSlotLines.push(l)
671
+ }
672
+ const innerBlocks = parseBlocks(defaultSlotLines.join('\n'))
673
+
674
+ // Extract fenced code from #code slot and add as code blocks
675
+ const codeBlocks = extractFencedCode(codeSlotLines)
676
+
677
+ const CALLOUT_NAMES = new Set(['tip', 'note', 'info', 'warning', 'caution', 'danger', 'callout', 'alert'])
678
+ if (CALLOUT_NAMES.has(componentName.toLowerCase())) {
679
+ blocks.push({ type: 'callout', calloutType: componentName.toLowerCase(), innerBlocks })
680
+ } else {
681
+ const mdcProps = parseMdcProps(line.match(MDC_OPEN)?.[3])
682
+ const lc = componentName.toLowerCase()
683
+
684
+ if (lc === 'collapsible') {
685
+ blocks.push({ type: 'collapsible', label: mdcProps['label'] || 'Details', open: mdcProps['open'] === 'true', innerBlocks })
686
+ } else if (lc === 'steps') {
687
+ blocks.push({ type: 'steps', innerBlocks })
688
+ } else if (lc === 'card') {
689
+ blocks.push({ type: 'card', title: mdcProps['title'] || '', icon: mdcProps['icon'] || '', to: mdcProps['to'] || '', innerBlocks })
690
+ } else if (lc === 'card-group') {
691
+ // Each nested ::card inside card-group is already parsed as inner blocks
692
+ const cards = innerBlocks.filter(b => b.type === 'card')
693
+ if (cards.length) {
694
+ blocks.push({ type: 'cardGroup', cards })
695
+ } else {
696
+ blocks.push(...innerBlocks)
697
+ }
698
+ } else if (lc === 'code-collapse') {
699
+ blocks.push({ type: 'codeCollapse', codeBlocks: codeBlocks.length ? codeBlocks : innerBlocks.filter(b => b.type === 'codeBlock') })
700
+ } else if (lc === 'code-group') {
701
+ const allCode = [...innerBlocks.filter(b => b.type === 'codeBlock'), ...codeBlocks]
702
+ blocks.push({ type: 'codeGroup', codeBlocks: allCode })
703
+ } else if (lc === 'code-preview') {
704
+ blocks.push({ type: 'codePreview', innerBlocks, codeBlocks })
705
+ } else if (lc === 'code-tree') {
706
+ blocks.push({ type: 'codeTree', files: mdcProps['files'] || '[]' })
707
+ } else if (lc === 'accordion') {
708
+ const items = parseMdcChildren(contentLines, 'item')
709
+ if (items.length) {
710
+ blocks.push({ type: 'accordion', items })
711
+ } else {
712
+ blocks.push({ type: 'accordion', items: [{ label: 'Item 1', icon: '', innerBlocks }] })
713
+ }
714
+ } else if (lc === 'tabs') {
715
+ const items = parseMdcChildren(contentLines, 'tab')
716
+ if (items.length) {
717
+ blocks.push({ type: 'tabs', items })
718
+ } else {
719
+ blocks.push({ type: 'tabs', items: [{ label: 'Tab 1', icon: '', innerBlocks }] })
720
+ }
721
+ } else if (lc === 'field') {
722
+ blocks.push({ type: 'field', name: mdcProps['name'] || '', fieldType: mdcProps['type'] || 'string', required: mdcProps['required'] === 'true', innerBlocks })
723
+ } else if (lc === 'field-group') {
724
+ const fields = innerBlocks.filter(b => b.type === 'field')
725
+ if (fields.length) {
726
+ blocks.push({ type: 'fieldGroup', fields })
727
+ } else {
728
+ blocks.push(...innerBlocks)
729
+ }
730
+ } else {
731
+ // Unknown component — inline the content
732
+ blocks.push(...innerBlocks)
733
+ blocks.push(...codeBlocks)
734
+ }
735
+ }
736
+ continue
737
+ }
738
+
739
+ // Task list (must check before bullet list — `- [x]` starts the same way)
740
+ if (TASK_RE.test(line)) {
741
+ const { items, next } = consumeList(lines, i, 0, 'task')
742
+ i = next
743
+ blocks.push({ type: 'taskList', items })
744
+ continue
745
+ }
746
+
747
+ // Bullet list
748
+ if (/^[-*+]\s+/.test(line)) {
749
+ const { items, next } = consumeList(lines, i, 0, 'bullet')
750
+ if (items.length > 0) {
751
+ i = next
752
+ blocks.push({ type: 'bulletList', items })
753
+ continue
754
+ }
755
+ }
756
+
757
+ // Ordered list
758
+ if (/^\d+\.\s+/.test(line)) {
759
+ const { items, next } = consumeList(lines, i, 0, 'ordered')
760
+ if (items.length > 0) {
761
+ i = next
762
+ blocks.push({ type: 'orderedList', items })
763
+ continue
764
+ }
765
+ }
766
+
767
+ // Blank line
768
+ if (line.trim() === '') {
769
+ i++
770
+ continue
771
+ }
772
+
773
+ // Paragraph — consecutive non-special lines
774
+ const paraLines: string[] = []
775
+ while (
776
+ i < lines.length
777
+ && lines[i]!.trim() !== ''
778
+ && !/^(#{1,6}\s|[-*+]\s|\d+\.\s|>|`{3,}|\s*\||[-*_]{3,}\s*$|\s*:{2,}\w)/.test(lines[i]!)
779
+ ) {
780
+ paraLines.push(lines[i]!)
781
+ i++
782
+ }
783
+ if (paraLines.length) {
784
+ blocks.push({ type: 'paragraph', text: paraLines.join(' ') })
785
+ }
786
+ }
787
+
788
+ return blocks
789
+ }
790
+
791
+ // ── Y.js content population ──────────────────────────────────────────────────
792
+ //
793
+ // IMPORTANT — attach-before-fill ordering:
794
+ //
795
+ // Y.XmlText.insert() is only deterministically ordered when the text node is
796
+ // already attached to a Y.Doc (so it can use the doc's logical clock).
797
+ // Inserting text into a standalone Y.XmlText gives every operation clock-0,
798
+ // which causes the YATA algorithm to produce reversed or scrambled text.
799
+ //
800
+ // Rule: attach container to doc → attach Y.XmlText to container → insert text.
801
+
802
+ /**
803
+ * Insert formatted inline tokens into an already-attached Y.XmlElement.
804
+ * Creates one Y.XmlText per token (attach first, fill second).
805
+ */
806
+ function fillTextInto(el: Y.XmlElement, tokens: InlineToken[]): void {
807
+ const filtered = tokens.filter(t => t.text.length > 0)
808
+ if (!filtered.length) return
809
+
810
+ // Create empty text nodes and attach them to el in one batch
811
+ const xtNodes = filtered.map(() => new Y.XmlText())
812
+ el.insert(0, xtNodes)
813
+
814
+ // Fill text only after attachment — el is already attached to the doc
815
+ filtered.forEach((tok, i) => {
816
+ if (tok.attrs) {
817
+ xtNodes[i]!.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
818
+ } else {
819
+ xtNodes[i]!.insert(0, tok.text)
820
+ }
821
+ })
822
+ }
823
+
824
+ function blockElName(b: Block): string {
825
+ switch (b.type) {
826
+ case 'heading': return 'heading'
827
+ case 'paragraph': return 'paragraph'
828
+ case 'bulletList': return 'bulletList'
829
+ case 'orderedList': return 'orderedList'
830
+ case 'taskList': return 'taskList'
831
+ case 'codeBlock': return 'codeBlock'
832
+ case 'blockquote': return 'blockquote'
833
+ case 'table': return 'table'
834
+ case 'hr': return 'horizontalRule'
835
+ case 'callout': return 'callout'
836
+ case 'collapsible': return 'collapsible'
837
+ case 'steps': return 'steps'
838
+ case 'card': return 'card'
839
+ case 'cardGroup': return 'cardGroup'
840
+ case 'codeCollapse': return 'codeCollapse'
841
+ case 'codeGroup': return 'codeGroup'
842
+ case 'codePreview': return 'codePreview'
843
+ case 'codeTree': return 'codeTree'
844
+ case 'accordion': return 'accordion'
845
+ case 'tabs': return 'tabs'
846
+ case 'field': return 'field'
847
+ case 'fieldGroup': return 'fieldGroup'
848
+ case 'image': return 'image'
849
+ case 'docEmbed': return 'docEmbed'
850
+ case 'mathBlock': return 'mathBlock'
851
+ case 'fileBlock': return 'fileBlock'
852
+ }
853
+ }
854
+
855
+ function populateListItemChildren(
856
+ itemEl: Y.XmlElement,
857
+ item: ListItemBlock,
858
+ _itemKind: 'listItem' | 'taskItem',
859
+ ): void {
860
+ // Always start with a paragraph carrying the item's inline text.
861
+ const paraEl = new Y.XmlElement('paragraph')
862
+ itemEl.insert(itemEl.length, [paraEl])
863
+ fillTextInto(paraEl, parseInline(item.text))
864
+
865
+ // Nested blocks (sub-lists, paragraphs, code, etc.) follow.
866
+ if (!item.innerBlocks?.length) return
867
+ const innerEls = item.innerBlocks.map(b => new Y.XmlElement(blockElName(b)))
868
+ itemEl.insert(itemEl.length, innerEls)
869
+ item.innerBlocks.forEach((b, i) => fillBlock(innerEls[i]!, b))
870
+ }
871
+
872
+ function fillBlock(el: Y.XmlElement, block: Block): void {
873
+ switch (block.type) {
874
+ case 'heading': {
875
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
876
+ el.setAttribute('level', block.level as any)
877
+ fillTextInto(el, parseInline(block.text))
878
+ break
879
+ }
880
+ case 'paragraph': {
881
+ fillTextInto(el, parseInline(block.text))
882
+ break
883
+ }
884
+ case 'bulletList':
885
+ case 'orderedList': {
886
+ const listItemEls = block.items.map(() => new Y.XmlElement('listItem'))
887
+ el.insert(0, listItemEls)
888
+ block.items.forEach((item, i) => {
889
+ populateListItemChildren(listItemEls[i]!, item, 'listItem')
890
+ })
891
+ break
892
+ }
893
+ case 'taskList': {
894
+ const taskItemEls = block.items.map(() => new Y.XmlElement('taskItem'))
895
+ el.insert(0, taskItemEls)
896
+ block.items.forEach((item, i) => {
897
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
898
+ taskItemEls[i]!.setAttribute('checked', !!item.checked as any)
899
+ populateListItemChildren(taskItemEls[i]!, item, 'taskItem')
900
+ })
901
+ break
902
+ }
903
+ case 'codeBlock': {
904
+ if (block.lang) el.setAttribute('language', block.lang)
905
+ const xt = new Y.XmlText()
906
+ el.insert(0, [xt]) // attach xt to el (already attached)
907
+ xt.insert(0, block.code)
908
+ break
909
+ }
910
+ case 'blockquote': {
911
+ const paraEls = block.lines.map(() => new Y.XmlElement('paragraph'))
912
+ el.insert(0, paraEls)
913
+ block.lines.forEach((line, i) => fillTextInto(paraEls[i]!, parseInline(line)))
914
+ break
915
+ }
916
+ case 'table': {
917
+ const headerRowEl = new Y.XmlElement('tableRow')
918
+ const dataRowEls = block.dataRows.map(() => new Y.XmlElement('tableRow'))
919
+ el.insert(0, [headerRowEl, ...dataRowEls])
920
+
921
+ // Header cells
922
+ const headerCellEls = block.headerRow.map(() => new Y.XmlElement('tableHeader'))
923
+ headerRowEl.insert(0, headerCellEls)
924
+ block.headerRow.forEach((cellText, i) => {
925
+ const paraEl = new Y.XmlElement('paragraph')
926
+ headerCellEls[i]!.insert(0, [paraEl])
927
+ fillTextInto(paraEl, parseInline(cellText))
928
+ })
929
+
930
+ // Data rows
931
+ block.dataRows.forEach((row, ri) => {
932
+ const cellEls = row.map(() => new Y.XmlElement('tableCell'))
933
+ dataRowEls[ri]!.insert(0, cellEls)
934
+ row.forEach((cellText, ci) => {
935
+ const paraEl = new Y.XmlElement('paragraph')
936
+ cellEls[ci]!.insert(0, [paraEl])
937
+ fillTextInto(paraEl, parseInline(cellText))
938
+ })
939
+ })
940
+ break
941
+ }
942
+ case 'hr': break // horizontalRule has no content
943
+ case 'callout': {
944
+ el.setAttribute('type', block.calloutType)
945
+ if (!block.innerBlocks.length) {
946
+ const paraEl = new Y.XmlElement('paragraph')
947
+ el.insert(0, [paraEl])
948
+ break
949
+ }
950
+ const innerEls = block.innerBlocks.map(b => new Y.XmlElement(blockElName(b)))
951
+ el.insert(0, innerEls)
952
+ block.innerBlocks.forEach((b, i) => fillBlock(innerEls[i]!, b))
953
+ break
954
+ }
955
+ case 'collapsible': {
956
+ el.setAttribute('label', block.label)
957
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
958
+ el.setAttribute('open', block.open as any)
959
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
960
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
961
+ el.insert(0, innerEls)
962
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
963
+ break
964
+ }
965
+ case 'steps': {
966
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
967
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
968
+ el.insert(0, innerEls)
969
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
970
+ break
971
+ }
972
+ case 'card': {
973
+ if (block.title) el.setAttribute('title', block.title)
974
+ if (block.icon) el.setAttribute('icon', block.icon)
975
+ if (block.to) el.setAttribute('to', block.to)
976
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
977
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
978
+ el.insert(0, innerEls)
979
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
980
+ break
981
+ }
982
+ case 'cardGroup': {
983
+ const cardEls = block.cards.map(b => new Y.XmlElement(blockElName(b)))
984
+ el.insert(0, cardEls)
985
+ block.cards.forEach((b, i) => fillBlock(cardEls[i]!, b))
986
+ break
987
+ }
988
+ case 'codeCollapse': {
989
+ const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
990
+ // Only insert the first code block (content model is singular codeBlock)
991
+ const codeEl = new Y.XmlElement('codeBlock')
992
+ el.insert(0, [codeEl])
993
+ fillBlock(codeEl, codes[0]!)
994
+ break
995
+ }
996
+ case 'codeGroup': {
997
+ const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
998
+ const codeEls = codes.map(() => new Y.XmlElement('codeBlock'))
999
+ el.insert(0, codeEls)
1000
+ codes.forEach((b, i) => fillBlock(codeEls[i]!, b))
1001
+ break
1002
+ }
1003
+ case 'codePreview': {
1004
+ const all = [...block.innerBlocks, ...block.codeBlocks]
1005
+ const inner = all.length ? all : [{ type: 'paragraph' as const, text: '' }]
1006
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
1007
+ el.insert(0, innerEls)
1008
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
1009
+ break
1010
+ }
1011
+ case 'codeTree': {
1012
+ el.setAttribute('files', block.files)
1013
+ break
1014
+ }
1015
+ case 'accordion': {
1016
+ const itemEls = block.items.map(() => new Y.XmlElement('accordionItem'))
1017
+ el.insert(0, itemEls)
1018
+ block.items.forEach((item, i) => {
1019
+ itemEls[i]!.setAttribute('label', item.label)
1020
+ if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
1021
+ const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
1022
+ const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
1023
+ itemEls[i]!.insert(0, childEls)
1024
+ inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
1025
+ })
1026
+ break
1027
+ }
1028
+ case 'tabs': {
1029
+ const itemEls = block.items.map(() => new Y.XmlElement('tabsItem'))
1030
+ el.insert(0, itemEls)
1031
+ block.items.forEach((item, i) => {
1032
+ itemEls[i]!.setAttribute('label', item.label)
1033
+ if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
1034
+ const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
1035
+ const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
1036
+ itemEls[i]!.insert(0, childEls)
1037
+ inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
1038
+ })
1039
+ break
1040
+ }
1041
+ case 'field': {
1042
+ if (block.name) el.setAttribute('name', block.name)
1043
+ el.setAttribute('type', block.fieldType)
1044
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1045
+ el.setAttribute('required', block.required as any)
1046
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
1047
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
1048
+ el.insert(0, innerEls)
1049
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
1050
+ break
1051
+ }
1052
+ case 'fieldGroup': {
1053
+ const fieldEls = block.fields.map(b => new Y.XmlElement(blockElName(b)))
1054
+ el.insert(0, fieldEls)
1055
+ block.fields.forEach((b, i) => fillBlock(fieldEls[i]!, b))
1056
+ break
1057
+ }
1058
+ case 'image': {
1059
+ el.setAttribute('src', block.src)
1060
+ if (block.alt) el.setAttribute('alt', block.alt)
1061
+ if (block.width) el.setAttribute('width', block.width)
1062
+ if (block.height) el.setAttribute('height', block.height)
1063
+ break
1064
+ }
1065
+ case 'docEmbed': {
1066
+ el.setAttribute('docId', block.docId)
1067
+ for (const flag of ['collapsed', 'tall', 'seamless']) {
1068
+ if (block.props[flag] === 'true' || block.props[flag] === '1') {
1069
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1070
+ el.setAttribute(flag, true as any)
1071
+ }
1072
+ }
1073
+ break
1074
+ }
1075
+ case 'mathBlock': {
1076
+ el.setAttribute('expression', block.expression)
1077
+ break
1078
+ }
1079
+ case 'fileBlock': {
1080
+ if (block.src) el.setAttribute('src', block.src)
1081
+ if (block.mime) el.setAttribute('mime', block.mime)
1082
+ if (block.uploadId) el.setAttribute('uploadId', block.uploadId)
1083
+ if (block.filename) el.setAttribute('filename', block.filename)
1084
+ break
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ // ── Public API ───────────────────────────────────────────────────────────────
1090
+
1091
+ /**
1092
+ * Parses markdown text and writes the result into a Y.XmlFragment that
1093
+ * TipTap's Collaboration extension can read.
1094
+ *
1095
+ * Requires `fragment.doc` to be set (i.e. the fragment must already be
1096
+ * obtained from a live Y.Doc via `ydoc.getXmlFragment('default')`).
1097
+ *
1098
+ * @param fragment The target `Y.Doc.getXmlFragment('default')`
1099
+ * @param markdown Raw markdown string
1100
+ * @param fallbackTitle Used as the title when the markdown has no H1
1101
+ */
1102
+ export function populateYDocFromMarkdown(
1103
+ fragment: Y.XmlFragment,
1104
+ markdown: string,
1105
+ fallbackTitle = 'Untitled'
1106
+ ): void {
1107
+ const ydoc = fragment.doc
1108
+ if (!ydoc) {
1109
+ console.warn('[markdownToYjs] fragment has no doc — skipping population')
1110
+ return
1111
+ }
1112
+
1113
+ // Strip YAML frontmatter from the head of the input so its `---`
1114
+ // fences don't get reinterpreted as horizontal rules.
1115
+ const fm = parseFrontmatter(markdown)
1116
+ const blocks = parseBlocks(fm.body)
1117
+
1118
+ let title = fallbackTitle
1119
+ // The title can live in three places: frontmatter, the first body H1,
1120
+ // or a filename-derived fallback. We track which so the serialiser
1121
+ // can round-trip into the same form. Body H1 wins over frontmatter
1122
+ // when both are present (frontmatter's title is typically a stale
1123
+ // copy of the body H1).
1124
+ let titleSource: 'h1' | 'frontmatter' | undefined
1125
+ if (fm.title !== undefined) {
1126
+ title = fm.title
1127
+ titleSource = 'frontmatter'
1128
+ }
1129
+ let contentBlocks = blocks
1130
+ const h1 = blocks.findIndex(b => b.type === 'heading' && b.level === 1)
1131
+ if (h1 !== -1) {
1132
+ title = (blocks[h1] as { type: 'heading', level: number, text: string }).text
1133
+ contentBlocks = blocks.filter((_, i) => i !== h1)
1134
+ titleSource = 'h1'
1135
+ }
1136
+ // For empty markdown we used to seed an empty paragraph so the
1137
+ // editor had something to focus. That bytes-pads the round-trip
1138
+ // back to "\n\n" — keep contentBlocks empty so the serialiser can
1139
+ // emit a clean empty file.
1140
+
1141
+ ydoc.transact(() => {
1142
+ // ── Step 1: create empty skeleton elements ────────────────────────────
1143
+ const headerEl = new Y.XmlElement('documentHeader')
1144
+ const metaEl = new Y.XmlElement('documentMeta')
1145
+ const bodyEls: Y.XmlElement[] = contentBlocks.map((b) => {
1146
+ switch (b.type) {
1147
+ case 'heading': return new Y.XmlElement('heading')
1148
+ case 'paragraph': return new Y.XmlElement('paragraph')
1149
+ case 'bulletList': return new Y.XmlElement('bulletList')
1150
+ case 'orderedList': return new Y.XmlElement('orderedList')
1151
+ case 'taskList': return new Y.XmlElement('taskList')
1152
+ case 'codeBlock': return new Y.XmlElement('codeBlock')
1153
+ case 'blockquote': return new Y.XmlElement('blockquote')
1154
+ case 'table': return new Y.XmlElement('table')
1155
+ case 'hr': return new Y.XmlElement('horizontalRule')
1156
+ case 'callout': return new Y.XmlElement('callout')
1157
+ case 'collapsible': return new Y.XmlElement('collapsible')
1158
+ case 'steps': return new Y.XmlElement('steps')
1159
+ case 'card': return new Y.XmlElement('card')
1160
+ case 'cardGroup': return new Y.XmlElement('cardGroup')
1161
+ case 'codeCollapse': return new Y.XmlElement('codeCollapse')
1162
+ case 'codeGroup': return new Y.XmlElement('codeGroup')
1163
+ case 'codePreview': return new Y.XmlElement('codePreview')
1164
+ case 'codeTree': return new Y.XmlElement('codeTree')
1165
+ case 'accordion': return new Y.XmlElement('accordion')
1166
+ case 'tabs': return new Y.XmlElement('tabs')
1167
+ case 'field': return new Y.XmlElement('field')
1168
+ case 'fieldGroup': return new Y.XmlElement('fieldGroup')
1169
+ case 'image': return new Y.XmlElement('image')
1170
+ case 'docEmbed': return new Y.XmlElement('docEmbed')
1171
+ case 'mathBlock': return new Y.XmlElement('mathBlock')
1172
+ case 'fileBlock': return new Y.XmlElement('fileBlock')
1173
+ }
1174
+ })
1175
+
1176
+ // ── Step 2: attach ALL skeleton elements to fragment in one shot ──────
1177
+ // After this line everything is connected to ydoc and subsequent ops
1178
+ // use the real doc clock — no more clock-0 scrambling.
1179
+ fragment.insert(0, [headerEl, metaEl, ...bodyEls])
1180
+
1181
+ // ── Step 3: fill header title + record where it came from ─────────────
1182
+ if (titleSource) {
1183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1184
+ headerEl.setAttribute('titleSource', titleSource as any)
1185
+ }
1186
+ const headerXt = new Y.XmlText()
1187
+ headerEl.insert(0, [headerXt])
1188
+ headerXt.insert(0, title)
1189
+
1190
+ // ── Step 4: stash parsed frontmatter meta on documentMeta ─────────────
1191
+ // Each universal-meta key becomes an attribute on documentMeta so
1192
+ // the serialiser can faithfully emit it back to frontmatter. Type
1193
+ // (page-type) is also stashed here under the well-known "type" key.
1194
+ for (const k of Object.keys(fm.meta)) {
1195
+ const v = (fm.meta as Record<string, unknown>)[k]
1196
+ if (v === undefined || v === null) continue
1197
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1198
+ metaEl.setAttribute(k, v as any)
1199
+ }
1200
+ if (fm.type) {
1201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1202
+ metaEl.setAttribute('type', fm.type as any)
1203
+ }
1204
+
1205
+ // ── Step 5: fill body blocks ──────────────────────────────────────────
1206
+ contentBlocks.forEach((block, i) => fillBlock(bodyEls[i]!, block))
1207
+ })
1208
+ }