@abraca/convert 2.4.0 → 2.5.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/README.md +28 -0
- package/dist/abracadabra-convert.cjs +91 -62
- package/dist/abracadabra-convert.cjs.map +1 -1
- package/dist/abracadabra-convert.esm.js +91 -62
- package/dist/abracadabra-convert.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/file-blocks/paths.ts +26 -3
- package/src/markdown-to-yjs.ts +44 -10
- package/src/yjs-to-markdown.ts +89 -44
package/package.json
CHANGED
package/src/file-blocks/paths.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// ── Collision-safe path building for FS sync ─────────────────────────────────
|
|
2
2
|
|
|
3
|
+
import * as Y from 'yjs'
|
|
4
|
+
|
|
3
5
|
import type { FsSyncManifest } from './manifest.ts'
|
|
4
6
|
|
|
5
7
|
export interface FsTreeEntry {
|
|
@@ -15,8 +17,12 @@ export interface FsTreeEntry {
|
|
|
15
17
|
* e.g. "My Project!" -> "my-project"
|
|
16
18
|
*/
|
|
17
19
|
export function labelToFilename(label: string): string {
|
|
20
|
+
// Nullish-safe: real trees (kanban cards, server-compacted entries)
|
|
21
|
+
// contain entries with no `label`; buildRelativePath() calls this for
|
|
22
|
+
// a doc AND every ancestor, so one missing label must not throw and
|
|
23
|
+
// abort fs-sync for the whole tree. Empty → 'untitled' (below).
|
|
18
24
|
return (
|
|
19
|
-
label
|
|
25
|
+
String(label ?? '')
|
|
20
26
|
.toLowerCase()
|
|
21
27
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
22
28
|
.replace(/\s+/g, '-')
|
|
@@ -186,8 +192,25 @@ export function simpleHash(str: string): string {
|
|
|
186
192
|
export function getTreeData(treeMap: any): Record<string, FsTreeEntry> {
|
|
187
193
|
const data: Record<string, FsTreeEntry> = {}
|
|
188
194
|
treeMap.forEach((val: any, key: string) => {
|
|
189
|
-
|
|
190
|
-
|
|
195
|
+
// A tree entry may be stored as a nested Y.Map — both from the
|
|
196
|
+
// per-key ⑦ write path (every SDK + cou-sh) and from server-side
|
|
197
|
+
// Yrs compaction. Read it as a plain object (mirror of the
|
|
198
|
+
// provider's `toPlain`); a raw Y.Map would expose
|
|
199
|
+
// label/parentId/order as `undefined` and silently corrupt the
|
|
200
|
+
// FS-sync tree (mass mis-parent / mis-label / spurious delete).
|
|
201
|
+
//
|
|
202
|
+
// Detect Y.Map by DUCK-TYPING, not `instanceof`: a host that wires
|
|
203
|
+
// @abraca/dabra (which bundles its own physical `yjs`) alongside
|
|
204
|
+
// this package would fail an instanceof across the two yjs copies
|
|
205
|
+
// and hand back the raw Y.Map → every doc "untitled". toJSON()
|
|
206
|
+
// works regardless of which yjs constructed the map.
|
|
207
|
+
const isYMap = val instanceof Y.Map
|
|
208
|
+
|| (!!val && typeof val === 'object'
|
|
209
|
+
&& typeof (val as any).toJSON === 'function'
|
|
210
|
+
&& typeof (val as any).get === 'function')
|
|
211
|
+
const plain = isYMap ? (val as any).toJSON() : val
|
|
212
|
+
if (plain && typeof plain === 'object') {
|
|
213
|
+
data[key] = plain as FsTreeEntry
|
|
191
214
|
}
|
|
192
215
|
})
|
|
193
216
|
return data
|
package/src/markdown-to-yjs.ts
CHANGED
|
@@ -166,6 +166,27 @@ interface InlineToken {
|
|
|
166
166
|
attrs?: Record<string, unknown>
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// Re-tokenize the inner text of a wrapping mark (bold/italic/strike) so nested
|
|
170
|
+
// atoms (wikilinks, mentions, code, math) and nested marks survive instead of
|
|
171
|
+
// being captured as a single literal leaf. The wrapping mark's attrs are merged
|
|
172
|
+
// onto every produced child token; object-valued attrs (docLink/mention/badge)
|
|
173
|
+
// are preserved alongside the boolean mark. Terminates because `inner` is
|
|
174
|
+
// strictly shorter than the matched delimiter run.
|
|
175
|
+
function pushNested(
|
|
176
|
+
out: InlineToken[],
|
|
177
|
+
inner: string,
|
|
178
|
+
wrap: Record<string, unknown>,
|
|
179
|
+
): void {
|
|
180
|
+
const children = parseInline(inner)
|
|
181
|
+
if (children.length === 0) {
|
|
182
|
+
out.push({ text: inner, attrs: { ...wrap } })
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
for (const child of children) {
|
|
186
|
+
out.push({ text: child.text, attrs: { ...(child.attrs ?? {}), ...wrap } })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
169
190
|
function parseInline(text: string): InlineToken[] {
|
|
170
191
|
// Strip MDC attribute annotations on inline code: `string`{lang="ts-type"}
|
|
171
192
|
const stripped = text.replace(/\{lang="[^"]*"\}/g, '')
|
|
@@ -214,13 +235,13 @@ function parseInline(text: string): InlineToken[] {
|
|
|
214
235
|
const label = match[9] ?? docId
|
|
215
236
|
tokens.push({ text: label, attrs: { docLink: { docId } } })
|
|
216
237
|
} else if (match[10] !== undefined) {
|
|
217
|
-
tokens
|
|
238
|
+
pushNested(tokens, match[10], { strike: true })
|
|
218
239
|
} else if (match[11] !== undefined) {
|
|
219
|
-
tokens
|
|
240
|
+
pushNested(tokens, match[11], { bold: true })
|
|
220
241
|
} else if (match[12] !== undefined) {
|
|
221
|
-
tokens
|
|
242
|
+
pushNested(tokens, match[12], { italic: true })
|
|
222
243
|
} else if (match[13] !== undefined) {
|
|
223
|
-
tokens
|
|
244
|
+
pushNested(tokens, match[13], { italic: true })
|
|
224
245
|
} else if (match[14] !== undefined) {
|
|
225
246
|
tokens.push({ text: match[14], attrs: { code: true } })
|
|
226
247
|
} else if (match[15] !== undefined && match[16] !== undefined) {
|
|
@@ -807,16 +828,29 @@ function fillTextInto(el: Y.XmlElement, tokens: InlineToken[]): void {
|
|
|
807
828
|
const filtered = tokens.filter(t => t.text.length > 0)
|
|
808
829
|
if (!filtered.length) return
|
|
809
830
|
|
|
810
|
-
//
|
|
811
|
-
|
|
812
|
-
|
|
831
|
+
// Build the child skeleton. A docLink is an inline ATOM NODE, not a text
|
|
832
|
+
// mark: it must be a Y.XmlElement so the y-prosemirror binding materializes
|
|
833
|
+
// it as a `docLink` node (its label is resolved live from the doc tree by
|
|
834
|
+
// the renderer, never stored). Every other token is a Y.XmlText run.
|
|
835
|
+
// Attach all children in one batch first (attach-before-fill), then fill.
|
|
836
|
+
const children: (Y.XmlText | Y.XmlElement)[] = filtered.map((tok) => {
|
|
837
|
+
const dl = tok.attrs?.docLink as { docId?: string } | undefined
|
|
838
|
+
return dl?.docId ? new Y.XmlElement('docLink') : new Y.XmlText()
|
|
839
|
+
})
|
|
840
|
+
el.insert(0, children)
|
|
813
841
|
|
|
814
|
-
// Fill
|
|
842
|
+
// Fill only after attachment — el is already attached to the doc.
|
|
815
843
|
filtered.forEach((tok, i) => {
|
|
844
|
+
const node = children[i]!
|
|
845
|
+
if (node instanceof Y.XmlElement) {
|
|
846
|
+
const dl = tok.attrs!.docLink as { docId: string }
|
|
847
|
+
node.setAttribute('docId', dl.docId)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
816
850
|
if (tok.attrs) {
|
|
817
|
-
|
|
851
|
+
node.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
|
|
818
852
|
} else {
|
|
819
|
-
|
|
853
|
+
node.insert(0, tok.text)
|
|
820
854
|
}
|
|
821
855
|
})
|
|
822
856
|
}
|
package/src/yjs-to-markdown.ts
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import * as Y from 'yjs'
|
|
2
2
|
import type { DocPageMeta } from './types.ts'
|
|
3
3
|
|
|
4
|
+
// ── Cross-yjs-copy node typing ───────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// The serializers must decide "is this a Y.XmlText vs Y.XmlElement"
|
|
7
|
+
// without `instanceof`. A host that wires @abraca/dabra (file: dep) or
|
|
8
|
+
// bundles us through Vite/esbuild dep-prebundling ends up with a SECOND
|
|
9
|
+
// physical copy of yjs; nodes built by that copy are NOT `instanceof`
|
|
10
|
+
// *our* Y.XmlText/Y.XmlElement, so every check silently fails and text
|
|
11
|
+
// and inline atoms (docLink/mention) serialize to NOTHING. Duck-typed
|
|
12
|
+
// predicates work regardless of which yjs constructed the node — the
|
|
13
|
+
// same lesson as getTreeData's `instanceof Y.Map` fix. (`nodeName` is a
|
|
14
|
+
// plain own-property; `toDelta` only exists on Y.XmlText.)
|
|
15
|
+
// An element is anything carrying a string `nodeName` (paragraph,
|
|
16
|
+
// heading, bulletList, AND inline atoms like docLink/mention — atoms
|
|
17
|
+
// have no children but still have a nodeName). Mirrors the original
|
|
18
|
+
// `instanceof Y.XmlElement` intent with NO extra constraint: requiring
|
|
19
|
+
// `.toArray` wrongly excluded childless atom nodes → docLinks dropped.
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
function isXElem(n: any): n is Y.XmlElement {
|
|
22
|
+
return !!n && typeof n.nodeName === 'string'
|
|
23
|
+
}
|
|
24
|
+
// Y.XmlText: no nodeName, has toDelta. (Y.XmlFragment has neither →
|
|
25
|
+
// classified as neither, which is correct — it's only ever the root.)
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
function isXText(n: any): n is Y.XmlText {
|
|
28
|
+
return !!n && typeof n.nodeName !== 'string' && typeof n.toDelta === 'function'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// No fragment normalization is needed any more — the serializers are
|
|
32
|
+
// duck-typed, so a foreign-yjs fragment serializes directly. Kept as an
|
|
33
|
+
// identity so the public entry points don't need to change.
|
|
34
|
+
function localizeFragment(fragment: Y.XmlFragment): Y.XmlFragment {
|
|
35
|
+
return fragment
|
|
36
|
+
}
|
|
37
|
+
|
|
4
38
|
// ── Inline serialization ────────────────────────────────────────────────────
|
|
5
39
|
|
|
6
40
|
function serializeDelta(delta: any[]): string {
|
|
@@ -84,11 +118,17 @@ function serializeDelta(delta: any[]): string {
|
|
|
84
118
|
function serializeInline(el: Y.XmlElement | Y.XmlFragment): string {
|
|
85
119
|
const parts: string[] = []
|
|
86
120
|
for (const child of el.toArray()) {
|
|
87
|
-
if (child
|
|
121
|
+
if (isXText(child)) {
|
|
88
122
|
parts.push(serializeDelta(child.toDelta()))
|
|
89
|
-
} else if (child
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
} else if (isXElem(child)) {
|
|
124
|
+
if (child.nodeName === 'docLink') {
|
|
125
|
+
// Inline doc-link atom node — bare wire form; label is tree-derived.
|
|
126
|
+
const docId = child.getAttribute('docId') ?? ''
|
|
127
|
+
parts.push(`[[${docId}]]`)
|
|
128
|
+
} else {
|
|
129
|
+
// Nested inline element — just get text
|
|
130
|
+
parts.push(serializeInline(child))
|
|
131
|
+
}
|
|
92
132
|
}
|
|
93
133
|
}
|
|
94
134
|
return parts.join('')
|
|
@@ -97,7 +137,7 @@ function serializeInline(el: Y.XmlElement | Y.XmlFragment): string {
|
|
|
97
137
|
// ── Block serialization ─────────────────────────────────────────────────────
|
|
98
138
|
|
|
99
139
|
function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
|
|
100
|
-
if (el
|
|
140
|
+
if (isXText(el)) {
|
|
101
141
|
return serializeDelta(el.toDelta())
|
|
102
142
|
}
|
|
103
143
|
|
|
@@ -137,7 +177,7 @@ function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
|
|
|
137
177
|
case 'blockquote': {
|
|
138
178
|
const lines: string[] = []
|
|
139
179
|
for (const child of el.toArray()) {
|
|
140
|
-
if (child
|
|
180
|
+
if (isXElem(child)) {
|
|
141
181
|
const text = serializeBlock(child)
|
|
142
182
|
for (const line of text.split('\n')) {
|
|
143
183
|
lines.push(`> ${line}`)
|
|
@@ -231,7 +271,7 @@ function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
|
|
|
231
271
|
|
|
232
272
|
case 'cardGroup': {
|
|
233
273
|
const cards = el.toArray()
|
|
234
|
-
.filter((c): c is Y.XmlElement => c
|
|
274
|
+
.filter((c): c is Y.XmlElement => isXElem(c))
|
|
235
275
|
.map(c => serializeBlock(c))
|
|
236
276
|
.join('\n\n')
|
|
237
277
|
return `::card-group\n${cards}\n::`
|
|
@@ -239,7 +279,7 @@ function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
|
|
|
239
279
|
|
|
240
280
|
case 'codeCollapse': {
|
|
241
281
|
const code = el.toArray()
|
|
242
|
-
.filter((c): c is Y.XmlElement => c
|
|
282
|
+
.filter((c): c is Y.XmlElement => isXElem(c) && c.nodeName === 'codeBlock')
|
|
243
283
|
.map(c => serializeBlock(c))
|
|
244
284
|
.join('\n\n')
|
|
245
285
|
return `::code-collapse\n${code}\n::`
|
|
@@ -247,14 +287,14 @@ function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
|
|
|
247
287
|
|
|
248
288
|
case 'codeGroup': {
|
|
249
289
|
const code = el.toArray()
|
|
250
|
-
.filter((c): c is Y.XmlElement => c
|
|
290
|
+
.filter((c): c is Y.XmlElement => isXElem(c) && c.nodeName === 'codeBlock')
|
|
251
291
|
.map(c => serializeBlock(c))
|
|
252
292
|
.join('\n\n')
|
|
253
293
|
return `::code-group\n${code}\n::`
|
|
254
294
|
}
|
|
255
295
|
|
|
256
296
|
case 'codePreview': {
|
|
257
|
-
const children = el.toArray().filter((c): c is Y.XmlElement => c
|
|
297
|
+
const children = el.toArray().filter((c): c is Y.XmlElement => isXElem(c))
|
|
258
298
|
const nonCode = children.filter(c => c.nodeName !== 'codeBlock').map(c => serializeBlock(c)).join('\n\n')
|
|
259
299
|
const code = children.filter(c => c.nodeName === 'codeBlock').map(c => serializeBlock(c)).join('\n\n')
|
|
260
300
|
const parts = [nonCode]
|
|
@@ -284,7 +324,7 @@ function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
|
|
|
284
324
|
|
|
285
325
|
case 'fieldGroup': {
|
|
286
326
|
const fields = el.toArray()
|
|
287
|
-
.filter((c): c is Y.XmlElement => c
|
|
327
|
+
.filter((c): c is Y.XmlElement => isXElem(c))
|
|
288
328
|
.map(c => serializeBlock(c))
|
|
289
329
|
.join('\n\n')
|
|
290
330
|
return `::field-group\n${fields}\n::`
|
|
@@ -299,10 +339,10 @@ function serializeBlock(el: Y.XmlElement | Y.XmlText, indent = ''): string {
|
|
|
299
339
|
function serializeChildren(el: Y.XmlElement | Y.XmlFragment): string {
|
|
300
340
|
const blocks: string[] = []
|
|
301
341
|
for (const child of el.toArray()) {
|
|
302
|
-
if (child
|
|
342
|
+
if (isXElem(child)) {
|
|
303
343
|
const text = serializeBlock(child)
|
|
304
344
|
if (text) blocks.push(text)
|
|
305
|
-
} else if (child
|
|
345
|
+
} else if (isXText(child)) {
|
|
306
346
|
const text = serializeDelta(child.toDelta())
|
|
307
347
|
if (text) blocks.push(text)
|
|
308
348
|
}
|
|
@@ -314,12 +354,12 @@ function serializeListItems(el: Y.XmlElement, type: 'bullet' | 'ordered', indent
|
|
|
314
354
|
const lines: string[] = []
|
|
315
355
|
let counter = 1
|
|
316
356
|
for (const child of el.toArray()) {
|
|
317
|
-
if (!(child
|
|
357
|
+
if (!(isXElem(child)) || child.nodeName !== 'listItem') continue
|
|
318
358
|
const prefix = type === 'bullet' ? '- ' : `${counter++}. `
|
|
319
359
|
// A listItem may contain paragraphs and nested lists
|
|
320
360
|
const subParts: string[] = []
|
|
321
361
|
for (const sub of child.toArray()) {
|
|
322
|
-
if (!(sub
|
|
362
|
+
if (!(isXElem(sub))) continue
|
|
323
363
|
if (sub.nodeName === 'bulletList') {
|
|
324
364
|
subParts.push(serializeListItems(sub, 'bullet', indent + ' '))
|
|
325
365
|
} else if (sub.nodeName === 'orderedList') {
|
|
@@ -344,14 +384,14 @@ function serializeListItems(el: Y.XmlElement, type: 'bullet' | 'ordered', indent
|
|
|
344
384
|
function serializeTaskList(el: Y.XmlElement, indent: string): string {
|
|
345
385
|
const lines: string[] = []
|
|
346
386
|
for (const child of el.toArray()) {
|
|
347
|
-
if (!(child
|
|
387
|
+
if (!(isXElem(child)) || child.nodeName !== 'taskItem') continue
|
|
348
388
|
const checked: unknown = child.getAttribute('checked')
|
|
349
389
|
const marker = (checked === true || checked === 'true') ? '[x]' : '[ ]'
|
|
350
390
|
|
|
351
391
|
let header = ''
|
|
352
392
|
const nestedParts: string[] = []
|
|
353
393
|
for (const sub of child.toArray()) {
|
|
354
|
-
if (!(sub
|
|
394
|
+
if (!(isXElem(sub))) continue
|
|
355
395
|
if (sub.nodeName === 'paragraph' && header === '') {
|
|
356
396
|
header = serializeInline(sub)
|
|
357
397
|
}
|
|
@@ -377,7 +417,7 @@ function serializeTaskList(el: Y.XmlElement, indent: string): string {
|
|
|
377
417
|
|
|
378
418
|
function getCodeBlockText(el: Y.XmlElement): string {
|
|
379
419
|
for (const child of el.toArray()) {
|
|
380
|
-
if (child
|
|
420
|
+
if (isXText(child)) {
|
|
381
421
|
return child.toString()
|
|
382
422
|
}
|
|
383
423
|
}
|
|
@@ -385,17 +425,17 @@ function getCodeBlockText(el: Y.XmlElement): string {
|
|
|
385
425
|
}
|
|
386
426
|
|
|
387
427
|
function serializeTable(el: Y.XmlElement): string {
|
|
388
|
-
const rows = el.toArray().filter((c): c is Y.XmlElement => c
|
|
428
|
+
const rows = el.toArray().filter((c): c is Y.XmlElement => isXElem(c))
|
|
389
429
|
if (!rows.length) return ''
|
|
390
430
|
|
|
391
431
|
const serializedRows: string[][] = []
|
|
392
432
|
for (const row of rows) {
|
|
393
433
|
const cells = row.toArray()
|
|
394
|
-
.filter((c): c is Y.XmlElement => c
|
|
434
|
+
.filter((c): c is Y.XmlElement => isXElem(c))
|
|
395
435
|
.map((cell) => {
|
|
396
436
|
// Cell contains paragraphs — join their text
|
|
397
437
|
return cell.toArray()
|
|
398
|
-
.filter((c): c is Y.XmlElement => c
|
|
438
|
+
.filter((c): c is Y.XmlElement => isXElem(c))
|
|
399
439
|
.map(c => serializeInline(c))
|
|
400
440
|
.join(' ')
|
|
401
441
|
})
|
|
@@ -424,7 +464,7 @@ function serializeSlottedContainer(
|
|
|
424
464
|
childName: string,
|
|
425
465
|
slotPrefix: string
|
|
426
466
|
): string {
|
|
427
|
-
const items = el.toArray().filter((c): c is Y.XmlElement => c
|
|
467
|
+
const items = el.toArray().filter((c): c is Y.XmlElement => isXElem(c) && c.nodeName === childName)
|
|
428
468
|
const slots = items.map((item) => {
|
|
429
469
|
const label = item.getAttribute('label') ?? ''
|
|
430
470
|
const icon = item.getAttribute('icon') ?? ''
|
|
@@ -493,7 +533,7 @@ function escapeYaml(s: string): string {
|
|
|
493
533
|
// ── HTML serialization ──────────────────────────────────────────────────────
|
|
494
534
|
|
|
495
535
|
function serializeBlockToHtml(el: Y.XmlElement | Y.XmlText): string {
|
|
496
|
-
if (el
|
|
536
|
+
if (isXText(el)) {
|
|
497
537
|
return serializeDeltaToHtml(el.toDelta())
|
|
498
538
|
}
|
|
499
539
|
|
|
@@ -521,7 +561,9 @@ function serializeBlockToHtml(el: Y.XmlElement | Y.XmlText): string {
|
|
|
521
561
|
return `<ul>${serializeTaskListHtml(el)}</ul>`
|
|
522
562
|
|
|
523
563
|
case 'codeBlock': {
|
|
524
|
-
|
|
564
|
+
// Restrict the language to a safe identifier charset — it is interpolated
|
|
565
|
+
// into a class attribute below; an unsanitised value is a stored-XSS sink.
|
|
566
|
+
const lang = (el.getAttribute('language') ?? '').replace(/[^\w.-]/g, '')
|
|
525
567
|
const code = escapeHtml(getCodeBlockText(el))
|
|
526
568
|
return lang
|
|
527
569
|
? `<pre><code class="language-${lang}">${code}</code></pre>`
|
|
@@ -530,7 +572,7 @@ function serializeBlockToHtml(el: Y.XmlElement | Y.XmlText): string {
|
|
|
530
572
|
|
|
531
573
|
case 'blockquote': {
|
|
532
574
|
const inner = el.toArray()
|
|
533
|
-
.filter((c): c is Y.XmlElement => c
|
|
575
|
+
.filter((c): c is Y.XmlElement => isXElem(c))
|
|
534
576
|
.map(c => serializeBlockToHtml(c))
|
|
535
577
|
.join('\n')
|
|
536
578
|
return `<blockquote>\n${inner}\n</blockquote>`
|
|
@@ -558,8 +600,8 @@ function serializeBlockToHtml(el: Y.XmlElement | Y.XmlText): string {
|
|
|
558
600
|
default: {
|
|
559
601
|
// Generic wrapper for MDC blocks etc.
|
|
560
602
|
const inner = el.toArray()
|
|
561
|
-
.filter((c): c is Y.XmlElement | Y.XmlText => c
|
|
562
|
-
.map(c => c
|
|
603
|
+
.filter((c): c is Y.XmlElement | Y.XmlText => isXElem(c) || isXText(c))
|
|
604
|
+
.map(c => isXElem(c) ? serializeBlockToHtml(c) : serializeDeltaToHtml(c.toDelta()))
|
|
563
605
|
.join('\n')
|
|
564
606
|
return `<div data-type="${name}">\n${inner}\n</div>`
|
|
565
607
|
}
|
|
@@ -569,9 +611,9 @@ function serializeBlockToHtml(el: Y.XmlElement | Y.XmlText): string {
|
|
|
569
611
|
function serializeInlineHtml(el: Y.XmlElement | Y.XmlFragment): string {
|
|
570
612
|
const parts: string[] = []
|
|
571
613
|
for (const child of el.toArray()) {
|
|
572
|
-
if (child
|
|
614
|
+
if (isXText(child)) {
|
|
573
615
|
parts.push(serializeDeltaToHtml(child.toDelta()))
|
|
574
|
-
} else if (child
|
|
616
|
+
} else if (isXElem(child)) {
|
|
575
617
|
parts.push(serializeInlineHtml(child))
|
|
576
618
|
}
|
|
577
619
|
}
|
|
@@ -599,34 +641,34 @@ function serializeDeltaToHtml(delta: any[]): string {
|
|
|
599
641
|
|
|
600
642
|
function serializeListHtml(el: Y.XmlElement): string {
|
|
601
643
|
return el.toArray()
|
|
602
|
-
.filter((c): c is Y.XmlElement => c
|
|
603
|
-
.map(li => `<li>${li.toArray().filter((c): c is Y.XmlElement => c
|
|
644
|
+
.filter((c): c is Y.XmlElement => isXElem(c) && c.nodeName === 'listItem')
|
|
645
|
+
.map(li => `<li>${li.toArray().filter((c): c is Y.XmlElement => isXElem(c)).map(c => serializeBlockToHtml(c)).join('')}</li>`)
|
|
604
646
|
.join('\n')
|
|
605
647
|
}
|
|
606
648
|
|
|
607
649
|
function serializeTaskListHtml(el: Y.XmlElement): string {
|
|
608
650
|
return el.toArray()
|
|
609
|
-
.filter((c): c is Y.XmlElement => c
|
|
651
|
+
.filter((c): c is Y.XmlElement => isXElem(c) && c.nodeName === 'taskItem')
|
|
610
652
|
.map((ti) => {
|
|
611
653
|
const rawChecked: unknown = ti.getAttribute('checked')
|
|
612
654
|
const checked = rawChecked === true || rawChecked === 'true'
|
|
613
|
-
const text = ti.toArray().filter((c): c is Y.XmlElement => c
|
|
655
|
+
const text = ti.toArray().filter((c): c is Y.XmlElement => isXElem(c)).map(c => serializeInlineHtml(c)).join('')
|
|
614
656
|
return `<li><input type="checkbox"${checked ? ' checked' : ''} disabled> ${text}</li>`
|
|
615
657
|
})
|
|
616
658
|
.join('\n')
|
|
617
659
|
}
|
|
618
660
|
|
|
619
661
|
function serializeTableHtml(el: Y.XmlElement): string {
|
|
620
|
-
const rows = el.toArray().filter((c): c is Y.XmlElement => c
|
|
662
|
+
const rows = el.toArray().filter((c): c is Y.XmlElement => isXElem(c))
|
|
621
663
|
if (!rows.length) return ''
|
|
622
664
|
|
|
623
665
|
const htmlRows = rows.map((row, ri) => {
|
|
624
666
|
const tag = ri === 0 ? 'th' : 'td'
|
|
625
667
|
const cells = row.toArray()
|
|
626
|
-
.filter((c): c is Y.XmlElement => c
|
|
668
|
+
.filter((c): c is Y.XmlElement => isXElem(c))
|
|
627
669
|
.map((cell) => {
|
|
628
670
|
const inner = cell.toArray()
|
|
629
|
-
.filter((c): c is Y.XmlElement => c
|
|
671
|
+
.filter((c): c is Y.XmlElement => isXElem(c))
|
|
630
672
|
.map(c => serializeInlineHtml(c))
|
|
631
673
|
.join('')
|
|
632
674
|
return `<${tag}>${inner}</${tag}>`
|
|
@@ -650,6 +692,7 @@ export function yjsToMarkdown(
|
|
|
650
692
|
meta?: DocPageMeta,
|
|
651
693
|
type?: string
|
|
652
694
|
): string {
|
|
695
|
+
fragment = localizeFragment(fragment)
|
|
653
696
|
// The title can come from three places. We honour where the parser
|
|
654
697
|
// captured it so the wire form round-trips byte-stably:
|
|
655
698
|
// - 'h1' → emit `# title` as the first body block
|
|
@@ -697,7 +740,7 @@ function readDocumentMeta(fragment: Y.XmlFragment): { meta: DocPageMeta, type?:
|
|
|
697
740
|
const meta: DocPageMeta = {}
|
|
698
741
|
let type: string | undefined
|
|
699
742
|
for (const child of fragment.toArray()) {
|
|
700
|
-
if (!(child
|
|
743
|
+
if (!(isXElem(child)) || child.nodeName !== 'documentMeta') continue
|
|
701
744
|
const attrs = child.getAttributes() as Record<string, unknown>
|
|
702
745
|
for (const k of Object.keys(attrs)) {
|
|
703
746
|
const v = attrs[k]
|
|
@@ -718,8 +761,8 @@ interface HeaderInfo { text: string, source?: 'h1' | 'frontmatter' }
|
|
|
718
761
|
|
|
719
762
|
function readDocumentHeader(fragment: Y.XmlFragment): HeaderInfo {
|
|
720
763
|
for (const child of fragment.toArray()) {
|
|
721
|
-
if (!(child
|
|
722
|
-
const text = child.toArray().find(c => c
|
|
764
|
+
if (!(isXElem(child)) || child.nodeName !== 'documentHeader') continue
|
|
765
|
+
const text = child.toArray().find(c => isXText(c)) as Y.XmlText | undefined
|
|
723
766
|
const src: unknown = child.getAttribute('titleSource')
|
|
724
767
|
const source = src === 'h1' || src === 'frontmatter' ? src : undefined
|
|
725
768
|
return { text: text ? text.toString() : '', source }
|
|
@@ -730,7 +773,7 @@ function readDocumentHeader(fragment: Y.XmlFragment): HeaderInfo {
|
|
|
730
773
|
function collectBodyBlocks(fragment: Y.XmlFragment): Y.XmlElement[] {
|
|
731
774
|
const out: Y.XmlElement[] = []
|
|
732
775
|
for (const child of fragment.toArray()) {
|
|
733
|
-
if (!(child
|
|
776
|
+
if (!(isXElem(child))) continue
|
|
734
777
|
if (child.nodeName === 'documentHeader' || child.nodeName === 'documentMeta') continue
|
|
735
778
|
out.push(child)
|
|
736
779
|
}
|
|
@@ -770,9 +813,10 @@ function isMetaEmpty(meta: DocPageMeta | undefined): boolean {
|
|
|
770
813
|
* accessibility tooling, search indexing, and snippet previews.
|
|
771
814
|
*/
|
|
772
815
|
export function yjsToPlainText(fragment: Y.XmlFragment): string {
|
|
816
|
+
fragment = localizeFragment(fragment)
|
|
773
817
|
const out: string[] = []
|
|
774
818
|
const visit = (node: Y.XmlElement | Y.XmlText): void => {
|
|
775
|
-
if (node
|
|
819
|
+
if (isXText(node)) {
|
|
776
820
|
out.push(node.toString())
|
|
777
821
|
return
|
|
778
822
|
}
|
|
@@ -783,7 +827,7 @@ export function yjsToPlainText(fragment: Y.XmlFragment): string {
|
|
|
783
827
|
return
|
|
784
828
|
}
|
|
785
829
|
for (const child of node.toArray()) {
|
|
786
|
-
if (child
|
|
830
|
+
if (isXText(child) || isXElem(child)) visit(child)
|
|
787
831
|
}
|
|
788
832
|
// Insert a single newline after block-level elements so paragraphs
|
|
789
833
|
// and headings produce a readable plain-text form.
|
|
@@ -791,7 +835,7 @@ export function yjsToPlainText(fragment: Y.XmlFragment): string {
|
|
|
791
835
|
out.push('\n')
|
|
792
836
|
}
|
|
793
837
|
for (const child of fragment.toArray()) {
|
|
794
|
-
if (child
|
|
838
|
+
if (isXText(child) || isXElem(child)) visit(child)
|
|
795
839
|
}
|
|
796
840
|
return out.join('').replace(/\n+$/, '').replace(/\n{3,}/g, '\n\n')
|
|
797
841
|
}
|
|
@@ -800,10 +844,11 @@ export function yjsToHtml(
|
|
|
800
844
|
fragment: Y.XmlFragment,
|
|
801
845
|
label: string
|
|
802
846
|
): string {
|
|
847
|
+
fragment = localizeFragment(fragment)
|
|
803
848
|
const title = escapeHtml(label)
|
|
804
849
|
const bodyParts: string[] = []
|
|
805
850
|
for (const child of fragment.toArray()) {
|
|
806
|
-
if (child
|
|
851
|
+
if (isXElem(child)) {
|
|
807
852
|
const html = serializeBlockToHtml(child)
|
|
808
853
|
if (html) bodyParts.push(html)
|
|
809
854
|
}
|