safe-mdx 1.3.7 → 1.3.9

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,953 @@
1
+ import { test, expect, describe } from 'vitest'
2
+ import { unified, Plugin } from 'unified'
3
+
4
+ import remarkMdx from 'remark-mdx'
5
+ import remarkStringify from 'remark-stringify'
6
+ import remarkParse from 'remark-parse'
7
+
8
+ import { parseHtmlToMdxAst } from 'safe-mdx/parse'
9
+ import { Root, RootContent } from 'mdast'
10
+ import { remark } from 'remark'
11
+ import { visit } from 'unist-util-visit'
12
+
13
+ /** Template literal for auto formatting with dedent */
14
+ function html(
15
+ strings: TemplateStringsArray,
16
+ ...expressions: unknown[]
17
+ ): string {
18
+ // Join all string parts
19
+ let raw = strings[0] ?? ''
20
+
21
+ for (let i = 1, l = strings.length; i < l; i++) {
22
+ raw += expressions[i - 1]
23
+ raw += strings[i]
24
+ }
25
+
26
+ // dedent: remove common leading whitespace from all non-empty lines
27
+ const lines = raw.split('\n')
28
+ // Ignore empty lines and lines with only whitespace
29
+ const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
30
+ const indentLengths = nonEmptyLines.map(
31
+ (line) => line.match(/^(\s*)/)![0].length,
32
+ )
33
+ const minIndent = indentLengths.length > 0 ? Math.min(...indentLengths) : 0
34
+
35
+ // Remove the common indent from all lines
36
+ const dedented = lines.map((line) => line.slice(minIndent)).join('\n')
37
+
38
+ // Trim leading/trailing newlines
39
+ return dedented.trim()
40
+ }
41
+
42
+ // Helper to convert HTML to MDX string
43
+ async function htmlToMdxString({
44
+ markdown,
45
+ onError,
46
+ }: {
47
+ markdown: string
48
+ onError?: (error: unknown, text: string) => void
49
+ }): Promise<string> {
50
+ const remarkHtmlBlocks: Plugin<[], Root> = function () {
51
+ return (tree: Root) => {
52
+ visit(tree, (node, index, parent) => {
53
+ if (
54
+ node.type === 'html' &&
55
+ parent &&
56
+ typeof index === 'number'
57
+ ) {
58
+ const htmlValue = node.value as string
59
+
60
+ // Parse HTML to MDX AST with processor for markdown parsing
61
+ const mdxNodes = parseHtmlToMdxAst({
62
+ html: htmlValue,
63
+ onError,
64
+ textToMdast: ({ text: x }) => {
65
+ const processor = remark().use(() => {
66
+ return (tree, file) => {
67
+ file.data.ast = tree
68
+ }
69
+ })
70
+
71
+ const mdast = processor.parse(x) as any
72
+ processor.runSync(mdast)
73
+ return mdast
74
+ },
75
+ parentType: parent.type,
76
+ })
77
+
78
+ // Replace the HTML node with the MDX nodes
79
+ if (mdxNodes.length === 1) {
80
+ parent.children[index] = mdxNodes[0]
81
+ } else if (mdxNodes.length > 1) {
82
+ parent.children.splice(index, 1, ...mdxNodes)
83
+ } else {
84
+ // Remove the node if no content
85
+ parent.children.splice(index, 1)
86
+ }
87
+ }
88
+ })
89
+ }
90
+ }
91
+
92
+ const processor = remark().use(remarkHtmlBlocks).use(remarkStringify, {})
93
+
94
+ const mdast = processor.parse(markdown)
95
+ processor.runSync(mdast)
96
+ return remark().use(remarkMdx).use(remarkStringify, {}).stringify(mdast)
97
+ }
98
+
99
+ describe('Notion-specific HTML to MDX', () => {
100
+ test('converts page element to MDX with surrounding markdown', async () => {
101
+ const htmlContent = html`
102
+ <page url="{{https://notion.so/test}}">
103
+ Test Page
104
+ </page>
105
+ `
106
+
107
+ const markdown = `
108
+ # My Document
109
+
110
+ ${htmlContent}
111
+
112
+ Some text after the page element.
113
+ `
114
+
115
+ const mdxString = await htmlToMdxString({
116
+ markdown,
117
+ onError: (e) => {
118
+ throw e
119
+ },
120
+ })
121
+ expect(mdxString).toMatchInlineSnapshot(`
122
+ "# My Document
123
+
124
+ <page url="{{https://notion.so/test}}">
125
+ Test Page
126
+ </page>
127
+
128
+ Some text after the page element.
129
+ "
130
+ `)
131
+ })
132
+
133
+ test('converts callout element to MDX with surrounding content', async () => {
134
+ const htmlContent = html`
135
+ <callout icon="📎" color="pink_bg">
136
+ Important note
137
+ </callout>
138
+ `
139
+
140
+ const markdown = `
141
+ Here's an important message:
142
+
143
+ ${htmlContent}
144
+
145
+ **Bold text** after the callout.
146
+ `
147
+
148
+ const mdxString = await htmlToMdxString({
149
+ markdown,
150
+ onError: (e) => {
151
+ throw e
152
+ },
153
+ })
154
+ expect(mdxString).toMatchInlineSnapshot(`
155
+ "Here's an important message:
156
+
157
+ <callout icon="📎" color="pink_bg">
158
+ Important note
159
+ </callout>
160
+
161
+ **Bold text** after the callout.
162
+ "
163
+ `)
164
+ })
165
+
166
+ test('converts mention-page element to MDX with mixed content', async () => {
167
+ const htmlContent = html`
168
+ <mention-page url="{{https://notion.so/test}}" />
169
+ `
170
+
171
+ const markdown = `Check out this page: ${htmlContent} for more information.
172
+
173
+ - First item
174
+ - Second item`
175
+
176
+ const mdxString = await htmlToMdxString({
177
+ markdown,
178
+ onError: (e) => {
179
+ throw e
180
+ },
181
+ })
182
+ expect(mdxString).toMatchInlineSnapshot(`
183
+ "Check out this page: <mention-page url="{{https://notion.so/test}}" /> for more information.
184
+
185
+ * First item
186
+ * Second item
187
+ "
188
+ `)
189
+ })
190
+
191
+ test('converts nested Notion elements to MDX', async () => {
192
+ const htmlContent = html`
193
+ <columns>
194
+ <column>
195
+ <page url="{{https://notion.so/page1}}">Page 1</page>
196
+ Some text
197
+ </column>
198
+ <column>
199
+ <callout icon="💡" color="yellow_bg">
200
+ Important callout
201
+ </callout>
202
+ </column>
203
+ </columns>
204
+ `
205
+
206
+ const mdxString = await htmlToMdxString({
207
+ markdown: htmlContent,
208
+ onError: (e) => {
209
+ throw e
210
+ },
211
+ })
212
+ expect(mdxString).toMatchInlineSnapshot(`
213
+ "<columns>
214
+ <column>
215
+ <page url="{{https://notion.so/page1}}">
216
+ Page 1
217
+ </page>
218
+
219
+ Some text
220
+ </column>
221
+
222
+ <column>
223
+ <callout icon="💡" color="yellow_bg">
224
+ Important callout
225
+ </callout>
226
+ </column>
227
+ </columns>
228
+ "
229
+ `)
230
+ })
231
+
232
+ test('handles mixed HTML and Notion elements with surrounding markdown', async () => {
233
+ const htmlContent = html`
234
+ <div>
235
+ <h1>Title</h1>
236
+ <page url="{{https://notion.so/test}}">Test Page</page>
237
+ <p>Regular paragraph</p>
238
+ <mention-page url="{{https://notion.so/another}}" />
239
+ </div>
240
+ `
241
+
242
+ const markdown = `## Section Header
243
+
244
+ ${htmlContent}
245
+
246
+ And here's a [link](https://example.com) after the HTML block.`
247
+
248
+ const mdxString = await htmlToMdxString({
249
+ markdown,
250
+ onError: (e) => {
251
+ throw e
252
+ },
253
+ })
254
+ expect(mdxString).toMatchInlineSnapshot(`
255
+ "## Section Header
256
+
257
+ <div>
258
+ <h1>
259
+ Title
260
+ </h1>
261
+
262
+ <page url="{{https://notion.so/test}}">
263
+ Test Page
264
+ </page>
265
+
266
+ <p>
267
+ Regular paragraph
268
+ </p>
269
+
270
+ <mention-page url="{{https://notion.so/another}}" />
271
+ </div>
272
+
273
+ And here's a [link](https://example.com) after the HTML block.
274
+ "
275
+ `)
276
+ })
277
+
278
+ test('converts span with color attribute', async () => {
279
+ const htmlContent = html`
280
+ <span color="blue">
281
+ Blue text
282
+ </span>
283
+ `
284
+
285
+ const mdxString = await htmlToMdxString({
286
+ markdown: htmlContent,
287
+ onError: (e) => {
288
+ throw e
289
+ },
290
+ })
291
+ expect(mdxString).toMatchInlineSnapshot(`
292
+ "<span color="blue">
293
+ Blue text
294
+ </span>
295
+ "
296
+ `)
297
+ })
298
+
299
+ test('handles table element conversion', async () => {
300
+ const htmlContent = html`
301
+ <table header-row="true">
302
+ <tr>
303
+ <td>Cell 1</td>
304
+ <td>Cell 2</td>
305
+ </tr>
306
+ </table>
307
+ `
308
+
309
+ const mdxString = await htmlToMdxString({
310
+ markdown: htmlContent,
311
+ onError: (e) => {
312
+ throw e
313
+ },
314
+ })
315
+ expect(mdxString).toMatchInlineSnapshot(`
316
+ "<table header-row="true">
317
+ <tr>
318
+ <td>
319
+ Cell 1
320
+ </td>
321
+
322
+ <td>
323
+ Cell 2
324
+ </td>
325
+ </tr>
326
+ </table>
327
+ "
328
+ `)
329
+ })
330
+
331
+ test('handles image element conversion', async () => {
332
+ const htmlContent = html`
333
+ <image
334
+ source="{{https://example.com/image.jpg}}"
335
+ alt="Test image"
336
+ />
337
+ `
338
+
339
+ const mdxString = await htmlToMdxString({
340
+ markdown: htmlContent,
341
+ onError: (e) => {
342
+ throw e
343
+ },
344
+ })
345
+ expect(mdxString).toMatchInlineSnapshot(`
346
+ "<image source="{{https://example.com/image.jpg}}" alt="Test image" />
347
+ "
348
+ `)
349
+ })
350
+
351
+ test('handles unknown element conversion', async () => {
352
+ const htmlContent = html`
353
+ <unknown
354
+ url="{{https://notion.so/embed}}"
355
+ alt="embed"
356
+ />
357
+ `
358
+
359
+ const mdxString = await htmlToMdxString({
360
+ markdown: htmlContent,
361
+ onError: (e) => {
362
+ throw e
363
+ },
364
+ })
365
+ expect(mdxString).toMatchInlineSnapshot(`
366
+ "<unknown url="{{https://notion.so/embed}}" alt="embed" />
367
+ "
368
+ `)
369
+ })
370
+
371
+ test('handles columns with content and surrounding markdown', async () => {
372
+ const htmlContent = html`
373
+ <columns>
374
+ <column>
375
+ <h2>Section 1</h2>
376
+ <page url="{{https://notion.so/page}}">Page Link</page>
377
+ </column>
378
+ <column>
379
+ <callout icon="⚠️" color="yellow_bg">
380
+ <strong>Warning:</strong> Important information
381
+ </callout>
382
+ </column>
383
+ </columns>
384
+ `
385
+
386
+ const markdown = `# Main Title
387
+
388
+ Here's some introductory text before the columns.
389
+
390
+ ${htmlContent}
391
+
392
+ ---
393
+
394
+ Footer text with **bold** and *italic*.`
395
+
396
+ const mdxString = await htmlToMdxString({
397
+ markdown,
398
+ onError: (e) => {
399
+ throw e
400
+ },
401
+ })
402
+ expect(mdxString).toMatchInlineSnapshot(`
403
+ "# Main Title
404
+
405
+ Here's some introductory text before the columns.
406
+
407
+ <columns>
408
+ <column>
409
+ <h2>
410
+ Section 1
411
+ </h2>
412
+
413
+ <page url="{{https://notion.so/page}}">
414
+ Page Link
415
+ </page>
416
+ </column>
417
+
418
+ <column>
419
+ <callout icon="⚠️" color="yellow_bg">
420
+ <strong>
421
+ Warning:
422
+ </strong>
423
+
424
+ Important information
425
+ </callout>
426
+ </column>
427
+ </columns>
428
+
429
+ ***
430
+
431
+ Footer text with **bold** and *italic*.
432
+ "
433
+ `)
434
+ })
435
+
436
+ test('handles HTML wrappers around markdown content', async () => {
437
+
438
+ // TODO if ypu do not add a new line after <selfClosingTag /> it gets all parsed as html!
439
+ const markdown = html`
440
+ <table-of-contents color="gray" />
441
+
442
+ ## GitHub/GitLab: Update issues with pull request actions
443
+ The GitHub and GitLab integrations move issues from *In Progress* to *Done* automatically so you never have to update issues manually. It takes less than a minute to connect GitHub to the workspace and then go to team settings to configure the automatic updates. Read more in the detailed [documentation]({{/60b0cf80dbe0420faa1264a58da48bd2}}).
444
+ <unknown url="{{https://www.notion.so/f050b7b5625b40c1a67ec00d8523dca8#68722a306eb646ffac1a6bf590b654f6}}" alt="tweet" />
445
+ ### ✨ProTip: Set personal GitHub preferences
446
+ Configure these settings in Preferences under Account Settings.
447
+ `
448
+
449
+ const mdxString = await htmlToMdxString({
450
+ markdown,
451
+ onError: (e) => {
452
+ throw e
453
+ },
454
+ })
455
+ expect(mdxString).toMatchInlineSnapshot(`
456
+ "<table-of-contents color="gray" />
457
+
458
+ ## GitHub/GitLab: Update issues with pull request actions
459
+
460
+ The GitHub and GitLab integrations move issues from *In Progress* to *Done* automatically so you never have to update issues manually. It takes less than a minute to connect GitHub to the workspace and then go to team settings to configure the automatic updates. Read more in the detailed [documentation](\\{\\{/60b0cf80dbe0420faa1264a58da48bd2}}).
461
+ <unknown url="{{https://www.notion.so/f050b7b5625b40c1a67ec00d8523dca8#68722a306eb646ffac1a6bf590b654f6}}" alt="tweet" />
462
+
463
+ ### ✨ProTip: Set personal GitHub preferences
464
+
465
+ Configure these settings in Preferences under Account Settings.
466
+ "
467
+ `)
468
+ })
469
+ })
470
+
471
+ describe('parseHtmlToMdxAst', () => {
472
+ test('parses simple HTML element', () => {
473
+ const result = parseHtmlToMdxAst({ html: '<div>Hello</div>' })
474
+ expect(result).toMatchInlineSnapshot(`
475
+ [
476
+ {
477
+ "attributes": [],
478
+ "children": [
479
+ {
480
+ "type": "text",
481
+ "value": "Hello",
482
+ },
483
+ ],
484
+ "name": "div",
485
+ "type": "mdxJsxTextElement",
486
+ },
487
+ ]
488
+ `)
489
+ })
490
+
491
+ test('parses element without transforms (generic)', () => {
492
+ const result = parseHtmlToMdxAst({
493
+ html: '<page url="{{https://notion.so/test}}">Test Page</page>',
494
+ })
495
+ expect(result).toMatchInlineSnapshot(`
496
+ [
497
+ {
498
+ "attributes": [
499
+ {
500
+ "name": "url",
501
+ "type": "mdxJsxAttribute",
502
+ "value": "{{https://notion.so/test}}",
503
+ },
504
+ ],
505
+ "children": [
506
+ {
507
+ "type": "text",
508
+ "value": "Test Page",
509
+ },
510
+ ],
511
+ "name": "page",
512
+ "type": "mdxJsxTextElement",
513
+ },
514
+ ]
515
+ `)
516
+ })
517
+
518
+ test('parses Notion page element', () => {
519
+ const result = parseHtmlToMdxAst({
520
+ html: '<page url="{{https://www.notion.so/test}}">Test Page</page>',
521
+ })
522
+ expect(result).toMatchInlineSnapshot(`
523
+ [
524
+ {
525
+ "attributes": [
526
+ {
527
+ "name": "url",
528
+ "type": "mdxJsxAttribute",
529
+ "value": "{{https://www.notion.so/test}}",
530
+ },
531
+ ],
532
+ "children": [
533
+ {
534
+ "type": "text",
535
+ "value": "Test Page",
536
+ },
537
+ ],
538
+ "name": "page",
539
+ "type": "mdxJsxTextElement",
540
+ },
541
+ ]
542
+ `)
543
+ })
544
+
545
+ test('handles partial HTML - opening tag only', () => {
546
+ const result = parseHtmlToMdxAst({ html: '<div>' })
547
+ expect(result).toMatchInlineSnapshot(`
548
+ [
549
+ {
550
+ "attributes": [],
551
+ "children": [],
552
+ "name": "div",
553
+ "type": "mdxJsxTextElement",
554
+ },
555
+ ]
556
+ `)
557
+ })
558
+
559
+ test('handles partial HTML - closing tag only', () => {
560
+ const result = parseHtmlToMdxAst({ html: '</div>' })
561
+ expect(result).toMatchInlineSnapshot(`
562
+ []
563
+ `)
564
+ })
565
+
566
+ test('handles self-closing tags', () => {
567
+ const result = parseHtmlToMdxAst({
568
+ html: '<img source="{{https://example.com/img.jpg}}" />',
569
+ })
570
+ expect(result).toMatchInlineSnapshot(`
571
+ [
572
+ {
573
+ "attributes": [
574
+ {
575
+ "name": "source",
576
+ "type": "mdxJsxAttribute",
577
+ "value": "{{https://example.com/img.jpg}}",
578
+ },
579
+ ],
580
+ "children": [],
581
+ "name": "img",
582
+ "type": "mdxJsxTextElement",
583
+ },
584
+ ]
585
+ `)
586
+ })
587
+
588
+ test('handles mention-page element', () => {
589
+ const result = parseHtmlToMdxAst({
590
+ html: '<mention-page url="{{https://www.notion.so/test}}" />',
591
+ })
592
+ expect(result).toMatchInlineSnapshot(`
593
+ [
594
+ {
595
+ "attributes": [
596
+ {
597
+ "name": "url",
598
+ "type": "mdxJsxAttribute",
599
+ "value": "{{https://www.notion.so/test}}",
600
+ },
601
+ ],
602
+ "children": [],
603
+ "name": "mention-page",
604
+ "type": "mdxJsxTextElement",
605
+ },
606
+ ]
607
+ `)
608
+ })
609
+
610
+ test('handles callout with attributes', () => {
611
+ const result = parseHtmlToMdxAst({
612
+ html: '<callout icon="📎" color="pink_bg">Some text</callout>',
613
+ })
614
+ expect(result).toMatchInlineSnapshot(`
615
+ [
616
+ {
617
+ "attributes": [
618
+ {
619
+ "name": "icon",
620
+ "type": "mdxJsxAttribute",
621
+ "value": "📎",
622
+ },
623
+ {
624
+ "name": "color",
625
+ "type": "mdxJsxAttribute",
626
+ "value": "pink_bg",
627
+ },
628
+ ],
629
+ "children": [
630
+ {
631
+ "type": "text",
632
+ "value": "Some text",
633
+ },
634
+ ],
635
+ "name": "callout",
636
+ "type": "mdxJsxTextElement",
637
+ },
638
+ ]
639
+ `)
640
+ })
641
+
642
+ test('handles span with color', () => {
643
+ const result = parseHtmlToMdxAst({
644
+ html: '<span color="blue">colored text</span>',
645
+ })
646
+ expect(result).toMatchInlineSnapshot(`
647
+ [
648
+ {
649
+ "attributes": [
650
+ {
651
+ "name": "color",
652
+ "type": "mdxJsxAttribute",
653
+ "value": "blue",
654
+ },
655
+ ],
656
+ "children": [
657
+ {
658
+ "type": "text",
659
+ "value": "colored text",
660
+ },
661
+ ],
662
+ "name": "span",
663
+ "type": "mdxJsxTextElement",
664
+ },
665
+ ]
666
+ `)
667
+ })
668
+
669
+ test('handles mixed content', () => {
670
+ const result = parseHtmlToMdxAst({
671
+ html: 'Some text <page url="{{https://notion.so/test}}">Page</page> more text',
672
+ })
673
+ expect(result).toMatchInlineSnapshot(`
674
+ [
675
+ {
676
+ "type": "text",
677
+ "value": "Some text",
678
+ },
679
+ {
680
+ "attributes": [
681
+ {
682
+ "name": "url",
683
+ "type": "mdxJsxAttribute",
684
+ "value": "{{https://notion.so/test}}",
685
+ },
686
+ ],
687
+ "children": [
688
+ {
689
+ "type": "text",
690
+ "value": "Page",
691
+ },
692
+ ],
693
+ "name": "page",
694
+ "type": "mdxJsxTextElement",
695
+ },
696
+ {
697
+ "type": "text",
698
+ "value": "more text",
699
+ },
700
+ ]
701
+ `)
702
+ })
703
+
704
+ test('handles comments', () => {
705
+ const result = parseHtmlToMdxAst({ html: '<!-- This is a comment -->' })
706
+ expect(result).toMatchInlineSnapshot(`[]`)
707
+ })
708
+
709
+ test('handles table with attributes', () => {
710
+ const result = parseHtmlToMdxAst({
711
+ html: '<table header-row="true"><tr><td>Cell</td></tr></table>',
712
+ })
713
+ expect(result).toMatchInlineSnapshot(`
714
+ [
715
+ {
716
+ "attributes": [
717
+ {
718
+ "name": "header-row",
719
+ "type": "mdxJsxAttribute",
720
+ "value": "true",
721
+ },
722
+ ],
723
+ "children": [
724
+ {
725
+ "attributes": [],
726
+ "children": [
727
+ {
728
+ "attributes": [],
729
+ "children": [
730
+ {
731
+ "type": "text",
732
+ "value": "Cell",
733
+ },
734
+ ],
735
+ "name": "td",
736
+ "type": "mdxJsxTextElement",
737
+ },
738
+ ],
739
+ "name": "tr",
740
+ "type": "mdxJsxTextElement",
741
+ },
742
+ ],
743
+ "name": "table",
744
+ "type": "mdxJsxTextElement",
745
+ },
746
+ ]
747
+ `)
748
+ })
749
+ })
750
+
751
+ describe('parseHtmlToMdxAst without transforms (generic)', () => {
752
+ test('preserves tag names without transform', () => {
753
+ const result = parseHtmlToMdxAst({ html: '<page>Content</page>' })
754
+ expect(result[0]).toHaveProperty('name', 'page')
755
+ })
756
+
757
+ test('preserves curly brace URLs without transform', () => {
758
+ const result = parseHtmlToMdxAst({
759
+ html: '<a href="{{https://example.com}}">Link</a>',
760
+ })
761
+ expect(result[0]).toHaveProperty('attributes')
762
+ const attrs = (result[0] as any).attributes
763
+ expect(attrs[0]).toHaveProperty('value', '{{https://example.com}}')
764
+ })
765
+ })
766
+
767
+ describe('parseHtmlToMdxAst with markdown processor', () => {
768
+ test('parses markdown inside HTML tags', async () => {
769
+ const htmlContent = html`
770
+ <callout>
771
+ This is **bold** text
772
+ </callout>
773
+ `
774
+
775
+ const mdxString = await htmlToMdxString({
776
+ markdown: htmlContent,
777
+ onError: (e) => {
778
+ throw e
779
+ },
780
+ })
781
+ expect(mdxString).toMatchInlineSnapshot(`
782
+ "<callout>
783
+ This is **bold** text
784
+ </callout>
785
+ "
786
+ `)
787
+ })
788
+
789
+ test('parses markdown links inside HTML', async () => {
790
+ const htmlContent = html`
791
+ <span color="orange">
792
+ [link](http://google.com)
793
+ </span>
794
+ `
795
+
796
+ const mdxString = await htmlToMdxString({
797
+ markdown: htmlContent,
798
+ onError: (e) => {
799
+ throw e
800
+ },
801
+ })
802
+ expect(mdxString).toMatchInlineSnapshot(`
803
+ "<span color="orange">
804
+ [link](http://google.com)
805
+ </span>
806
+ "
807
+ `)
808
+ })
809
+
810
+ test('parses mixed markdown and HTML inside tags', async () => {
811
+ const htmlContent = html`
812
+ <callout>
813
+ **Read next:** <mention-page url="https://notion.so/page"/>
814
+ </callout>
815
+ `
816
+
817
+ const mdxString = await htmlToMdxString({
818
+ markdown: htmlContent,
819
+ onError: (e) => {
820
+ throw e
821
+ },
822
+ })
823
+ expect(mdxString).toMatchInlineSnapshot(`
824
+ "<callout>
825
+ **Read next:**
826
+
827
+ <mention-page url="https://notion.so/page" />
828
+ </callout>
829
+ "
830
+ `)
831
+ })
832
+
833
+ test('handles bold inside span with underline', async () => {
834
+ const htmlContent = html`
835
+ <span underline="true">
836
+ **sdf dsf**
837
+ </span>
838
+ `
839
+
840
+ const mdxString = await htmlToMdxString({
841
+ markdown: htmlContent,
842
+ onError: (e) => {
843
+ throw e
844
+ },
845
+ })
846
+ expect(mdxString).toMatchInlineSnapshot(`
847
+ "<span underline="true">
848
+ **sdf dsf**
849
+ </span>
850
+ "
851
+ `)
852
+ })
853
+
854
+ test('converts markdown inside callout to MDX string', async () => {
855
+ const htmlContent = html`
856
+ <callout icon="👉" color="orange_bg">
857
+ **Read next:** Some page
858
+ </callout>
859
+ `
860
+
861
+ const mdxString = await htmlToMdxString({
862
+ markdown: htmlContent,
863
+ onError: (e) => {
864
+ throw e
865
+ },
866
+ })
867
+ expect(mdxString).toMatchInlineSnapshot(`
868
+ "<callout icon="👉" color="orange_bg">
869
+ **Read next:** Some page
870
+ </callout>
871
+ "
872
+ `)
873
+ })
874
+
875
+ test('handles markdown inside table cells', async () => {
876
+ const htmlContent = html`
877
+ <table>
878
+ <tr>
879
+ <td>
880
+ **Bold** text and [link](http://example.com)
881
+ </td>
882
+ </tr>
883
+ </table>
884
+ `
885
+
886
+ const mdxString = await htmlToMdxString({
887
+ markdown: htmlContent,
888
+ onError: (e) => {
889
+ throw e
890
+ },
891
+ })
892
+ expect(mdxString).toMatchInlineSnapshot(`
893
+ "<table>
894
+ <tr>
895
+ <td>
896
+ **Bold** text and [link](http://example.com)
897
+ </td>
898
+ </tr>
899
+ </table>
900
+ "
901
+ `)
902
+ })
903
+
904
+ test('preserves plain text when no markdown', async () => {
905
+ const htmlContent = html`
906
+ <div>
907
+ Plain text without markdown
908
+ </div>
909
+ `
910
+
911
+ const mdxString = await htmlToMdxString({
912
+ markdown: htmlContent,
913
+ onError: (e) => {
914
+ throw e
915
+ },
916
+ })
917
+ expect(mdxString).toMatchInlineSnapshot(`
918
+ "<div>
919
+ Plain text without markdown
920
+ </div>
921
+ "
922
+ `)
923
+ })
924
+
925
+ test('handles nested HTML tags with markdown', async () => {
926
+ const htmlContent = html`
927
+ <div>
928
+ <span>
929
+ **Bold** and <a href="#">link</a>
930
+ </span>
931
+ </div>
932
+ `
933
+
934
+ const mdxString = await htmlToMdxString({
935
+ markdown: htmlContent,
936
+ onError: (e) => {
937
+ throw e
938
+ },
939
+ })
940
+ expect(mdxString).toMatchInlineSnapshot(`
941
+ "<div>
942
+ <span>
943
+ **Bold** and
944
+
945
+ <a href="#">
946
+ link
947
+ </a>
948
+ </span>
949
+ </div>
950
+ "
951
+ `)
952
+ })
953
+ })