@growth-labs/cms 0.5.17 → 0.5.19

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.
Files changed (72) hide show
  1. package/README.md +66 -0
  2. package/dist/engine/published-content.js +1 -1
  3. package/dist/engine/published-content.js.map +1 -1
  4. package/dist/engine/publisher.d.ts +6 -0
  5. package/dist/engine/publisher.d.ts.map +1 -1
  6. package/dist/engine/publisher.js +16 -7
  7. package/dist/engine/publisher.js.map +1 -1
  8. package/dist/engine/revisions.d.ts.map +1 -1
  9. package/dist/engine/revisions.js +1 -0
  10. package/dist/engine/revisions.js.map +1 -1
  11. package/dist/routes/content.d.ts.map +1 -1
  12. package/dist/routes/content.js +19 -1
  13. package/dist/routes/content.js.map +1 -1
  14. package/dist/schema/index.d.ts +1 -0
  15. package/dist/schema/index.d.ts.map +1 -1
  16. package/dist/schema/index.js +1 -0
  17. package/dist/schema/index.js.map +1 -1
  18. package/dist/schema/migrations.d.ts.map +1 -1
  19. package/dist/schema/migrations.js +7 -0
  20. package/dist/schema/migrations.js.map +1 -1
  21. package/dist/schema/portable-text.d.ts +52 -0
  22. package/dist/schema/portable-text.d.ts.map +1 -0
  23. package/dist/schema/portable-text.js +94 -0
  24. package/dist/schema/portable-text.js.map +1 -0
  25. package/dist/schema/types.d.ts +2 -0
  26. package/dist/schema/types.d.ts.map +1 -1
  27. package/dist/schema/types.js.map +1 -1
  28. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  29. package/dist/ui/editor/ContentForm.js +5 -3
  30. package/dist/ui/editor/ContentForm.js.map +1 -1
  31. package/dist/ui/editor/Rte.d.ts +12 -3
  32. package/dist/ui/editor/Rte.d.ts.map +1 -1
  33. package/dist/ui/editor/Rte.js +91 -10
  34. package/dist/ui/editor/Rte.js.map +1 -1
  35. package/dist/ui/editor/blocks.d.ts +17 -0
  36. package/dist/ui/editor/blocks.d.ts.map +1 -0
  37. package/dist/ui/editor/blocks.js +113 -0
  38. package/dist/ui/editor/blocks.js.map +1 -0
  39. package/dist/ui/editor/content-payload.d.ts +2 -0
  40. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  41. package/dist/ui/editor/content-payload.js +1 -0
  42. package/dist/ui/editor/content-payload.js.map +1 -1
  43. package/dist/ui/editor/extensions.d.ts +1 -1
  44. package/dist/ui/editor/extensions.d.ts.map +1 -1
  45. package/dist/ui/editor/extensions.js +9 -0
  46. package/dist/ui/editor/extensions.js.map +1 -1
  47. package/dist/ui/editor/portable-text.d.ts +7 -0
  48. package/dist/ui/editor/portable-text.d.ts.map +1 -0
  49. package/dist/ui/editor/portable-text.js +517 -0
  50. package/dist/ui/editor/portable-text.js.map +1 -0
  51. package/dist/ui/editor/serialize.d.ts.map +1 -1
  52. package/dist/ui/editor/serialize.js +195 -5
  53. package/dist/ui/editor/serialize.js.map +1 -1
  54. package/dist/ui/styles/broadsheet.css +21 -0
  55. package/migrations/0024_article_portable_text.sql +6 -0
  56. package/package.json +2 -1
  57. package/src/engine/published-content.ts +1 -1
  58. package/src/engine/publisher.ts +22 -5
  59. package/src/engine/revisions.ts +2 -0
  60. package/src/routes/content.ts +20 -1
  61. package/src/schema/index.ts +8 -0
  62. package/src/schema/migrations.ts +7 -0
  63. package/src/schema/portable-text.ts +118 -0
  64. package/src/schema/types.ts +2 -0
  65. package/src/ui/editor/ContentForm.tsx +8 -2
  66. package/src/ui/editor/Rte.tsx +120 -10
  67. package/src/ui/editor/blocks.ts +140 -0
  68. package/src/ui/editor/content-payload.ts +3 -0
  69. package/src/ui/editor/extensions.ts +9 -0
  70. package/src/ui/editor/portable-text.ts +582 -0
  71. package/src/ui/editor/serialize.ts +207 -5
  72. package/src/ui/styles/broadsheet.css +21 -0
@@ -0,0 +1,582 @@
1
+ // src/ui/editor/portable-text.ts
2
+ // PURE Tiptap doc↔Portable Text serializer pair.
3
+ // Operates exclusively on the ProseMirror JSONContent shape — no Editor,
4
+ // no EditorView, no DOM, fully testable in vitest (Node env).
5
+ //
6
+ // Portable Text is the durable store (schema/portable-text.ts documents the
7
+ // version 1 vocabulary); markdown stays a derived view produced by
8
+ // serialize.ts from the same Tiptap doc. Both serializers here are total over
9
+ // the editor's node set — an unknown node degrades to its text content on the
10
+ // way out, and an unknown `_type` throws on the way in so a vocabulary drift
11
+ // fails loudly instead of silently dropping content.
12
+ //
13
+ // Mark mapping (Tiptap ↔ Portable Text):
14
+ // bold ↔ strong, italic ↔ em, strike ↔ strike-through, code ↔ code,
15
+ // link ↔ markDef annotation { _type: 'link', href, title?, target? }
16
+ // Lists are stored FLAT (listItem + level per block) per the Portable Text
17
+ // convention; nesting is reconstructed on load. Hard breaks are stored as
18
+ // newlines inside span text.
19
+
20
+ import type { JSONContent } from '@tiptap/core'
21
+ import type { PortableTextObject } from '../../schema/portable-text.js'
22
+ import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
23
+
24
+ type KeyGenerator = () => string
25
+
26
+ /** Deterministic 12-hex key sequence: identical docs serialize identically. */
27
+ function createKeyGenerator(): KeyGenerator {
28
+ let n = 0
29
+ return () => {
30
+ n += 1
31
+ return n.toString(16).padStart(12, '0')
32
+ }
33
+ }
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // docToPortableText — JSONContent → PortableTextObject[]
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ const DECORATOR_BY_TIPTAP_MARK: Record<string, string> = {
40
+ bold: 'strong',
41
+ italic: 'em',
42
+ strike: 'strike-through',
43
+ code: 'code',
44
+ }
45
+
46
+ interface MarkDef {
47
+ _key: string
48
+ _type: string
49
+ [key: string]: unknown
50
+ }
51
+
52
+ /** Serialise a Tiptap JSONContent document to a Portable Text array. */
53
+ export function docToPortableText(doc: JSONContent): PortableTextObject[] {
54
+ const normalized = normalizeBoundaryMarks(doc)
55
+ const nextKey = createKeyGenerator()
56
+ return serializeBlocks(normalized.content ?? [], nextKey)
57
+ }
58
+
59
+ function serializeBlocks(nodes: JSONContent[], nextKey: KeyGenerator): PortableTextObject[] {
60
+ const out: PortableTextObject[] = []
61
+ for (const node of nodes) serializeBlockNode(node, out, nextKey, null)
62
+ return out
63
+ }
64
+
65
+ interface ListContext {
66
+ kind: 'bullet' | 'number'
67
+ level: number
68
+ }
69
+
70
+ function serializeBlockNode(
71
+ node: JSONContent,
72
+ out: PortableTextObject[],
73
+ nextKey: KeyGenerator,
74
+ list: ListContext | null,
75
+ ): void {
76
+ switch (node.type ?? 'paragraph') {
77
+ case 'paragraph':
78
+ out.push(buildTextBlock('normal', node.content ?? [], nextKey, list))
79
+ return
80
+ case 'heading': {
81
+ const level = (node.attrs?.level as number) ?? 2
82
+ out.push(buildTextBlock(`h${level}`, node.content ?? [], nextKey, list))
83
+ return
84
+ }
85
+ case 'blockquote': {
86
+ for (const child of node.content ?? []) {
87
+ if (child.type === 'paragraph') {
88
+ out.push(buildTextBlock('blockquote', child.content ?? [], nextKey, list))
89
+ } else {
90
+ serializeBlockNode(child, out, nextKey, list)
91
+ }
92
+ }
93
+ return
94
+ }
95
+ case 'bulletList':
96
+ serializeList(node, out, nextKey, 'bullet', list ? list.level + 1 : 1)
97
+ return
98
+ case 'orderedList':
99
+ serializeList(node, out, nextKey, 'number', list ? list.level + 1 : 1)
100
+ return
101
+ case 'codeBlock': {
102
+ const code = (node.content ?? []).map((child) => child.text ?? '').join('')
103
+ out.push({
104
+ _type: 'code',
105
+ _key: nextKey(),
106
+ language: (node.attrs?.language as string | null) ?? null,
107
+ code,
108
+ })
109
+ return
110
+ }
111
+ case 'image':
112
+ out.push(imageObject(node, nextKey))
113
+ return
114
+ case 'tweetEmbed':
115
+ out.push({
116
+ _type: 'tweetEmbed',
117
+ _key: nextKey(),
118
+ url: (node.attrs?.url as string) ?? '',
119
+ })
120
+ return
121
+ case 'horizontalRule':
122
+ out.push({ _type: 'horizontalRule', _key: nextKey() })
123
+ return
124
+ case 'table':
125
+ out.push(serializeTable(node, nextKey))
126
+ return
127
+ case 'callout':
128
+ out.push({
129
+ _type: 'callout',
130
+ _key: nextKey(),
131
+ tone: (node.attrs?.tone as string) ?? 'note',
132
+ content: serializeBlocks(node.content ?? [], nextKey),
133
+ })
134
+ return
135
+ case 'pullQuote':
136
+ out.push({
137
+ _type: 'pullQuote',
138
+ _key: nextKey(),
139
+ attribution: (node.attrs?.attribution as string | null) ?? null,
140
+ content: serializeBlocks(node.content ?? [], nextKey),
141
+ })
142
+ return
143
+ case 'generatedFaq':
144
+ out.push({
145
+ _type: 'generatedFaq',
146
+ _key: nextKey(),
147
+ markdown: (node.attrs?.markdown as string) ?? '',
148
+ })
149
+ return
150
+ default: {
151
+ // Unknown block: keep its text content rather than dropping it.
152
+ const inline = node.content ?? []
153
+ if (inline.length > 0) out.push(buildTextBlock('normal', inline, nextKey, list))
154
+ }
155
+ }
156
+ }
157
+
158
+ function serializeList(
159
+ node: JSONContent,
160
+ out: PortableTextObject[],
161
+ nextKey: KeyGenerator,
162
+ kind: 'bullet' | 'number',
163
+ level: number,
164
+ ): void {
165
+ for (const item of node.content ?? []) {
166
+ for (const child of item.content ?? []) {
167
+ if (child.type === 'paragraph') {
168
+ out.push(buildTextBlock('normal', child.content ?? [], nextKey, { kind, level }))
169
+ } else if (child.type === 'bulletList') {
170
+ serializeList(child, out, nextKey, 'bullet', level + 1)
171
+ } else if (child.type === 'orderedList') {
172
+ serializeList(child, out, nextKey, 'number', level + 1)
173
+ } else {
174
+ serializeBlockNode(child, out, nextKey, { kind, level })
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ function imageObject(node: JSONContent, nextKey: KeyGenerator): PortableTextObject {
181
+ return {
182
+ _type: 'image',
183
+ _key: nextKey(),
184
+ src: (node.attrs?.src as string) ?? '',
185
+ alt: (node.attrs?.alt as string | null) ?? '',
186
+ title: (node.attrs?.title as string | null) ?? null,
187
+ }
188
+ }
189
+
190
+ function buildTextBlock(
191
+ style: string,
192
+ inline: JSONContent[],
193
+ nextKey: KeyGenerator,
194
+ list: ListContext | null,
195
+ ): PortableTextObject {
196
+ const markDefs: MarkDef[] = []
197
+ type PendingSpan = { marks: string[]; text: string }
198
+ const pending: Array<PendingSpan | PortableTextObject> = []
199
+
200
+ const pushText = (text: string, marks: string[]) => {
201
+ const previous = pending[pending.length - 1]
202
+ if (
203
+ previous &&
204
+ !('_type' in previous) &&
205
+ JSON.stringify(previous.marks) === JSON.stringify(marks)
206
+ ) {
207
+ previous.text += text
208
+ } else {
209
+ pending.push({ marks, text })
210
+ }
211
+ }
212
+
213
+ for (const node of inline) {
214
+ if (node.type === 'hardBreak') {
215
+ pushText('\n', [])
216
+ continue
217
+ }
218
+ if (node.type === 'image') {
219
+ pending.push(imageObject(node, nextKey))
220
+ continue
221
+ }
222
+ const text = node.text ?? ''
223
+ if (text === '' && (node.marks ?? []).length === 0 && node.type !== 'text') continue
224
+ const marks: string[] = []
225
+ for (const mark of node.marks ?? []) {
226
+ const decorator = DECORATOR_BY_TIPTAP_MARK[mark.type ?? '']
227
+ if (decorator) {
228
+ marks.push(decorator)
229
+ continue
230
+ }
231
+ if (mark.type === 'link') {
232
+ marks.push(linkMarkDefKey(mark, markDefs, nextKey))
233
+ continue
234
+ }
235
+ if (mark.type === 'citation') {
236
+ marks.push(citationMarkDefKey(mark, markDefs, nextKey))
237
+ }
238
+ // Marks without a Portable Text form (legacy underline) are dropped,
239
+ // matching the markdown serializer.
240
+ }
241
+ pushText(text, marks)
242
+ }
243
+
244
+ const children: PortableTextObject[] = pending.map((entry) =>
245
+ '_type' in entry
246
+ ? entry
247
+ : { _type: 'span', _key: nextKey(), text: entry.text, marks: entry.marks },
248
+ )
249
+ if (children.length === 0) {
250
+ children.push({ _type: 'span', _key: nextKey(), text: '', marks: [] })
251
+ }
252
+
253
+ const block: PortableTextObject = {
254
+ _type: 'block',
255
+ _key: nextKey(),
256
+ style,
257
+ markDefs,
258
+ children,
259
+ }
260
+ if (list) {
261
+ block.listItem = list.kind
262
+ block.level = list.level
263
+ }
264
+ return block
265
+ }
266
+
267
+ function linkMarkDefKey(
268
+ mark: NonNullable<JSONContent['marks']>[number],
269
+ markDefs: MarkDef[],
270
+ nextKey: KeyGenerator,
271
+ ): string {
272
+ const href = (mark.attrs?.href as string) ?? ''
273
+ const title = (mark.attrs?.title as string | null) ?? null
274
+ const target = (mark.attrs?.target as string | null) ?? null
275
+ const existing = markDefs.find(
276
+ (def) =>
277
+ def._type === 'link' &&
278
+ def.href === href &&
279
+ (def.title ?? null) === title &&
280
+ (def.target ?? null) === target,
281
+ )
282
+ if (existing) return existing._key
283
+ const def: MarkDef = { _key: nextKey(), _type: 'link', href }
284
+ if (title !== null) def.title = title
285
+ if (target !== null) def.target = target
286
+ markDefs.push(def)
287
+ return def._key
288
+ }
289
+
290
+ function citationMarkDefKey(
291
+ mark: NonNullable<JSONContent['marks']>[number],
292
+ markDefs: MarkDef[],
293
+ nextKey: KeyGenerator,
294
+ ): string {
295
+ const href = (mark.attrs?.href as string) ?? ''
296
+ const existing = markDefs.find((def) => def._type === 'citation' && def.href === href)
297
+ if (existing) return existing._key
298
+ const def: MarkDef = { _key: nextKey(), _type: 'citation', href }
299
+ markDefs.push(def)
300
+ return def._key
301
+ }
302
+
303
+ function serializeTable(node: JSONContent, nextKey: KeyGenerator): PortableTextObject {
304
+ const rows = (node.content ?? []).filter((row) => row.type === 'tableRow')
305
+ let headerRows = 0
306
+ for (const row of rows) {
307
+ const cells = row.content ?? []
308
+ if (cells.length > 0 && cells.every((cell) => cell.type === 'tableHeader')) headerRows += 1
309
+ else break
310
+ }
311
+ return {
312
+ _type: 'table',
313
+ _key: nextKey(),
314
+ headerRows,
315
+ rows: rows.map((row, rowIndex) => ({
316
+ _type: 'row',
317
+ _key: nextKey(),
318
+ cells: (row.content ?? []).map((cell) =>
319
+ serializeTableCell(cell, rowIndex, headerRows, nextKey),
320
+ ),
321
+ })),
322
+ }
323
+ }
324
+
325
+ function serializeTableCell(
326
+ cell: JSONContent,
327
+ rowIndex: number,
328
+ headerRows: number,
329
+ nextKey: KeyGenerator,
330
+ ): PortableTextObject {
331
+ const out: PortableTextObject = {
332
+ _type: 'cell',
333
+ _key: nextKey(),
334
+ value: serializeBlocks(cell.content ?? [], nextKey),
335
+ }
336
+ if (cell.type === 'tableHeader' && rowIndex >= headerRows) out.header = true
337
+ const colspan = (cell.attrs?.colspan as number) ?? 1
338
+ const rowspan = (cell.attrs?.rowspan as number) ?? 1
339
+ const colwidth = (cell.attrs?.colwidth as number[] | null) ?? null
340
+ const align = (cell.attrs?.align as string | null) ?? null
341
+ if (colspan !== 1) out.colspan = colspan
342
+ if (rowspan !== 1) out.rowspan = rowspan
343
+ if (colwidth !== null) out.colwidth = colwidth
344
+ if (align !== null) out.align = align
345
+ return out
346
+ }
347
+
348
+ // ─────────────────────────────────────────────────────────────────────────────
349
+ // portableTextToDoc — PortableTextObject[] → JSONContent
350
+ // ─────────────────────────────────────────────────────────────────────────────
351
+
352
+ const TIPTAP_MARK_BY_DECORATOR: Record<string, string> = {
353
+ strong: 'bold',
354
+ em: 'italic',
355
+ 'strike-through': 'strike',
356
+ code: 'code',
357
+ }
358
+
359
+ /** Parse a Portable Text array back into a Tiptap JSONContent document. */
360
+ export function portableTextToDoc(blocks: PortableTextObject[]): JSONContent {
361
+ return { type: 'doc', content: parseBlocks(blocks) }
362
+ }
363
+
364
+ function parseBlocks(blocks: PortableTextObject[]): JSONContent[] {
365
+ const out: JSONContent[] = []
366
+ let index = 0
367
+ while (index < blocks.length) {
368
+ const block = blocks[index]
369
+ if (block._type === 'block' && typeof block.listItem === 'string') {
370
+ const run: PortableTextObject[] = []
371
+ while (
372
+ index < blocks.length &&
373
+ blocks[index]._type === 'block' &&
374
+ typeof blocks[index].listItem === 'string'
375
+ ) {
376
+ run.push(blocks[index])
377
+ index += 1
378
+ }
379
+ out.push(...buildListNodes(run))
380
+ continue
381
+ }
382
+ if (block._type === 'block' && block.style === 'blockquote') {
383
+ const paragraphs: JSONContent[] = []
384
+ while (
385
+ index < blocks.length &&
386
+ blocks[index]._type === 'block' &&
387
+ blocks[index].style === 'blockquote' &&
388
+ typeof blocks[index].listItem !== 'string'
389
+ ) {
390
+ paragraphs.push({ type: 'paragraph', content: parseInline(blocks[index]) })
391
+ index += 1
392
+ }
393
+ out.push({ type: 'blockquote', content: paragraphs })
394
+ continue
395
+ }
396
+ out.push(parseObject(block))
397
+ index += 1
398
+ }
399
+ return out
400
+ }
401
+
402
+ function parseObject(block: PortableTextObject): JSONContent {
403
+ switch (block._type) {
404
+ case 'block': {
405
+ const style = (block.style as string) ?? 'normal'
406
+ const heading = /^h([1-6])$/.exec(style)
407
+ if (heading) {
408
+ return {
409
+ type: 'heading',
410
+ attrs: { level: Number(heading[1]) },
411
+ content: parseInline(block),
412
+ }
413
+ }
414
+ return { type: 'paragraph', content: parseInline(block) }
415
+ }
416
+ case 'code': {
417
+ const code = (block.code as string) ?? ''
418
+ return {
419
+ type: 'codeBlock',
420
+ attrs: { language: (block.language as string | null) ?? null },
421
+ content: code ? [{ type: 'text', text: code }] : [],
422
+ }
423
+ }
424
+ case 'image':
425
+ return {
426
+ type: 'image',
427
+ attrs: {
428
+ src: (block.src as string) ?? '',
429
+ alt: (block.alt as string | null) ?? '',
430
+ title: (block.title as string | null) ?? null,
431
+ },
432
+ }
433
+ case 'tweetEmbed':
434
+ return { type: 'tweetEmbed', attrs: { url: (block.url as string) ?? '' } }
435
+ case 'horizontalRule':
436
+ return { type: 'horizontalRule' }
437
+ case 'table':
438
+ return parseTable(block)
439
+ case 'callout':
440
+ return {
441
+ type: 'callout',
442
+ attrs: { tone: (block.tone as string) ?? 'note' },
443
+ content: parseBlocks((block.content as PortableTextObject[] | undefined) ?? []),
444
+ }
445
+ case 'pullQuote':
446
+ return {
447
+ type: 'pullQuote',
448
+ attrs: { attribution: (block.attribution as string | null) ?? null },
449
+ content: parseBlocks((block.content as PortableTextObject[] | undefined) ?? []),
450
+ }
451
+ case 'generatedFaq':
452
+ return {
453
+ type: 'generatedFaq',
454
+ attrs: { markdown: (block.markdown as string) ?? '' },
455
+ }
456
+ default:
457
+ throw new Error(
458
+ `portableTextToDoc: unknown Portable Text _type "${block._type}" — the stored content is newer than this serializer`,
459
+ )
460
+ }
461
+ }
462
+
463
+ function buildListNodes(run: PortableTextObject[]): JSONContent[] {
464
+ const roots: JSONContent[] = []
465
+ type StackEntry = { list: JSONContent; kind: string; level: number }
466
+ const stack: StackEntry[] = []
467
+
468
+ for (const block of run) {
469
+ const kind = block.listItem === 'number' ? 'number' : 'bullet'
470
+ const level = typeof block.level === 'number' && block.level > 0 ? block.level : 1
471
+ const listType = kind === 'number' ? 'orderedList' : 'bulletList'
472
+
473
+ while (stack.length > 0 && stack[stack.length - 1].level > level) stack.pop()
474
+ let top = stack[stack.length - 1]
475
+ if (top && top.level === level && top.kind !== kind) {
476
+ stack.pop()
477
+ top = stack[stack.length - 1]
478
+ }
479
+
480
+ if (!top || top.level < level) {
481
+ const list: JSONContent = { type: listType, content: [] }
482
+ if (top && top.level < level) {
483
+ const items = top.list.content ?? []
484
+ const lastItem = items[items.length - 1]
485
+ if (lastItem) {
486
+ lastItem.content = [...(lastItem.content ?? []), list]
487
+ } else {
488
+ roots.push(list)
489
+ }
490
+ } else {
491
+ roots.push(list)
492
+ }
493
+ stack.push({ list, kind, level })
494
+ top = stack[stack.length - 1]
495
+ }
496
+
497
+ top.list.content = [
498
+ ...(top.list.content ?? []),
499
+ { type: 'listItem', content: [{ type: 'paragraph', content: parseInline(block) }] },
500
+ ]
501
+ }
502
+
503
+ return roots
504
+ }
505
+
506
+ function parseInline(block: PortableTextObject): JSONContent[] {
507
+ const markDefs = (block.markDefs as MarkDef[] | undefined) ?? []
508
+ const out: JSONContent[] = []
509
+ for (const child of (block.children as PortableTextObject[] | undefined) ?? []) {
510
+ if (child._type === 'span') {
511
+ out.push(...parseSpan(child, markDefs))
512
+ } else if (child._type === 'image') {
513
+ out.push(parseObject(child))
514
+ } else {
515
+ throw new Error(`portableTextToDoc: unknown inline Portable Text _type "${child._type}"`)
516
+ }
517
+ }
518
+ return out
519
+ }
520
+
521
+ function parseSpan(span: PortableTextObject, markDefs: MarkDef[]): JSONContent[] {
522
+ const marks: NonNullable<JSONContent['marks']> = []
523
+ for (const name of (span.marks as string[] | undefined) ?? []) {
524
+ const def = markDefs.find((candidate) => candidate._key === name)
525
+ if (def) {
526
+ if (def._type === 'link') {
527
+ marks.push({
528
+ type: 'link',
529
+ attrs: {
530
+ href: (def.href as string) ?? '',
531
+ title: (def.title as string | null) ?? null,
532
+ target: (def.target as string | null) ?? null,
533
+ },
534
+ })
535
+ } else if (def._type === 'citation') {
536
+ marks.push({ type: 'citation', attrs: { href: (def.href as string) ?? '' } })
537
+ }
538
+ // Other annotation types have no Tiptap node yet; drop the mark but
539
+ // keep the text.
540
+ continue
541
+ }
542
+ const tiptapMark = TIPTAP_MARK_BY_DECORATOR[name]
543
+ if (tiptapMark) marks.push({ type: tiptapMark })
544
+ }
545
+
546
+ const text = (span.text as string) ?? ''
547
+ const segments = text.split('\n')
548
+ const out: JSONContent[] = []
549
+ segments.forEach((segment, index) => {
550
+ if (index > 0) out.push({ type: 'hardBreak' })
551
+ if (segment !== '') {
552
+ const node: JSONContent = { type: 'text', text: segment }
553
+ if (marks.length > 0) node.marks = marks.map((mark) => ({ ...mark }))
554
+ out.push(node)
555
+ }
556
+ })
557
+ return out
558
+ }
559
+
560
+ function parseTable(block: PortableTextObject): JSONContent {
561
+ const headerRows = typeof block.headerRows === 'number' ? block.headerRows : 0
562
+ const rows = (block.rows as PortableTextObject[] | undefined) ?? []
563
+ return {
564
+ type: 'table',
565
+ content: rows.map((row, rowIndex) => ({
566
+ type: 'tableRow',
567
+ content: ((row.cells as PortableTextObject[] | undefined) ?? []).map((cell) => {
568
+ const isHeader = rowIndex < headerRows || cell.header === true
569
+ return {
570
+ type: isHeader ? 'tableHeader' : 'tableCell',
571
+ attrs: {
572
+ colspan: (cell.colspan as number) ?? 1,
573
+ rowspan: (cell.rowspan as number) ?? 1,
574
+ colwidth: (cell.colwidth as number[] | null) ?? null,
575
+ align: (cell.align as string | null) ?? null,
576
+ },
577
+ content: parseBlocks((cell.value as PortableTextObject[] | undefined) ?? []),
578
+ }
579
+ }),
580
+ })),
581
+ }
582
+ }