@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.
- package/dist/abracadabra-convert.cjs +3237 -0
- package/dist/abracadabra-convert.cjs.map +1 -0
- package/dist/abracadabra-convert.esm.js +3163 -0
- package/dist/abracadabra-convert.esm.js.map +1 -0
- package/dist/index.d.ts +356 -0
- package/package.json +41 -0
- package/src/diff.ts +302 -0
- package/src/file-blocks/manifest.ts +169 -0
- package/src/file-blocks/paths.ts +207 -0
- package/src/html-to-yjs.ts +322 -0
- package/src/index.ts +103 -0
- package/src/markdown-to-yjs.ts +1208 -0
- package/src/spec/index.ts +7 -0
- package/src/spec/marks.ts +92 -0
- package/src/spec/nodes.ts +333 -0
- package/src/spec/universal-meta.ts +147 -0
- package/src/types.ts +89 -0
- package/src/yjs-to-markdown.ts +820 -0
|
@@ -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 `${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|