@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,820 @@
1
+ import * as Y from 'yjs'
2
+ import type { DocPageMeta } from './types.ts'
3
+
4
+ // ── Inline serialization ────────────────────────────────────────────────────
5
+
6
+ function serializeDelta(delta: any[]): string {
7
+ let result = ''
8
+ for (const op of delta) {
9
+ if (typeof op.insert !== 'string') continue
10
+ let text = op.insert as string
11
+ const attrs = op.attributes ?? {}
12
+
13
+ if (attrs.code) {
14
+ result += `\`${text}\``
15
+ continue
16
+ }
17
+
18
+ // Badge mark
19
+ if (attrs.badge) {
20
+ const b = attrs.badge as { label?: string, color?: string, variant?: string }
21
+ const props: string[] = []
22
+ if (b.color && b.color !== 'neutral') props.push(`color="${b.color}"`)
23
+ if (b.variant && b.variant !== 'subtle') props.push(`variant="${b.variant}"`)
24
+ result += `:badge[${b.label || text}]${props.length ? `{${props.join(' ')}}` : ''}`
25
+ continue
26
+ }
27
+
28
+ // Prose icon mark
29
+ if (attrs.proseIcon) {
30
+ const icon = (attrs.proseIcon as { name?: string }).name || 'i-lucide-star'
31
+ result += `:icon{name="${icon}"}`
32
+ continue
33
+ }
34
+
35
+ // Kbd mark
36
+ if (attrs.kbd) {
37
+ const value = (attrs.kbd as { value?: string }).value || text
38
+ result += `:kbd{value="${value}"}`
39
+ continue
40
+ }
41
+
42
+ // docLink — inline doc reference. The label is the displayed text;
43
+ // the docId is the canonical anchor. Per SPEC.md §6 the label may
44
+ // be regenerated from a live registry, but the converter is pure —
45
+ // we use the stored text. Consumers that need fresh labels should
46
+ // rewrite them in the Y tree before serialising.
47
+ if (attrs.docLink) {
48
+ const docId = (attrs.docLink as { docId?: string }).docId
49
+ if (docId) {
50
+ result += text === docId ? `[[${docId}]]` : `[[${docId}|${text}]]`
51
+ continue
52
+ }
53
+ }
54
+
55
+ // mention — `@[label](user:uuid)`
56
+ if (attrs.mention) {
57
+ const { userId, label } = attrs.mention as { userId?: string, label?: string }
58
+ if (userId) {
59
+ result += `@[${label || text}](user:${userId})`
60
+ continue
61
+ }
62
+ }
63
+
64
+ // Inline math — `$expression$`
65
+ if (attrs.mathInline) {
66
+ const expr = (attrs.mathInline as { expression?: string }).expression ?? text
67
+ result += `$${expr}$`
68
+ continue
69
+ }
70
+
71
+ if (attrs.bold) text = `**${text}**`
72
+ if (attrs.italic) text = `*${text}*`
73
+ if (attrs.strike) text = `~~${text}~~`
74
+ if (attrs.link) {
75
+ const href = (attrs.link as { href?: string }).href ?? ''
76
+ text = `[${text}](${href})`
77
+ }
78
+
79
+ result += text
80
+ }
81
+ return result
82
+ }
83
+
84
+ function serializeInline(el: Y.XmlElement | Y.XmlFragment): string {
85
+ const parts: string[] = []
86
+ for (const child of el.toArray()) {
87
+ if (child instanceof Y.XmlText) {
88
+ parts.push(serializeDelta(child.toDelta()))
89
+ } else if (child instanceof Y.XmlElement) {
90
+ // Nested inline element — just get text
91
+ parts.push(serializeInline(child))
92
+ }
93
+ }
94
+ return parts.join('')
95
+ }
96
+
97
+ // ── Block serialization ─────────────────────────────────────────────────────
98
+
99
+ function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
100
+ if (el instanceof Y.XmlText) {
101
+ return serializeDelta(el.toDelta())
102
+ }
103
+
104
+ const name = el.nodeName
105
+ switch (name) {
106
+ case 'documentHeader':
107
+ case 'documentMeta':
108
+ return '' // handled via frontmatter
109
+
110
+ case 'heading': {
111
+ const level = Number(el.getAttribute('level') ?? 2)
112
+ const hashes = '#'.repeat(level)
113
+ return `${hashes} ${serializeInline(el)}`
114
+ }
115
+
116
+ case 'paragraph':
117
+ return serializeInline(el)
118
+
119
+ case 'bulletList':
120
+ return serializeListItems(el, 'bullet', indent)
121
+
122
+ case 'orderedList':
123
+ return serializeListItems(el, 'ordered', indent)
124
+
125
+ case 'taskList':
126
+ return serializeTaskList(el, indent)
127
+
128
+ case 'codeBlock': {
129
+ const lang = el.getAttribute('language') ?? ''
130
+ const code = getCodeBlockText(el)
131
+ // Empty body — single-newline form preserves the `\`\`\`lang\n\`\`\``
132
+ // wire form (no padding blank line).
133
+ if (code === '') return `\`\`\`${lang}\n\`\`\``
134
+ return `\`\`\`${lang}\n${code}\n\`\`\``
135
+ }
136
+
137
+ case 'blockquote': {
138
+ const lines: string[] = []
139
+ for (const child of el.toArray()) {
140
+ if (child instanceof Y.XmlElement) {
141
+ const text = serializeBlock(child)
142
+ for (const line of text.split('\n')) {
143
+ lines.push(`> ${line}`)
144
+ }
145
+ }
146
+ }
147
+ return lines.join('\n')
148
+ }
149
+
150
+ case 'table':
151
+ return serializeTable(el)
152
+
153
+ case 'horizontalRule':
154
+ return '---'
155
+
156
+ case 'image': {
157
+ const src = el.getAttribute('src') ?? ''
158
+ const alt = el.getAttribute('alt') ?? ''
159
+ const width = el.getAttribute('width')
160
+ const height = el.getAttribute('height')
161
+ const attrs: string[] = []
162
+ // MDC convention is `{width=400 height=300}` — bare values, no
163
+ // quotes — matching what the parser at the time accepts.
164
+ if (width) attrs.push(`width=${width}`)
165
+ if (height) attrs.push(`height=${height}`)
166
+ return `![${alt}](${src})${attrs.length ? `{${attrs.join(' ')}}` : ''}`
167
+ }
168
+
169
+ case 'docEmbed': {
170
+ const docId = el.getAttribute('docId') ?? ''
171
+ const collapsed: unknown = el.getAttribute('collapsed')
172
+ const tall: unknown = el.getAttribute('tall')
173
+ const seamless: unknown = el.getAttribute('seamless')
174
+ const flags: string[] = []
175
+ if (collapsed === true || collapsed === 'true') flags.push('collapsed')
176
+ if (tall === true || tall === 'true') flags.push('tall')
177
+ if (seamless === true || seamless === 'true') flags.push('seamless')
178
+ return `![[${docId}]]${flags.length ? `{${flags.join(' ')}}` : ''}`
179
+ }
180
+
181
+ case 'mathBlock': {
182
+ const expr = el.getAttribute('expression') ?? ''
183
+ return `\`\`\`math\n${expr}\n\`\`\``
184
+ }
185
+
186
+ case 'fileBlock': {
187
+ // SPEC.md §7 wire form: `:file{src=… mime=… upload-id=… filename=…}`.
188
+ // The sidecar binary lives under `.abracadabra/files/`; this tag
189
+ // is purely a reference into that store + the manifest.
190
+ const uploadId = el.getAttribute('uploadId') ?? ''
191
+ const filename = el.getAttribute('filename') ?? ''
192
+ const mime = el.getAttribute('mime') ?? ''
193
+ const src = el.getAttribute('src') ?? (uploadId && filename
194
+ ? `.abracadabra/files/${uploadId}-${filename}`
195
+ : '')
196
+ const props: string[] = []
197
+ if (src) props.push(`src="${src}"`)
198
+ if (mime) props.push(`mime="${mime}"`)
199
+ if (uploadId) props.push(`upload-id="${uploadId}"`)
200
+ if (filename) props.push(`filename="${filename}"`)
201
+ return `:file{${props.join(' ')}}`
202
+ }
203
+
204
+ // MDC components
205
+ case 'callout': {
206
+ const type = el.getAttribute('type') ?? 'note'
207
+ return `::${type}\n${serializeChildren(el)}\n::`
208
+ }
209
+
210
+ case 'collapsible': {
211
+ const label = el.getAttribute('label') ?? 'Details'
212
+ const open: unknown = el.getAttribute('open')
213
+ const props: string[] = [`label="${label}"`]
214
+ if (open === true || open === 'true') props.push('open="true"')
215
+ return `::collapsible{${props.join(' ')}}\n${serializeChildren(el)}\n::`
216
+ }
217
+
218
+ case 'steps':
219
+ return `::steps\n${serializeChildren(el)}\n::`
220
+
221
+ case 'card': {
222
+ const props: string[] = []
223
+ const title = el.getAttribute('title')
224
+ const icon = el.getAttribute('icon')
225
+ const to = el.getAttribute('to')
226
+ if (title) props.push(`title="${title}"`)
227
+ if (icon) props.push(`icon="${icon}"`)
228
+ if (to) props.push(`to="${to}"`)
229
+ return `::card${props.length ? `{${props.join(' ')}}` : ''}\n${serializeChildren(el)}\n::`
230
+ }
231
+
232
+ case 'cardGroup': {
233
+ const cards = el.toArray()
234
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
235
+ .map(c => serializeBlock(c))
236
+ .join('\n\n')
237
+ return `::card-group\n${cards}\n::`
238
+ }
239
+
240
+ case 'codeCollapse': {
241
+ const code = el.toArray()
242
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement && c.nodeName === 'codeBlock')
243
+ .map(c => serializeBlock(c))
244
+ .join('\n\n')
245
+ return `::code-collapse\n${code}\n::`
246
+ }
247
+
248
+ case 'codeGroup': {
249
+ const code = el.toArray()
250
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement && c.nodeName === 'codeBlock')
251
+ .map(c => serializeBlock(c))
252
+ .join('\n\n')
253
+ return `::code-group\n${code}\n::`
254
+ }
255
+
256
+ case 'codePreview': {
257
+ const children = el.toArray().filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
258
+ const nonCode = children.filter(c => c.nodeName !== 'codeBlock').map(c => serializeBlock(c)).join('\n\n')
259
+ const code = children.filter(c => c.nodeName === 'codeBlock').map(c => serializeBlock(c)).join('\n\n')
260
+ const parts = [nonCode]
261
+ if (code) parts.push(`#code\n${code}`)
262
+ return `::code-preview\n${parts.filter(Boolean).join('\n\n')}\n::`
263
+ }
264
+
265
+ case 'codeTree': {
266
+ const files = el.getAttribute('files') ?? '[]'
267
+ return `::code-tree{files="${files}"}\n::`
268
+ }
269
+
270
+ case 'accordion':
271
+ return serializeSlottedContainer(el, 'accordion', 'accordionItem', 'item')
272
+
273
+ case 'tabs':
274
+ return serializeSlottedContainer(el, 'tabs', 'tabsItem', 'tab')
275
+
276
+ case 'field': {
277
+ const fieldName = el.getAttribute('name') ?? ''
278
+ const fieldType = el.getAttribute('type') ?? 'string'
279
+ const required: unknown = el.getAttribute('required')
280
+ const props = [`name="${fieldName}"`, `type="${fieldType}"`]
281
+ if (required === true || required === 'true') props.push('required="true"')
282
+ return `::field{${props.join(' ')}}\n${serializeChildren(el)}\n::`
283
+ }
284
+
285
+ case 'fieldGroup': {
286
+ const fields = el.toArray()
287
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
288
+ .map(c => serializeBlock(c))
289
+ .join('\n\n')
290
+ return `::field-group\n${fields}\n::`
291
+ }
292
+
293
+ default:
294
+ // Unknown node type — try to serialize children
295
+ return serializeChildren(el)
296
+ }
297
+ }
298
+
299
+ function serializeChildren(el: Y.XmlElement | Y.XmlFragment): string {
300
+ const blocks: string[] = []
301
+ for (const child of el.toArray()) {
302
+ if (child instanceof Y.XmlElement) {
303
+ const text = serializeBlock(child)
304
+ if (text) blocks.push(text)
305
+ } else if (child instanceof Y.XmlText) {
306
+ const text = serializeDelta(child.toDelta())
307
+ if (text) blocks.push(text)
308
+ }
309
+ }
310
+ return blocks.join('\n\n')
311
+ }
312
+
313
+ function serializeListItems(el: Y.XmlElement, type: 'bullet' | 'ordered', indent: string): string {
314
+ const lines: string[] = []
315
+ let counter = 1
316
+ for (const child of el.toArray()) {
317
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== 'listItem') continue
318
+ const prefix = type === 'bullet' ? '- ' : `${counter++}. `
319
+ // A listItem may contain paragraphs and nested lists
320
+ const subParts: string[] = []
321
+ for (const sub of child.toArray()) {
322
+ if (!(sub instanceof Y.XmlElement)) continue
323
+ if (sub.nodeName === 'bulletList') {
324
+ subParts.push(serializeListItems(sub, 'bullet', indent + ' '))
325
+ } else if (sub.nodeName === 'orderedList') {
326
+ subParts.push(serializeListItems(sub, 'ordered', indent + ' '))
327
+ } else {
328
+ subParts.push(serializeInline(sub))
329
+ }
330
+ }
331
+ if (subParts.length <= 1) {
332
+ lines.push(`${indent}${prefix}${subParts[0] ?? ''}`)
333
+ } else {
334
+ lines.push(`${indent}${prefix}${subParts[0] ?? ''}`)
335
+ for (let i = 1; i < subParts.length; i++) {
336
+ // Nested lists are already indented; plain text gets continuation indent
337
+ lines.push(subParts[i]!)
338
+ }
339
+ }
340
+ }
341
+ return lines.join('\n')
342
+ }
343
+
344
+ function serializeTaskList(el: Y.XmlElement, indent: string): string {
345
+ const lines: string[] = []
346
+ for (const child of el.toArray()) {
347
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== 'taskItem') continue
348
+ const checked: unknown = child.getAttribute('checked')
349
+ const marker = (checked === true || checked === 'true') ? '[x]' : '[ ]'
350
+
351
+ let header = ''
352
+ const nestedParts: string[] = []
353
+ for (const sub of child.toArray()) {
354
+ if (!(sub instanceof Y.XmlElement)) continue
355
+ if (sub.nodeName === 'paragraph' && header === '') {
356
+ header = serializeInline(sub)
357
+ }
358
+ else if (sub.nodeName === 'bulletList') {
359
+ nestedParts.push(serializeListItems(sub, 'bullet', indent + ' '))
360
+ }
361
+ else if (sub.nodeName === 'orderedList') {
362
+ nestedParts.push(serializeListItems(sub, 'ordered', indent + ' '))
363
+ }
364
+ else if (sub.nodeName === 'taskList') {
365
+ nestedParts.push(serializeTaskList(sub, indent + ' '))
366
+ }
367
+ else {
368
+ // Other block — render under the item with a 2-space hanging indent
369
+ nestedParts.push((indent + ' ') + serializeBlock(sub, indent + ' '))
370
+ }
371
+ }
372
+ lines.push(`${indent}- ${marker} ${header}`)
373
+ for (const part of nestedParts) lines.push(part)
374
+ }
375
+ return lines.join('\n')
376
+ }
377
+
378
+ function getCodeBlockText(el: Y.XmlElement): string {
379
+ for (const child of el.toArray()) {
380
+ if (child instanceof Y.XmlText) {
381
+ return child.toString()
382
+ }
383
+ }
384
+ return ''
385
+ }
386
+
387
+ function serializeTable(el: Y.XmlElement): string {
388
+ const rows = el.toArray().filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
389
+ if (!rows.length) return ''
390
+
391
+ const serializedRows: string[][] = []
392
+ for (const row of rows) {
393
+ const cells = row.toArray()
394
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
395
+ .map((cell) => {
396
+ // Cell contains paragraphs — join their text
397
+ return cell.toArray()
398
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
399
+ .map(c => serializeInline(c))
400
+ .join(' ')
401
+ })
402
+ serializedRows.push(cells)
403
+ }
404
+
405
+ if (!serializedRows.length) return ''
406
+
407
+ const colCount = Math.max(...serializedRows.map(r => r.length))
408
+ const headerRow = serializedRows[0]!
409
+ const separator = Array(colCount).fill('---')
410
+ const dataRows = serializedRows.slice(1)
411
+
412
+ const formatRow = (cells: string[]) => {
413
+ const padded = Array(colCount).fill('').map((_, i) => cells[i] ?? '')
414
+ return `| ${padded.join(' | ')} |`
415
+ }
416
+
417
+ const lines = [formatRow(headerRow), formatRow(separator), ...dataRows.map(formatRow)]
418
+ return lines.join('\n')
419
+ }
420
+
421
+ function serializeSlottedContainer(
422
+ el: Y.XmlElement,
423
+ containerName: string,
424
+ childName: string,
425
+ slotPrefix: string
426
+ ): string {
427
+ const items = el.toArray().filter((c): c is Y.XmlElement => c instanceof Y.XmlElement && c.nodeName === childName)
428
+ const slots = items.map((item) => {
429
+ const label = item.getAttribute('label') ?? ''
430
+ const icon = item.getAttribute('icon') ?? ''
431
+ const props: string[] = []
432
+ if (label) props.push(`label="${label}"`)
433
+ if (icon) props.push(`icon="${icon}"`)
434
+ const content = serializeChildren(item)
435
+ return `#${slotPrefix}{${props.join(' ')}}\n${content}`
436
+ })
437
+ return `::${containerName}\n${slots.join('\n\n')}\n::`
438
+ }
439
+
440
+ // ── Frontmatter generation ──────────────────────────────────────────────────
441
+
442
+ function generateFrontmatter(label: string | undefined, meta?: DocPageMeta, type?: string): string {
443
+ const lines: string[] = []
444
+
445
+ if (label !== undefined) lines.push(`title: "${escapeYaml(label)}"`)
446
+
447
+ if (type && type !== 'doc') {
448
+ lines.push(`type: ${type}`)
449
+ }
450
+
451
+ if (!meta) return `---\n${lines.join('\n')}\n---`
452
+
453
+ if (meta.tags?.length) {
454
+ lines.push(`tags: [${meta.tags.join(', ')}]`)
455
+ }
456
+ if (meta.color) lines.push(`color: ${yamlScalar(meta.color)}`)
457
+ if (meta.icon) lines.push(`icon: ${yamlScalar(meta.icon)}`)
458
+ if (meta.status) lines.push(`status: ${yamlScalar(meta.status)}`)
459
+
460
+ if (meta.priority !== undefined && meta.priority !== 0) {
461
+ const map: Record<number, string> = { 1: 'low', 2: 'medium', 3: 'high', 4: 'urgent' }
462
+ lines.push(`priority: ${map[meta.priority] ?? meta.priority}`)
463
+ }
464
+
465
+ if (meta.checked !== undefined) lines.push(`checked: ${meta.checked}`)
466
+ if (meta.dateStart) lines.push(`dateStart: "${escapeYaml(meta.dateStart)}"`)
467
+ if (meta.dateEnd) lines.push(`dateEnd: "${escapeYaml(meta.dateEnd)}"`)
468
+ if (meta.subtitle) lines.push(`subtitle: "${escapeYaml(meta.subtitle)}"`)
469
+ if (meta.url) lines.push(`url: ${meta.url}`)
470
+ if (meta.rating !== undefined && meta.rating !== 0) lines.push(`rating: ${meta.rating}`)
471
+
472
+ return `---\n${lines.join('\n')}\n---`
473
+ }
474
+
475
+ /**
476
+ * Render a YAML scalar — bare when safe, double-quoted when the value
477
+ * needs escaping. YAML treats `#`, `:`, leading whitespace, and a few
478
+ * other characters as syntactically significant, so anything starting
479
+ * with one of those gets quoted to stay round-trip safe.
480
+ */
481
+ function yamlScalar(s: string): string {
482
+ if (s === '') return '""'
483
+ if (/^[#&*!|>%@`]/.test(s)) return `"${escapeYaml(s)}"`
484
+ if (/[:"]/.test(s)) return `"${escapeYaml(s)}"`
485
+ if (/^\s|\s$/.test(s)) return `"${escapeYaml(s)}"`
486
+ return s
487
+ }
488
+
489
+ function escapeYaml(s: string): string {
490
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
491
+ }
492
+
493
+ // ── HTML serialization ──────────────────────────────────────────────────────
494
+
495
+ function serializeBlockToHtml(el: Y.XmlElement | Y.XmlText): string {
496
+ if (el instanceof Y.XmlText) {
497
+ return serializeDeltaToHtml(el.toDelta())
498
+ }
499
+
500
+ const name = el.nodeName
501
+ switch (name) {
502
+ case 'documentHeader':
503
+ case 'documentMeta':
504
+ return ''
505
+
506
+ case 'heading': {
507
+ const level = Number(el.getAttribute('level') ?? 2)
508
+ return `<h${level}>${serializeInlineHtml(el)}</h${level}>`
509
+ }
510
+
511
+ case 'paragraph':
512
+ return `<p>${serializeInlineHtml(el)}</p>`
513
+
514
+ case 'bulletList':
515
+ return `<ul>${serializeListHtml(el)}</ul>`
516
+
517
+ case 'orderedList':
518
+ return `<ol>${serializeListHtml(el)}</ol>`
519
+
520
+ case 'taskList':
521
+ return `<ul>${serializeTaskListHtml(el)}</ul>`
522
+
523
+ case 'codeBlock': {
524
+ const lang = el.getAttribute('language') ?? ''
525
+ const code = escapeHtml(getCodeBlockText(el))
526
+ return lang
527
+ ? `<pre><code class="language-${lang}">${code}</code></pre>`
528
+ : `<pre><code>${code}</code></pre>`
529
+ }
530
+
531
+ case 'blockquote': {
532
+ const inner = el.toArray()
533
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
534
+ .map(c => serializeBlockToHtml(c))
535
+ .join('\n')
536
+ return `<blockquote>\n${inner}\n</blockquote>`
537
+ }
538
+
539
+ case 'table':
540
+ return serializeTableHtml(el)
541
+
542
+ case 'horizontalRule':
543
+ return '<hr>'
544
+
545
+ case 'image': {
546
+ const src = el.getAttribute('src') ?? ''
547
+ const alt = el.getAttribute('alt') ?? ''
548
+ return `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">`
549
+ }
550
+
551
+ case 'fileBlock': {
552
+ const uploadId = el.getAttribute('uploadId') ?? ''
553
+ const filename = el.getAttribute('filename') ?? 'file'
554
+ if (uploadId) return `<!--fileblock:${uploadId}:${filename}-->`
555
+ return `<!-- file: ${filename} -->`
556
+ }
557
+
558
+ default: {
559
+ // Generic wrapper for MDC blocks etc.
560
+ const inner = el.toArray()
561
+ .filter((c): c is Y.XmlElement | Y.XmlText => c instanceof Y.XmlElement || c instanceof Y.XmlText)
562
+ .map(c => c instanceof Y.XmlElement ? serializeBlockToHtml(c) : serializeDeltaToHtml(c.toDelta()))
563
+ .join('\n')
564
+ return `<div data-type="${name}">\n${inner}\n</div>`
565
+ }
566
+ }
567
+ }
568
+
569
+ function serializeInlineHtml(el: Y.XmlElement | Y.XmlFragment): string {
570
+ const parts: string[] = []
571
+ for (const child of el.toArray()) {
572
+ if (child instanceof Y.XmlText) {
573
+ parts.push(serializeDeltaToHtml(child.toDelta()))
574
+ } else if (child instanceof Y.XmlElement) {
575
+ parts.push(serializeInlineHtml(child))
576
+ }
577
+ }
578
+ return parts.join('')
579
+ }
580
+
581
+ function serializeDeltaToHtml(delta: any[]): string {
582
+ let result = ''
583
+ for (const op of delta) {
584
+ if (typeof op.insert !== 'string') continue
585
+ let text = escapeHtml(op.insert as string)
586
+ const attrs = op.attributes ?? {}
587
+ if (attrs.code) text = `<code>${text}</code>`
588
+ if (attrs.bold) text = `<strong>${text}</strong>`
589
+ if (attrs.italic) text = `<em>${text}</em>`
590
+ if (attrs.strike) text = `<s>${text}</s>`
591
+ if (attrs.link) {
592
+ const href = escapeHtml((attrs.link as { href?: string }).href ?? '')
593
+ text = `<a href="${href}">${text}</a>`
594
+ }
595
+ result += text
596
+ }
597
+ return result
598
+ }
599
+
600
+ function serializeListHtml(el: Y.XmlElement): string {
601
+ return el.toArray()
602
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement && c.nodeName === 'listItem')
603
+ .map(li => `<li>${li.toArray().filter((c): c is Y.XmlElement => c instanceof Y.XmlElement).map(c => serializeBlockToHtml(c)).join('')}</li>`)
604
+ .join('\n')
605
+ }
606
+
607
+ function serializeTaskListHtml(el: Y.XmlElement): string {
608
+ return el.toArray()
609
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement && c.nodeName === 'taskItem')
610
+ .map((ti) => {
611
+ const rawChecked: unknown = ti.getAttribute('checked')
612
+ const checked = rawChecked === true || rawChecked === 'true'
613
+ const text = ti.toArray().filter((c): c is Y.XmlElement => c instanceof Y.XmlElement).map(c => serializeInlineHtml(c)).join('')
614
+ return `<li><input type="checkbox"${checked ? ' checked' : ''} disabled> ${text}</li>`
615
+ })
616
+ .join('\n')
617
+ }
618
+
619
+ function serializeTableHtml(el: Y.XmlElement): string {
620
+ const rows = el.toArray().filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
621
+ if (!rows.length) return ''
622
+
623
+ const htmlRows = rows.map((row, ri) => {
624
+ const tag = ri === 0 ? 'th' : 'td'
625
+ const cells = row.toArray()
626
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
627
+ .map((cell) => {
628
+ const inner = cell.toArray()
629
+ .filter((c): c is Y.XmlElement => c instanceof Y.XmlElement)
630
+ .map(c => serializeInlineHtml(c))
631
+ .join('')
632
+ return `<${tag}>${inner}</${tag}>`
633
+ })
634
+ .join('')
635
+ return `<tr>${cells}</tr>`
636
+ })
637
+
638
+ return `<table>\n${htmlRows.join('\n')}\n</table>`
639
+ }
640
+
641
+ function escapeHtml(s: string): string {
642
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
643
+ }
644
+
645
+ // ── Public API ──────────────────────────────────────────────────────────────
646
+
647
+ export function yjsToMarkdown(
648
+ fragment: Y.XmlFragment,
649
+ label: string,
650
+ meta?: DocPageMeta,
651
+ type?: string
652
+ ): string {
653
+ // The title can come from three places. We honour where the parser
654
+ // captured it so the wire form round-trips byte-stably:
655
+ // - 'h1' → emit `# title` as the first body block
656
+ // - 'frontmatter' → emit `title:` in frontmatter
657
+ // - undefined → no real title; skip the frontmatter block
658
+ // when no other meta is set
659
+ const { text: headerText, source: titleSource } = readDocumentHeader(fragment)
660
+ const effectiveTitle = headerText || label
661
+
662
+ // If the caller didn't pass meta / type, read them from documentMeta —
663
+ // that's where the parser stashes frontmatter fields so they survive
664
+ // round-trip without needing to be piped through the consumer.
665
+ const docMeta = readDocumentMeta(fragment)
666
+ const effectiveMeta = meta ?? docMeta.meta
667
+ const effectiveType = type ?? docMeta.type
668
+
669
+ const metaIsEmpty = isMetaEmpty(effectiveMeta)
670
+ const typeIsDefault = !effectiveType || effectiveType === 'doc'
671
+
672
+ const bodyBlocks = collectBodyBlocks(fragment)
673
+
674
+ // Body assembly — when the parser captured the title from a body H1
675
+ // we restore it at the top of the body before serialising.
676
+ let body: string
677
+ if (titleSource === 'h1' && effectiveTitle) {
678
+ const tail = serializeBlocksClean(bodyBlocks)
679
+ body = tail === '' ? `# ${effectiveTitle}` : `# ${effectiveTitle}\n\n${tail}`
680
+ }
681
+ else {
682
+ body = serializeBlocksClean(bodyBlocks)
683
+ }
684
+
685
+ const wantFrontmatterTitle = titleSource === 'frontmatter'
686
+ const wantFrontmatterMeta = !metaIsEmpty || !typeIsDefault
687
+ if (!wantFrontmatterTitle && !wantFrontmatterMeta) {
688
+ return body === '' ? '' : `${body}\n`
689
+ }
690
+ const fmTitle = wantFrontmatterTitle ? effectiveTitle : undefined
691
+ const frontmatter = generateFrontmatter(fmTitle, effectiveMeta, effectiveType)
692
+ if (body === '') return `${frontmatter}\n`
693
+ return `${frontmatter}\n\n${body}\n`
694
+ }
695
+
696
+ function readDocumentMeta(fragment: Y.XmlFragment): { meta: DocPageMeta, type?: string } {
697
+ const meta: DocPageMeta = {}
698
+ let type: string | undefined
699
+ for (const child of fragment.toArray()) {
700
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== 'documentMeta') continue
701
+ const attrs = child.getAttributes() as Record<string, unknown>
702
+ for (const k of Object.keys(attrs)) {
703
+ const v = attrs[k]
704
+ if (v === undefined || v === null) continue
705
+ if (k === 'type' && typeof v === 'string') {
706
+ type = v
707
+ continue
708
+ }
709
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
710
+ (meta as Record<string, unknown>)[k] = v
711
+ }
712
+ break
713
+ }
714
+ return { meta, type }
715
+ }
716
+
717
+ interface HeaderInfo { text: string, source?: 'h1' | 'frontmatter' }
718
+
719
+ function readDocumentHeader(fragment: Y.XmlFragment): HeaderInfo {
720
+ for (const child of fragment.toArray()) {
721
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== 'documentHeader') continue
722
+ const text = child.toArray().find(c => c instanceof Y.XmlText) as Y.XmlText | undefined
723
+ const src: unknown = child.getAttribute('titleSource')
724
+ const source = src === 'h1' || src === 'frontmatter' ? src : undefined
725
+ return { text: text ? text.toString() : '', source }
726
+ }
727
+ return { text: '' }
728
+ }
729
+
730
+ function collectBodyBlocks(fragment: Y.XmlFragment): Y.XmlElement[] {
731
+ const out: Y.XmlElement[] = []
732
+ for (const child of fragment.toArray()) {
733
+ if (!(child instanceof Y.XmlElement)) continue
734
+ if (child.nodeName === 'documentHeader' || child.nodeName === 'documentMeta') continue
735
+ out.push(child)
736
+ }
737
+ return out
738
+ }
739
+
740
+ function serializeBlocksClean(blocks: Y.XmlElement[]): string {
741
+ const parts: string[] = []
742
+ for (const block of blocks) {
743
+ // Empty trailing paragraphs are the parser's placeholder for an
744
+ // "empty doc"; skip them so empty input round-trips to "".
745
+ if (block.nodeName === 'paragraph' && block.length === 0) {
746
+ parts.push('')
747
+ continue
748
+ }
749
+ parts.push(serializeBlock(block))
750
+ }
751
+ while (parts.length && parts[parts.length - 1] === '') parts.pop()
752
+ return parts.join('\n\n')
753
+ }
754
+
755
+ function isMetaEmpty(meta: DocPageMeta | undefined): boolean {
756
+ if (!meta) return true
757
+ for (const key of Object.keys(meta)) {
758
+ const v = (meta as Record<string, unknown>)[key]
759
+ if (v === undefined || v === null) continue
760
+ if (typeof v === 'string' && v === '') continue
761
+ if (Array.isArray(v) && v.length === 0) continue
762
+ return false
763
+ }
764
+ return true
765
+ }
766
+
767
+ /**
768
+ * Walk the Y.XmlFragment and concatenate all text content with no
769
+ * markup or frontmatter. Block boundaries become newlines. Useful for
770
+ * accessibility tooling, search indexing, and snippet previews.
771
+ */
772
+ export function yjsToPlainText(fragment: Y.XmlFragment): string {
773
+ const out: string[] = []
774
+ const visit = (node: Y.XmlElement | Y.XmlText): void => {
775
+ if (node instanceof Y.XmlText) {
776
+ out.push(node.toString())
777
+ return
778
+ }
779
+ if (node.nodeName === 'documentMeta') return
780
+ if (node.nodeName === 'image') {
781
+ const alt = node.getAttribute('alt') ?? ''
782
+ if (alt) out.push(alt)
783
+ return
784
+ }
785
+ for (const child of node.toArray()) {
786
+ if (child instanceof Y.XmlText || child instanceof Y.XmlElement) visit(child)
787
+ }
788
+ // Insert a single newline after block-level elements so paragraphs
789
+ // and headings produce a readable plain-text form.
790
+ if (node.nodeName !== 'paragraph' && node.length === 0) return
791
+ out.push('\n')
792
+ }
793
+ for (const child of fragment.toArray()) {
794
+ if (child instanceof Y.XmlText || child instanceof Y.XmlElement) visit(child)
795
+ }
796
+ return out.join('').replace(/\n+$/, '').replace(/\n{3,}/g, '\n\n')
797
+ }
798
+
799
+ export function yjsToHtml(
800
+ fragment: Y.XmlFragment,
801
+ label: string
802
+ ): string {
803
+ const title = escapeHtml(label)
804
+ const bodyParts: string[] = []
805
+ for (const child of fragment.toArray()) {
806
+ if (child instanceof Y.XmlElement) {
807
+ const html = serializeBlockToHtml(child)
808
+ if (html) bodyParts.push(html)
809
+ }
810
+ }
811
+ return `<!DOCTYPE html>
812
+ <html>
813
+ <head><meta charset="utf-8"><title>${title}</title></head>
814
+ <body>
815
+ <h1>${title}</h1>
816
+ ${bodyParts.join('\n')}
817
+ </body>
818
+ </html>
819
+ `
820
+ }