@portabletext/markdown 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1204 @@
1
+ import {
2
+ isSpan,
3
+ type PortableTextBlock,
4
+ type PortableTextObject,
5
+ type PortableTextTextBlock,
6
+ type Schema,
7
+ } from '@portabletext/schema'
8
+ import markdownit from 'markdown-it'
9
+ import {
10
+ blockquoteStyleDefinition,
11
+ defaultCodeDecoratorDefinition,
12
+ defaultCodeObjectDefinition,
13
+ defaultEmDecoratorDefinition,
14
+ defaultHorizontalRuleObjectDefinition,
15
+ defaultHtmlObjectDefinition,
16
+ defaultImageObjectDefinition,
17
+ defaultLinkObjectDefinition,
18
+ defaultOrderedListItemDefinition,
19
+ defaultSchema,
20
+ defaultStrikeThroughDecoratorDefinition,
21
+ defaultStrongDecoratorDefinition,
22
+ defaultUnorderedListItemDefinition,
23
+ h1StyleDefinition,
24
+ h2StyleDefinition,
25
+ h3StyleDefinition,
26
+ h4StyleDefinition,
27
+ h5StyleDefinition,
28
+ h6StyleDefinition,
29
+ normalStyleDefinition,
30
+ } from '../default-schema'
31
+ import {defaultKeyGenerator} from '../key-generator'
32
+ import {
33
+ buildAnnotationMatcher,
34
+ buildDecoratorMatcher,
35
+ buildListItemMatcher,
36
+ buildObjectMatcher,
37
+ buildStyleMatcher,
38
+ type AnnotationMatcher,
39
+ type DecoratorMatcher,
40
+ type ExtractValue,
41
+ type ListItemMatcher,
42
+ type ObjectMatcher,
43
+ type StyleMatcher,
44
+ } from './matchers'
45
+
46
+ type Options = {
47
+ schema?: Schema
48
+ keyGenerator?: () => string
49
+ marks?: {
50
+ strong?: DecoratorMatcher
51
+ em?: DecoratorMatcher
52
+ code?: DecoratorMatcher
53
+ strikeThrough?: DecoratorMatcher
54
+ link?: AnnotationMatcher<{href: string; title: string | undefined}>
55
+ }
56
+ block?: {
57
+ normal?: StyleMatcher
58
+ blockquote?: StyleMatcher
59
+ h1?: StyleMatcher
60
+ h2?: StyleMatcher
61
+ h3?: StyleMatcher
62
+ h4?: StyleMatcher
63
+ h5?: StyleMatcher
64
+ h6?: StyleMatcher
65
+ }
66
+ listItem?: {
67
+ number?: ListItemMatcher
68
+ bullet?: ListItemMatcher
69
+ }
70
+ types?: {
71
+ code?: ObjectMatcher<{language: string | undefined; code: string}>
72
+ horizontalRule?: ObjectMatcher
73
+ html?: ObjectMatcher<{html: string}>
74
+ table?: ObjectMatcher<{
75
+ headerRows: number | undefined
76
+ rows: Array<{
77
+ _key: string
78
+ _type: 'row'
79
+ cells: Array<{
80
+ _type: 'cell'
81
+ _key: string
82
+ value: Array<PortableTextBlock>
83
+ }>
84
+ }>
85
+ }>
86
+ image?: ObjectMatcher<{src: string; alt: string; title: string | undefined}>
87
+ }
88
+ html?: {
89
+ /**
90
+ * How to handle inline HTML.
91
+ * - 'skip': Ignore inline HTML (default)
92
+ * - 'text': Convert inline HTML to plain text
93
+ *
94
+ * @defaultValue 'skip'
95
+ */
96
+ inline?: 'skip' | 'text'
97
+ }
98
+ }
99
+
100
+ const codeBlockMatcher: ObjectMatcher<
101
+ ExtractValue<typeof defaultCodeObjectDefinition>
102
+ > = ({context, value, isInline}) => {
103
+ const defaultMatcher = buildObjectMatcher(defaultCodeObjectDefinition)
104
+ const codeObject = defaultMatcher({context, value, isInline})
105
+
106
+ if (!codeObject) {
107
+ return undefined
108
+ }
109
+
110
+ if (!('code' in codeObject)) {
111
+ return undefined
112
+ }
113
+
114
+ return codeObject
115
+ }
116
+
117
+ const imageBlockMatcher: ObjectMatcher<
118
+ ExtractValue<typeof defaultImageObjectDefinition>
119
+ > = ({context, value, isInline}) => {
120
+ const defaultMatcher = buildObjectMatcher(defaultImageObjectDefinition)
121
+ const imageObject = defaultMatcher({context, value, isInline})
122
+
123
+ if (!imageObject) {
124
+ return undefined
125
+ }
126
+
127
+ if (!('src' in imageObject)) {
128
+ return undefined
129
+ }
130
+
131
+ return imageObject
132
+ }
133
+
134
+ const defaultOptions = {
135
+ schema: defaultSchema,
136
+ keyGenerator: defaultKeyGenerator,
137
+ html: {
138
+ inline: 'skip',
139
+ },
140
+ block: {
141
+ normal: buildStyleMatcher(normalStyleDefinition),
142
+ blockquote: buildStyleMatcher(blockquoteStyleDefinition),
143
+ h1: buildStyleMatcher(h1StyleDefinition),
144
+ h2: buildStyleMatcher(h2StyleDefinition),
145
+ h3: buildStyleMatcher(h3StyleDefinition),
146
+ h4: buildStyleMatcher(h4StyleDefinition),
147
+ h5: buildStyleMatcher(h5StyleDefinition),
148
+ h6: buildStyleMatcher(h6StyleDefinition),
149
+ },
150
+ listItem: {
151
+ number: buildListItemMatcher(defaultOrderedListItemDefinition),
152
+ bullet: buildListItemMatcher(defaultUnorderedListItemDefinition),
153
+ },
154
+ marks: {
155
+ strong: buildDecoratorMatcher(defaultStrongDecoratorDefinition),
156
+ em: buildDecoratorMatcher(defaultEmDecoratorDefinition),
157
+ code: buildDecoratorMatcher(defaultCodeDecoratorDefinition),
158
+ strikeThrough: buildDecoratorMatcher(
159
+ defaultStrikeThroughDecoratorDefinition,
160
+ ),
161
+ link: buildAnnotationMatcher(defaultLinkObjectDefinition),
162
+ },
163
+ types: {
164
+ code: codeBlockMatcher,
165
+ horizontalRule: buildObjectMatcher(defaultHorizontalRuleObjectDefinition),
166
+ html: buildObjectMatcher(defaultHtmlObjectDefinition),
167
+ image: imageBlockMatcher,
168
+ },
169
+ } as const satisfies Options
170
+
171
+ /**
172
+ * Flattens a table structure by lifting all blocks from all cells.
173
+ */
174
+ function flattenTable(
175
+ table: {
176
+ rows: Array<{
177
+ _key: string
178
+ _type: 'row'
179
+ cells: Array<{
180
+ _type: 'cell'
181
+ _key: string
182
+ value: Array<PortableTextBlock>
183
+ }>
184
+ }>
185
+ headerRows: number
186
+ },
187
+ portableText: Array<PortableTextBlock>,
188
+ ): void {
189
+ // Flatten the table by lifting all blocks from all cells
190
+ for (const row of table.rows) {
191
+ for (const cell of row.cells) {
192
+ for (const block of cell.value) {
193
+ portableText.push(block)
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Converts a markdown string to an array of Portable Text blocks.
201
+ *
202
+ * @public
203
+ */
204
+ export function markdownToPortableText(
205
+ markdown: string,
206
+ options?: Options,
207
+ ): Array<PortableTextBlock> {
208
+ const consolidatedOptions = {
209
+ schema: options?.schema ?? defaultSchema,
210
+ keyGenerator: options?.keyGenerator ?? defaultKeyGenerator,
211
+ html: {
212
+ inline: options?.html?.inline ?? 'skip',
213
+ },
214
+ marks: {
215
+ ...defaultOptions.marks,
216
+ ...options?.marks,
217
+ },
218
+ block: {
219
+ ...defaultOptions.block,
220
+ ...options?.block,
221
+ },
222
+ listItem: {
223
+ ...defaultOptions.listItem,
224
+ ...options?.listItem,
225
+ },
226
+ types: {
227
+ ...defaultOptions.types,
228
+ ...options?.types,
229
+ },
230
+ }
231
+
232
+ const md = markdownit({
233
+ html: true,
234
+ linkify: true,
235
+ typographer: false,
236
+ }).enable(['strikethrough', 'table'])
237
+
238
+ const tokens = md.parse(markdown, {})
239
+
240
+ const portableText: Array<PortableTextBlock> = []
241
+
242
+ // State
243
+ let currentBlock: PortableTextTextBlock | null = null
244
+ const currentListStack: Array<string | null> = []
245
+ const markDefRefs: Array<string> = [] // mark keys: 'strong', 'em', 'code', or link keys
246
+ let currentMarkDefs: Array<PortableTextObject> = []
247
+ let currentBlockquoteStyle: string | null = null // Track blockquote style when inside blockquote
248
+ let inListItem = false // Track if we're inside a list item
249
+
250
+ // Table state
251
+ let currentTable: {
252
+ rows: Array<{
253
+ _key: string
254
+ _type: 'row'
255
+ cells: Array<{
256
+ _type: 'cell'
257
+ _key: string
258
+ value: Array<PortableTextBlock>
259
+ }>
260
+ }>
261
+ headerRows: number
262
+ } | null = null
263
+ let currentTableRow: Array<{
264
+ _type: 'cell'
265
+ _key: string
266
+ value: Array<PortableTextBlock>
267
+ }> | null = null
268
+ let inTableHead = false
269
+
270
+ const startBlock = (style: string) => {
271
+ flushBlock()
272
+ currentBlock = {
273
+ _type: 'block' as const,
274
+ style,
275
+ children: [],
276
+ _key: consolidatedOptions.keyGenerator(),
277
+ markDefs: [],
278
+ }
279
+ currentMarkDefs = []
280
+ }
281
+
282
+ const flushBlock = () => {
283
+ if (!currentBlock) {
284
+ return
285
+ }
286
+
287
+ // Text blocks must have at least one child span
288
+ if (currentBlock.children.length === 0) {
289
+ currentBlock.children.push({
290
+ _type: consolidatedOptions.schema.span.name,
291
+ _key: consolidatedOptions.keyGenerator(),
292
+ text: '',
293
+ marks: [],
294
+ })
295
+ }
296
+
297
+ // Assign accumulated markDefs to the block
298
+ currentBlock.markDefs = currentMarkDefs
299
+
300
+ portableText.push(currentBlock)
301
+
302
+ currentBlock = null
303
+ currentMarkDefs = []
304
+ }
305
+
306
+ const addSpan = (text: string) => {
307
+ if (text.length === 0) {
308
+ return
309
+ }
310
+
311
+ if (!currentBlock) {
312
+ throw new Error('Expected current block')
313
+ }
314
+
315
+ const lastChild = currentBlock.children.at(-1)
316
+
317
+ if (
318
+ isSpan({schema: consolidatedOptions.schema}, lastChild) &&
319
+ lastChild.marks?.every((mark) => markDefRefs.includes(mark)) &&
320
+ markDefRefs.every((mark) => lastChild.marks?.includes(mark))
321
+ ) {
322
+ // Merge with previous span if marks match
323
+ lastChild.text += text
324
+ } else {
325
+ currentBlock.children.push({
326
+ _type: consolidatedOptions.schema.span.name,
327
+ _key: consolidatedOptions.keyGenerator(),
328
+ text: text,
329
+ marks: [...markDefRefs],
330
+ })
331
+ }
332
+ }
333
+
334
+ // Helpers for lists
335
+ const listLevel = () => currentListStack.length
336
+ const ensureListBlock = (listItem: string) => {
337
+ if (!currentBlock) {
338
+ // Use blockquote style if inside a blockquote, otherwise use normal style
339
+ const style =
340
+ currentBlockquoteStyle ??
341
+ consolidatedOptions.block.normal({
342
+ context: {schema: consolidatedOptions.schema},
343
+ })
344
+
345
+ if (!style) {
346
+ console.warn('No default style found, using "normal"')
347
+ startBlock('normal')
348
+ } else {
349
+ startBlock(style)
350
+ }
351
+ }
352
+
353
+ if (!currentBlock) {
354
+ throw new Error('Expected current block')
355
+ }
356
+
357
+ if (
358
+ currentBlock.listItem !== listItem ||
359
+ currentBlock.level !== listLevel()
360
+ ) {
361
+ currentBlock.listItem = listItem
362
+ currentBlock.level = listLevel()
363
+ }
364
+ }
365
+
366
+ // Walk tokens
367
+ for (const token of tokens) {
368
+ switch (token.type) {
369
+ // Paragraphs
370
+ case 'paragraph_open': {
371
+ // Skip creating a new block if we're inside a list item (the list item is the block)
372
+ if (inListItem) {
373
+ break
374
+ }
375
+
376
+ // Use blockquote style if inside a blockquote, otherwise use normal style
377
+ const style =
378
+ currentBlockquoteStyle ??
379
+ consolidatedOptions.block.normal({
380
+ context: {schema: consolidatedOptions.schema},
381
+ })
382
+
383
+ if (!style) {
384
+ console.warn('No default style found, using "normal"')
385
+ startBlock('normal')
386
+ break
387
+ }
388
+
389
+ startBlock(style)
390
+ break
391
+ }
392
+ case 'paragraph_close':
393
+ // Skip flushing if we're inside a list item (list_item_close will flush)
394
+ if (inListItem) {
395
+ break
396
+ }
397
+ flushBlock()
398
+ break
399
+
400
+ // Headings
401
+ case 'heading_open': {
402
+ const level = Number(token?.tag?.slice(1))
403
+
404
+ // Map level to the appropriate heading matcher
405
+ const headingMatchers = {
406
+ 1: consolidatedOptions.block.h1,
407
+ 2: consolidatedOptions.block.h2,
408
+ 3: consolidatedOptions.block.h3,
409
+ 4: consolidatedOptions.block.h4,
410
+ 5: consolidatedOptions.block.h5,
411
+ 6: consolidatedOptions.block.h6,
412
+ } as const
413
+
414
+ const headingMatcher =
415
+ headingMatchers[level as keyof typeof headingMatchers]
416
+
417
+ const style =
418
+ headingMatcher?.({
419
+ context: {schema: consolidatedOptions.schema},
420
+ }) ??
421
+ consolidatedOptions.block.normal({
422
+ context: {schema: consolidatedOptions.schema},
423
+ })
424
+
425
+ if (!style) {
426
+ console.warn('No heading style found, using "normal"')
427
+ startBlock('normal')
428
+ break
429
+ }
430
+
431
+ startBlock(style)
432
+ break
433
+ }
434
+ case 'heading_close':
435
+ flushBlock()
436
+ break
437
+
438
+ // Blockquote
439
+ case 'blockquote_open': {
440
+ // Set the blockquote style for paragraphs inside the blockquote
441
+ const style =
442
+ consolidatedOptions.block.blockquote({
443
+ context: {schema: consolidatedOptions.schema},
444
+ }) ??
445
+ consolidatedOptions.block.normal({
446
+ context: {schema: consolidatedOptions.schema},
447
+ })
448
+
449
+ currentBlockquoteStyle = style ?? 'normal'
450
+ break
451
+ }
452
+ case 'blockquote_close': {
453
+ currentBlockquoteStyle = null
454
+ break
455
+ }
456
+ // Lists
457
+ case 'bullet_list_open': {
458
+ const listItem = consolidatedOptions.listItem.bullet({
459
+ context: {schema: consolidatedOptions.schema},
460
+ })
461
+
462
+ if (!listItem) {
463
+ // No list definition in schema, push null to indicate we should skip list properties
464
+ currentListStack.push(null)
465
+ break
466
+ }
467
+
468
+ currentListStack.push(listItem)
469
+ break
470
+ }
471
+ case 'ordered_list_open': {
472
+ const listItem = consolidatedOptions.listItem.number({
473
+ context: {schema: consolidatedOptions.schema},
474
+ })
475
+
476
+ if (!listItem) {
477
+ // No list definition in schema, push null to indicate we should skip list properties
478
+ currentListStack.push(null)
479
+ break
480
+ }
481
+
482
+ currentListStack.push(listItem)
483
+ break
484
+ }
485
+ case 'bullet_list_close':
486
+ case 'ordered_list_close':
487
+ currentListStack.pop()
488
+ break
489
+ case 'list_item_open': {
490
+ const listType = currentListStack.at(-1)
491
+
492
+ if (listType === undefined) {
493
+ throw new Error('Expected an open list')
494
+ }
495
+
496
+ // Flush any previous list item block before starting a new one
497
+ // This is needed for proper separation of list items
498
+ if (currentBlock) {
499
+ flushBlock()
500
+ }
501
+
502
+ // If listType is null, it means there's no list definition in the schema
503
+ // Just create a normal block without list properties
504
+ if (listType === null) {
505
+ // Use blockquote style if inside a blockquote, otherwise use normal style
506
+ const style =
507
+ currentBlockquoteStyle ??
508
+ consolidatedOptions.block.normal({
509
+ context: {schema: consolidatedOptions.schema},
510
+ })
511
+
512
+ if (!style) {
513
+ console.warn('No default style found, using "normal"')
514
+ startBlock('normal')
515
+ } else {
516
+ startBlock(style)
517
+ }
518
+ inListItem = true
519
+ break
520
+ }
521
+
522
+ ensureListBlock(listType)
523
+ inListItem = true
524
+ break
525
+ }
526
+ case 'list_item_close':
527
+ inListItem = false
528
+ flushBlock()
529
+ break
530
+
531
+ // Code fences / blocks
532
+ case 'fence': {
533
+ flushBlock()
534
+
535
+ const language = token.info.trim() || undefined
536
+ // Remove trailing newline from code content
537
+ const code = token.content.replace(/\n$/, '')
538
+
539
+ const codeObject = consolidatedOptions.types.code({
540
+ context: {
541
+ schema: consolidatedOptions.schema,
542
+ keyGenerator: consolidatedOptions.keyGenerator,
543
+ },
544
+ value: {language, code},
545
+ isInline: false,
546
+ })
547
+
548
+ if (!codeObject) {
549
+ // For fallback to text block, check if it's multi-line
550
+ const hasMultipleLines = code.includes('\n')
551
+
552
+ if (hasMultipleLines) {
553
+ // Multi-line code without definition should still be a code object
554
+ portableText.push({
555
+ _type: 'code',
556
+ _key: consolidatedOptions.keyGenerator(),
557
+ code,
558
+ })
559
+ } else {
560
+ // Single-line code becomes a text block
561
+ const style = consolidatedOptions.block.normal({
562
+ context: {schema: consolidatedOptions.schema},
563
+ })
564
+
565
+ if (!style) {
566
+ console.warn('No default style found, using "normal"')
567
+ startBlock('normal')
568
+ } else {
569
+ startBlock(style)
570
+ }
571
+
572
+ addSpan(code)
573
+ }
574
+
575
+ break
576
+ }
577
+
578
+ portableText.push(codeObject)
579
+
580
+ break
581
+ }
582
+
583
+ // Horizontal rule
584
+ case 'hr': {
585
+ flushBlock()
586
+
587
+ const hrObject = consolidatedOptions.types.horizontalRule({
588
+ context: {
589
+ schema: consolidatedOptions.schema,
590
+ keyGenerator: consolidatedOptions.keyGenerator,
591
+ },
592
+ value: {},
593
+ isInline: false,
594
+ })
595
+
596
+ if (!hrObject) {
597
+ // If there's no break definition in the schema, parse as text
598
+ const style = consolidatedOptions.block.normal({
599
+ context: {schema: consolidatedOptions.schema},
600
+ })
601
+
602
+ if (!style) {
603
+ console.warn('No default style found, using "normal"')
604
+ startBlock('normal')
605
+ } else {
606
+ startBlock(style)
607
+ }
608
+
609
+ addSpan('---')
610
+ flushBlock()
611
+ break
612
+ }
613
+
614
+ portableText.push(hrObject)
615
+
616
+ break
617
+ }
618
+
619
+ // HTML block
620
+ case 'html_block': {
621
+ flushBlock()
622
+
623
+ const htmlContent = token.content.trim()
624
+
625
+ if (!htmlContent) {
626
+ break
627
+ }
628
+
629
+ const htmlObject = consolidatedOptions.types.html({
630
+ context: {
631
+ schema: consolidatedOptions.schema,
632
+ keyGenerator: consolidatedOptions.keyGenerator,
633
+ },
634
+ value: {html: htmlContent},
635
+ isInline: false,
636
+ })
637
+
638
+ if (!htmlObject) {
639
+ // If there's no HTML block definition in the schema, parse as text
640
+ const style = consolidatedOptions.block.normal({
641
+ context: {schema: consolidatedOptions.schema},
642
+ })
643
+
644
+ if (!style) {
645
+ console.warn('No default style found, using "normal"')
646
+ startBlock('normal')
647
+ } else {
648
+ startBlock(style)
649
+ }
650
+
651
+ addSpan(htmlContent)
652
+ flushBlock()
653
+ break
654
+ }
655
+
656
+ portableText.push(htmlObject)
657
+
658
+ break
659
+ }
660
+
661
+ case 'code_block': {
662
+ flushBlock()
663
+
664
+ // Remove trailing newline from code content
665
+ const code = token.content.replace(/\n$/, '')
666
+
667
+ const codeObject = consolidatedOptions.types.code({
668
+ context: {
669
+ schema: consolidatedOptions.schema,
670
+ keyGenerator: consolidatedOptions.keyGenerator,
671
+ },
672
+ value: {language: undefined, code},
673
+ isInline: false,
674
+ })
675
+
676
+ if (!codeObject) {
677
+ // For fallback to text block, check if it's multi-line
678
+ const hasMultipleLines = code.includes('\n')
679
+
680
+ if (hasMultipleLines) {
681
+ // Multi-line code without definition should still be a code object
682
+ portableText.push({
683
+ _type: 'code',
684
+ _key: consolidatedOptions.keyGenerator(),
685
+ code,
686
+ })
687
+ } else {
688
+ // Single-line code becomes a text block
689
+ const style = consolidatedOptions.block.normal({
690
+ context: {schema: consolidatedOptions.schema},
691
+ })
692
+
693
+ if (!style) {
694
+ console.warn('No default style found, using "normal"')
695
+ startBlock('normal')
696
+ } else {
697
+ startBlock(style)
698
+ }
699
+
700
+ addSpan(code)
701
+ }
702
+ } else {
703
+ portableText.push(codeObject)
704
+ }
705
+
706
+ break
707
+ }
708
+
709
+ // Tables
710
+ case 'table_open':
711
+ flushBlock()
712
+ currentTable = {rows: [], headerRows: 0}
713
+ break
714
+
715
+ case 'table_close': {
716
+ if (!currentTable) {
717
+ break
718
+ }
719
+
720
+ // Only create table object if table type is defined
721
+ if (consolidatedOptions.types.table) {
722
+ const tableObject = consolidatedOptions.types.table({
723
+ context: {
724
+ schema: consolidatedOptions.schema,
725
+ keyGenerator: consolidatedOptions.keyGenerator,
726
+ },
727
+ value: {
728
+ rows: currentTable.rows,
729
+ headerRows:
730
+ currentTable.headerRows > 0
731
+ ? currentTable.headerRows
732
+ : undefined,
733
+ },
734
+ isInline: false,
735
+ })
736
+
737
+ if (tableObject) {
738
+ portableText.push(tableObject)
739
+ } else {
740
+ // If table object couldn't be created, flatten the table
741
+ flattenTable(currentTable, portableText)
742
+ }
743
+ } else {
744
+ // If there's no table definition in the schema, flatten the table
745
+ flattenTable(currentTable, portableText)
746
+ }
747
+
748
+ currentTable = null
749
+ break
750
+ }
751
+
752
+ case 'thead_open':
753
+ inTableHead = true
754
+ break
755
+
756
+ case 'thead_close':
757
+ inTableHead = false
758
+ break
759
+
760
+ case 'tbody_open':
761
+ case 'tbody_close':
762
+ // Just markers, no action needed
763
+ break
764
+
765
+ case 'tr_open':
766
+ currentTableRow = []
767
+ break
768
+
769
+ case 'tr_close':
770
+ if (currentTable && currentTableRow) {
771
+ currentTable.rows.push({
772
+ _key: consolidatedOptions.keyGenerator(),
773
+ _type: 'row',
774
+ cells: currentTableRow,
775
+ })
776
+ if (inTableHead) {
777
+ currentTable.headerRows++
778
+ }
779
+ }
780
+ currentTableRow = null
781
+ break
782
+
783
+ case 'th_open':
784
+ case 'td_open': {
785
+ // Start a new block for the table cell
786
+ const style = consolidatedOptions.block.normal({
787
+ context: {schema: consolidatedOptions.schema},
788
+ })
789
+
790
+ if (!style) {
791
+ console.warn('No default style found, using "normal"')
792
+ startBlock('normal')
793
+ } else {
794
+ startBlock(style)
795
+ }
796
+ break
797
+ }
798
+
799
+ case 'th_close':
800
+ case 'td_close': {
801
+ // Flush the current block into the cell
802
+ flushBlock()
803
+
804
+ // Get all blocks that were added since this cell started
805
+ // We need to extract them from portableText array
806
+ const cellBlocks: Array<PortableTextBlock> = []
807
+
808
+ // Check if we have blocks to extract (added after table_open)
809
+ if (portableText.length > 0) {
810
+ const lastBlock = portableText.at(-1)
811
+ if (lastBlock && lastBlock._type === 'block') {
812
+ cellBlocks.push(portableText.pop()!)
813
+ }
814
+ }
815
+
816
+ // If no blocks were created (empty cell), create an empty block
817
+ if (cellBlocks.length === 0) {
818
+ cellBlocks.push({
819
+ _type: 'block' as const,
820
+ style:
821
+ consolidatedOptions.block.normal({
822
+ context: {schema: consolidatedOptions.schema},
823
+ }) || 'normal',
824
+ children: [
825
+ {
826
+ _type: consolidatedOptions.schema.span.name,
827
+ _key: consolidatedOptions.keyGenerator(),
828
+ text: '',
829
+ marks: [],
830
+ },
831
+ ],
832
+ _key: consolidatedOptions.keyGenerator(),
833
+ markDefs: [],
834
+ })
835
+ }
836
+
837
+ // Check if the cell contains a single block with a single image child
838
+ // If so, extract the image as a block-level image
839
+ const firstBlock = cellBlocks[0]
840
+ if (
841
+ cellBlocks.length === 1 &&
842
+ firstBlock &&
843
+ firstBlock._type === 'block' &&
844
+ 'children' in firstBlock &&
845
+ Array.isArray(firstBlock.children) &&
846
+ firstBlock.children.length === 1
847
+ ) {
848
+ const onlyChild = firstBlock.children[0]
849
+ // Check if it's an image object (not a span)
850
+ if (
851
+ typeof onlyChild === 'object' &&
852
+ onlyChild !== null &&
853
+ '_type' in onlyChild &&
854
+ onlyChild._type !== consolidatedOptions.schema.span.name &&
855
+ onlyChild._type === 'image'
856
+ ) {
857
+ // Replace the block with just the image
858
+ cellBlocks[0] = onlyChild as PortableTextBlock
859
+ }
860
+ }
861
+
862
+ if (currentTableRow !== null) {
863
+ currentTableRow.push({
864
+ _type: 'cell',
865
+ _key: consolidatedOptions.keyGenerator(),
866
+ value: cellBlocks,
867
+ })
868
+ }
869
+ break
870
+ }
871
+
872
+ // Inline container
873
+ case 'inline': {
874
+ // Check if we're in a table cell
875
+ const inTableCell = currentTableRow !== null
876
+
877
+ // Check if this is a standalone image (paragraph with only an image)
878
+ if (
879
+ token.children?.length === 1 &&
880
+ token.children[0]?.type === 'image'
881
+ ) {
882
+ const imageToken = token.children[0]
883
+ if (!imageToken) {
884
+ break
885
+ }
886
+
887
+ const src =
888
+ imageToken.attrs?.find(([name]) => name === 'src')?.at(1) || ''
889
+ const alt = imageToken.content || ''
890
+ const title =
891
+ imageToken.attrs?.find(([name]) => name === 'title')?.at(1) ||
892
+ undefined
893
+
894
+ const blockImageObject = consolidatedOptions.types.image({
895
+ context: {
896
+ schema: consolidatedOptions.schema,
897
+ keyGenerator: consolidatedOptions.keyGenerator,
898
+ },
899
+ value: {src, alt, title},
900
+ isInline: false,
901
+ })
902
+
903
+ if (blockImageObject) {
904
+ if (inTableCell) {
905
+ // In table cells, we can't push to portableText directly
906
+ // The block image will be handled in th_close/td_close extraction logic
907
+ // For now, add it as a child of the current block
908
+ if (currentBlock && 'children' in currentBlock) {
909
+ ;(currentBlock as PortableTextTextBlock).children.push(
910
+ blockImageObject as PortableTextObject,
911
+ )
912
+ }
913
+ } else {
914
+ // Discard the empty paragraph block that was created by paragraph_open
915
+ currentBlock = null
916
+ currentMarkDefs = []
917
+ portableText.push(blockImageObject)
918
+ }
919
+ break
920
+ }
921
+
922
+ addSpan(`![${alt}](${src})`)
923
+ break
924
+ }
925
+
926
+ // Walk its children for text/marks/links
927
+ for (const childToken of token.children ?? []) {
928
+ switch (childToken.type) {
929
+ case 'text':
930
+ addSpan(childToken.content)
931
+ break
932
+ case 'softbreak':
933
+ case 'hardbreak':
934
+ addSpan('\n')
935
+ break
936
+ case 'code_inline': {
937
+ const decorator = consolidatedOptions.marks.code({
938
+ context: {schema: consolidatedOptions.schema},
939
+ })
940
+
941
+ if (!decorator) {
942
+ // No code decorator defined, just add the content without marks
943
+ addSpan(childToken.content)
944
+ break
945
+ }
946
+
947
+ markDefRefs.push(decorator)
948
+ addSpan(childToken.content)
949
+
950
+ // code_inline is self-contained, so we need to pop the decorator
951
+ const index = markDefRefs.lastIndexOf(decorator)
952
+
953
+ if (index !== -1) {
954
+ markDefRefs.splice(index, 1)
955
+ }
956
+
957
+ break
958
+ }
959
+ case 'strong_open': {
960
+ const decorator = consolidatedOptions.marks.strong({
961
+ context: {schema: consolidatedOptions.schema},
962
+ })
963
+
964
+ if (!decorator) {
965
+ break
966
+ }
967
+
968
+ markDefRefs.push(decorator)
969
+ break
970
+ }
971
+ case 'strong_close': {
972
+ const decorator = consolidatedOptions.marks.strong({
973
+ context: {schema: consolidatedOptions.schema},
974
+ })
975
+
976
+ if (!decorator) {
977
+ break
978
+ }
979
+
980
+ const index = markDefRefs.lastIndexOf(decorator)
981
+
982
+ if (index !== -1) {
983
+ markDefRefs.splice(index, 1)
984
+ }
985
+
986
+ break
987
+ }
988
+ case 'em_open': {
989
+ const decorator = consolidatedOptions.marks.em({
990
+ context: {schema: consolidatedOptions.schema},
991
+ })
992
+
993
+ if (!decorator) {
994
+ break
995
+ }
996
+
997
+ markDefRefs.push(decorator)
998
+
999
+ break
1000
+ }
1001
+ case 'em_close': {
1002
+ const decorator = consolidatedOptions.marks.em({
1003
+ context: {schema: consolidatedOptions.schema},
1004
+ })
1005
+
1006
+ if (!decorator) {
1007
+ break
1008
+ }
1009
+
1010
+ const index = markDefRefs.lastIndexOf(decorator)
1011
+
1012
+ if (index !== -1) {
1013
+ markDefRefs.splice(index, 1)
1014
+ }
1015
+
1016
+ break
1017
+ }
1018
+ case 's_open': {
1019
+ const decorator = consolidatedOptions.marks.strikeThrough({
1020
+ context: {schema: consolidatedOptions.schema},
1021
+ })
1022
+
1023
+ if (!decorator) {
1024
+ break
1025
+ }
1026
+
1027
+ markDefRefs.push(decorator)
1028
+
1029
+ break
1030
+ }
1031
+ case 's_close': {
1032
+ const decorator = consolidatedOptions.marks.strikeThrough({
1033
+ context: {schema: consolidatedOptions.schema},
1034
+ })
1035
+
1036
+ if (!decorator) {
1037
+ break
1038
+ }
1039
+
1040
+ const index = markDefRefs.lastIndexOf(decorator)
1041
+
1042
+ if (index !== -1) {
1043
+ markDefRefs.splice(index, 1)
1044
+ }
1045
+
1046
+ break
1047
+ }
1048
+ case 'link_open': {
1049
+ const href = childToken.attrs
1050
+ ?.find(([name]) => name === 'href')
1051
+ ?.at(1)
1052
+
1053
+ if (!href) {
1054
+ break
1055
+ }
1056
+
1057
+ const title = childToken.attrs
1058
+ ?.find(([name]) => name === 'title')
1059
+ ?.at(1)
1060
+
1061
+ const linkObject = consolidatedOptions.marks.link({
1062
+ context: {
1063
+ schema: consolidatedOptions.schema,
1064
+ keyGenerator: consolidatedOptions.keyGenerator,
1065
+ },
1066
+ value: {href, title},
1067
+ })
1068
+
1069
+ if (!linkObject) {
1070
+ break
1071
+ }
1072
+
1073
+ currentMarkDefs.push(linkObject)
1074
+ markDefRefs.push(linkObject._key)
1075
+ break
1076
+ }
1077
+ case 'link_close': {
1078
+ // remove the last link key
1079
+ const markDefKeys = new Set(currentMarkDefs.map((d) => d._key))
1080
+ let lastLinkIndex: number | undefined
1081
+
1082
+ for (const markDefRef of markDefRefs.reverse()) {
1083
+ if (markDefKeys.has(markDefRef)) {
1084
+ lastLinkIndex = markDefRefs.indexOf(markDefRef)
1085
+ break
1086
+ }
1087
+ }
1088
+
1089
+ if (lastLinkIndex !== undefined) {
1090
+ const realIndex = markDefRefs.length - 1 - lastLinkIndex
1091
+ markDefRefs.splice(realIndex, 1)
1092
+ }
1093
+ break
1094
+ }
1095
+ case 'image': {
1096
+ const src =
1097
+ childToken.attrs?.find(([name]) => name === 'src')?.at(1) || ''
1098
+ const alt = childToken.content || ''
1099
+
1100
+ // Try to create an inline image first
1101
+ const inlineImageObject = consolidatedOptions.types.image({
1102
+ context: {
1103
+ schema: consolidatedOptions.schema,
1104
+ keyGenerator: consolidatedOptions.keyGenerator,
1105
+ },
1106
+ value: {src, alt, title: undefined},
1107
+ isInline: true,
1108
+ })
1109
+
1110
+ if (inlineImageObject) {
1111
+ // Inline image is supported - add it to current block
1112
+ if (!currentBlock) {
1113
+ const style = consolidatedOptions.block.normal({
1114
+ context: {schema: consolidatedOptions.schema},
1115
+ })
1116
+
1117
+ if (!style) {
1118
+ console.warn('No default style found, using "normal"')
1119
+ startBlock('normal')
1120
+ } else {
1121
+ startBlock(style)
1122
+ }
1123
+ }
1124
+
1125
+ // At this point currentBlock should exist
1126
+ if (!currentBlock) {
1127
+ throw new Error('Expected current block after startBlock')
1128
+ }
1129
+
1130
+ // Add the image as an inline object (TypeScript assertion needed for type narrowing)
1131
+ ;(currentBlock as PortableTextTextBlock).children.push(
1132
+ inlineImageObject as PortableTextObject,
1133
+ )
1134
+ break
1135
+ }
1136
+
1137
+ // Inline image not supported - try block image as fallback
1138
+ const blockImageObject = consolidatedOptions.types.image({
1139
+ context: {
1140
+ schema: consolidatedOptions.schema,
1141
+ keyGenerator: consolidatedOptions.keyGenerator,
1142
+ },
1143
+ value: {src, alt, title: undefined},
1144
+ isInline: false,
1145
+ })
1146
+
1147
+ if (!blockImageObject) {
1148
+ // Neither inline nor block image supported
1149
+ addSpan(`![${alt}](${src})`)
1150
+ break
1151
+ }
1152
+
1153
+ // Block image supported - flush current block and add as block-level
1154
+ // Skip if we're in a table cell (images in cells are handled differently)
1155
+ if (inTableCell) {
1156
+ // In table cells, add the image to current block (will be extracted later)
1157
+ if (currentBlock && 'children' in currentBlock) {
1158
+ ;(currentBlock as PortableTextTextBlock).children.push(
1159
+ blockImageObject as PortableTextObject,
1160
+ )
1161
+ }
1162
+ break
1163
+ }
1164
+
1165
+ // Not in table - flush current block, add image as block, start new block
1166
+ flushBlock()
1167
+ portableText.push(blockImageObject)
1168
+
1169
+ // Start a new block for any remaining content
1170
+ const style = consolidatedOptions.block.normal({
1171
+ context: {schema: consolidatedOptions.schema},
1172
+ })
1173
+
1174
+ if (style) {
1175
+ startBlock(style)
1176
+ }
1177
+
1178
+ break
1179
+ }
1180
+ case 'html_inline': {
1181
+ // Handle inline HTML based on configuration
1182
+ if (consolidatedOptions.html.inline === 'text') {
1183
+ addSpan(childToken.content)
1184
+ }
1185
+ // 'skip' - do nothing, ignore the HTML
1186
+ break
1187
+ }
1188
+ default:
1189
+ // Ignore other inline token types by default
1190
+ break
1191
+ }
1192
+ }
1193
+ break
1194
+ }
1195
+
1196
+ default:
1197
+ break
1198
+ }
1199
+ }
1200
+
1201
+ flushBlock()
1202
+
1203
+ return portableText
1204
+ }