@abraca/convert 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/convert",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Bullet-proof Markdown ↔ Yjs/TipTap round-trip — canonical converter shared by cou-sh, abracadabra-nuxt, and @abraca/mcp.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- if (val && typeof val === 'object') {
190
- data[key] = val as FsTreeEntry
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
@@ -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.push({ text: match[10], attrs: { strike: true } })
238
+ pushNested(tokens, match[10], { strike: true })
218
239
  } else if (match[11] !== undefined) {
219
- tokens.push({ text: match[11], attrs: { bold: true } })
240
+ pushNested(tokens, match[11], { bold: true })
220
241
  } else if (match[12] !== undefined) {
221
- tokens.push({ text: match[12], attrs: { italic: true } })
242
+ pushNested(tokens, match[12], { italic: true })
222
243
  } else if (match[13] !== undefined) {
223
- tokens.push({ text: match[13], attrs: { italic: true } })
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
- // Create empty text nodes and attach them to el in one batch
811
- const xtNodes = filtered.map(() => new Y.XmlText())
812
- el.insert(0, xtNodes)
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 text only after attachment — el is already attached to the doc
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
- xtNodes[i]!.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
851
+ node.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
818
852
  } else {
819
- xtNodes[i]!.insert(0, tok.text)
853
+ node.insert(0, tok.text)
820
854
  }
821
855
  })
822
856
  }
@@ -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 instanceof Y.XmlText) {
121
+ if (isXText(child)) {
88
122
  parts.push(serializeDelta(child.toDelta()))
89
- } else if (child instanceof Y.XmlElement) {
90
- // Nested inline element — just get text
91
- parts.push(serializeInline(child))
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 instanceof Y.XmlText) {
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 instanceof Y.XmlElement) {
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement && c.nodeName === 'codeBlock')
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 instanceof Y.XmlElement && c.nodeName === 'codeBlock')
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement) {
342
+ if (isXElem(child)) {
303
343
  const text = serializeBlock(child)
304
344
  if (text) blocks.push(text)
305
- } else if (child instanceof Y.XmlText) {
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 instanceof Y.XmlElement) || child.nodeName !== 'listItem') continue
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 instanceof Y.XmlElement)) continue
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 instanceof Y.XmlElement) || child.nodeName !== 'taskItem') continue
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 instanceof Y.XmlElement)) continue
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 instanceof Y.XmlText) {
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement && c.nodeName === childName)
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 instanceof Y.XmlText) {
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
- const lang = el.getAttribute('language') ?? ''
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement || c instanceof Y.XmlText)
562
- .map(c => c instanceof Y.XmlElement ? serializeBlockToHtml(c) : serializeDeltaToHtml(c.toDelta()))
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 instanceof Y.XmlText) {
614
+ if (isXText(child)) {
573
615
  parts.push(serializeDeltaToHtml(child.toDelta()))
574
- } else if (child instanceof Y.XmlElement) {
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 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>`)
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 instanceof Y.XmlElement && c.nodeName === 'taskItem')
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 instanceof Y.XmlElement).map(c => serializeInlineHtml(c)).join('')
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement)
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 instanceof Y.XmlElement) || child.nodeName !== 'documentMeta') continue
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 instanceof Y.XmlElement) || child.nodeName !== 'documentHeader') continue
722
- const text = child.toArray().find(c => c instanceof Y.XmlText) as Y.XmlText | undefined
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 instanceof Y.XmlElement)) continue
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 instanceof Y.XmlText) {
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 instanceof Y.XmlText || child instanceof Y.XmlElement) visit(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 instanceof Y.XmlText || child instanceof Y.XmlElement) visit(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 instanceof Y.XmlElement) {
851
+ if (isXElem(child)) {
807
852
  const html = serializeBlockToHtml(child)
808
853
  if (html) bodyParts.push(html)
809
854
  }