safe-mdx 1.0.4 → 1.2.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.
@@ -3,8 +3,9 @@ import { htmlToJsx } from 'html-to-jsx-transform'
3
3
  import React from 'react'
4
4
  import { renderToStaticMarkup } from 'react-dom/server'
5
5
  import { expect, test } from 'vitest'
6
+ import { z } from 'zod'
6
7
  import { mdxParse } from './parse.js'
7
- import { MdastToJsx, mdastBfs } from './safe-mdx.js'
8
+ import { MdastToJsx, mdastBfs, type ComponentPropsSchema } from './safe-mdx.js'
8
9
  import { completeJsxTags } from './streaming.js'
9
10
 
10
11
  const components = {
@@ -16,9 +17,9 @@ const components = {
16
17
  },
17
18
  }
18
19
 
19
- function render(code) {
20
+ function render(code, componentPropsSchema?: ComponentPropsSchema, allowClientEsmImports?: boolean) {
20
21
  const mdast = mdxParse(code)
21
- const visitor = new MdastToJsx({ markdown: code, mdast, components })
22
+ const visitor = new MdastToJsx({ markdown: code, mdast, components, componentPropsSchema, allowClientEsmImports })
22
23
  const result = visitor.run()
23
24
  const html = renderToStaticMarkup(result)
24
25
  // console.log(JSON.stringify(result, null, 2))
@@ -36,6 +37,20 @@ test('htmlToJsx', () => {
36
37
  )
37
38
  })
38
39
 
40
+ test('reference links with titles', () => {
41
+ const code = dedent`
42
+ > **Heads-up:** Check the [API docs][1] for more info.
43
+
44
+ Visit [Slack developers][2] for details.
45
+
46
+ [1]: https://api.slack.com/methods/search.messages "search.messages method - Slack API"
47
+ [2]: https://slack.dev/secure-data-connectivity/ "Secure Data Connectivity - Slack Developers"
48
+ `
49
+
50
+ const { html } = render(code)
51
+ expect(html).toMatchInlineSnapshot(`"<blockquote><p><strong>Heads-up:</strong> Check the <a href="https://api.slack.com/methods/search.messages" title="search.messages method - Slack API">API docs</a> for more info.</p></blockquote><p>Visit <a href="https://slack.dev/secure-data-connectivity/" title="Secure Data Connectivity - Slack Developers">Slack developers</a> for details.</p>"`)
52
+ })
53
+
39
54
  test('markdown inside jsx', () => {
40
55
  const code = dedent`
41
56
  # Hello
@@ -474,6 +489,7 @@ test('missing components are ignored', () => {
474
489
  {
475
490
  "errors": [
476
491
  {
492
+ "line": 1,
477
493
  "message": "Unsupported jsx component MissingComponent",
478
494
  },
479
495
  ],
@@ -510,32 +526,42 @@ test('props parsing', () => {
510
526
  {
511
527
  "errors": [
512
528
  {
513
- "message": "Expressions in jsx props are not supported (expression1={1 + 3})",
529
+ "line": 8,
530
+ "message": "Failed to evaluate expression attribute: expression2={Boolean(1)}",
514
531
  },
515
532
  {
516
- "message": "Expressions in jsx props are not supported (expression2={Boolean(1)})",
533
+ "line": 8,
534
+ "message": "Expressions in jsx prop not evaluated: (expression2={Boolean(1)})",
517
535
  },
518
536
  {
519
- "message": "Expressions in jsx props are not supported (jsx={<SomeComponent />})",
537
+ "line": 9,
538
+ "message": "Unsupported jsx component SomeComponent in attribute",
520
539
  },
521
540
  {
522
- "message": "Expressions in jsx props are not supported (...{ spread: true })",
541
+ "line": 9,
542
+ "message": "Failed to evaluate expression attribute: jsx={<SomeComponent />}",
543
+ },
544
+ {
545
+ "line": 9,
546
+ "message": "Expressions in jsx prop not evaluated: (jsx={<SomeComponent />})",
523
547
  },
524
548
  ],
525
549
  "html": "<h1><p>hi</p></h1>",
526
550
  "result": <React.Fragment>
527
551
  <Heading
528
- backTick="some \${expr} value"
552
+ backTick="some undefined value"
529
553
  boolean={false}
530
554
  doublequote="a " string"
555
+ expression1={4}
531
556
  null={null}
532
557
  num={2}
533
- quote="a " string"
558
+ quote="a ' string"
534
559
  someJson={
535
560
  {
536
561
  "a": 1,
537
562
  }
538
563
  }
564
+ spread={true}
539
565
  >
540
566
  <p>
541
567
  hi
@@ -545,6 +571,132 @@ test('props parsing', () => {
545
571
  }
546
572
  `)
547
573
  })
574
+
575
+ test('jsx attributes with arithmetic expressions', () => {
576
+ expect(
577
+ render(dedent`
578
+ <Heading
579
+ level={1 + 2}
580
+ width={100 * 2}
581
+ active={!false}
582
+ comparison={5 > 3}
583
+ concat={"hello " + "world"}
584
+ />
585
+ `),
586
+ ).toMatchInlineSnapshot(`
587
+ {
588
+ "errors": [],
589
+ "html": "<h1></h1>",
590
+ "result": <React.Fragment>
591
+ <Heading
592
+ active={true}
593
+ comparison={true}
594
+ concat="hello world"
595
+ level={3}
596
+ width={200}
597
+ />
598
+ </React.Fragment>,
599
+ }
600
+ `)
601
+ })
602
+
603
+ test('jsx attributes with complex objects and arrays', () => {
604
+ expect(
605
+ render(dedent`
606
+ <Heading
607
+ simpleArray={[1, 2, 3]}
608
+ stringArray={["one", "two", "three"]}
609
+ mixedArray={[1, "two", true, null]}
610
+ simpleObject={{name: "John", age: 30}}
611
+ nestedObject={{
612
+ user: {
613
+ name: "Alice",
614
+ preferences: {
615
+ theme: "dark",
616
+ lang: "en"
617
+ }
618
+ },
619
+ settings: {
620
+ notifications: true,
621
+ emails: ["alice@example.com", "alice.work@example.com"]
622
+ }
623
+ }}
624
+ arrayOfObjects={[
625
+ {id: 1, name: "Item 1"},
626
+ {id: 2, name: "Item 2"}
627
+ ]}
628
+ />
629
+ `),
630
+ ).toMatchInlineSnapshot(`
631
+ {
632
+ "errors": [],
633
+ "html": "<h1></h1>",
634
+ "result": <React.Fragment>
635
+ <Heading
636
+ arrayOfObjects={
637
+ [
638
+ {
639
+ "id": 1,
640
+ "name": "Item 1",
641
+ },
642
+ {
643
+ "id": 2,
644
+ "name": "Item 2",
645
+ },
646
+ ]
647
+ }
648
+ mixedArray={
649
+ [
650
+ 1,
651
+ "two",
652
+ true,
653
+ null,
654
+ ]
655
+ }
656
+ nestedObject={
657
+ {
658
+ "settings": {
659
+ "emails": [
660
+ "alice@example.com",
661
+ "alice.work@example.com",
662
+ ],
663
+ "notifications": true,
664
+ },
665
+ "user": {
666
+ "name": "Alice",
667
+ "preferences": {
668
+ "lang": "en",
669
+ "theme": "dark",
670
+ },
671
+ },
672
+ }
673
+ }
674
+ simpleArray={
675
+ [
676
+ 1,
677
+ 2,
678
+ 3,
679
+ ]
680
+ }
681
+ simpleObject={
682
+ {
683
+ "age": 30,
684
+ "name": "John",
685
+ }
686
+ }
687
+ stringArray={
688
+ [
689
+ "one",
690
+ "two",
691
+ "three",
692
+ ]
693
+ }
694
+ />
695
+ </React.Fragment>,
696
+ }
697
+ `)
698
+ })
699
+
548
700
  test('breaks', () => {
549
701
  expect(
550
702
  render(dedent`
@@ -1041,7 +1193,7 @@ test('kitchen sink', () => {
1041
1193
 
1042
1194
  [arbitrary case-insensitive reference text]: https://www.mozilla.org
1043
1195
  [1]: http://slashdot.org
1044
- [link text itself]: http://www.reddit.com</code></pre><p><a href="https://www.google.com" title="">I&#x27;m an inline-style link</a></p><p><a href="https://www.google.com" title="Google&#x27;s Homepage">I&#x27;m an inline-style link with title</a></p><p><a href="https://www.mozilla.org">I&#x27;m a reference-style link</a></p><p><a href="../blob/master/LICENSE" title="">I&#x27;m a relative reference to a repository file</a></p><p><a href="http://slashdot.org">You can use numbers for reference-style link definitions</a></p><p>Or leave it empty and use the <a href="http://www.reddit.com">link text itself</a>.</p><p>URLs and URLs in angle brackets will automatically get turned into links.
1196
+ [link text itself]: http://www.reddit.com</code></pre><p><a href="https://www.google.com" title="">I&#x27;m an inline-style link</a></p><p><a href="https://www.google.com" title="Google&#x27;s Homepage">I&#x27;m an inline-style link with title</a></p><p><a href="https://www.mozilla.org" title="">I&#x27;m a reference-style link</a></p><p><a href="../blob/master/LICENSE" title="">I&#x27;m a relative reference to a repository file</a></p><p><a href="http://slashdot.org" title="">You can use numbers for reference-style link definitions</a></p><p>Or leave it empty and use the <a href="http://www.reddit.com" title="">link text itself</a>.</p><p>URLs and URLs in angle brackets will automatically get turned into links.
1045
1197
  <a href="http://www.example.com" title="">http://www.example.com</a> and sometimes
1046
1198
  example.com (but not on Github, for example).</p><p>Some text to show that the reference links can follow later.</p><a name="images"></a><h2>Images</h2><pre><code class="language-no-highlight">Here&#x27;s our logo (hover to see the title text):
1047
1199
 
@@ -1540,6 +1692,7 @@ test('kitchen sink', () => {
1540
1692
  <p>
1541
1693
  <a
1542
1694
  href="https://www.mozilla.org"
1695
+ title=""
1543
1696
  >
1544
1697
  I'm a reference-style link
1545
1698
  </a>
@@ -1555,6 +1708,7 @@ test('kitchen sink', () => {
1555
1708
  <p>
1556
1709
  <a
1557
1710
  href="http://slashdot.org"
1711
+ title=""
1558
1712
  >
1559
1713
  You can use numbers for reference-style link definitions
1560
1714
  </a>
@@ -1563,6 +1717,7 @@ test('kitchen sink', () => {
1563
1717
  Or leave it empty and use the
1564
1718
  <a
1565
1719
  href="http://www.reddit.com"
1720
+ title=""
1566
1721
  >
1567
1722
  link text itself
1568
1723
  </a>
@@ -2288,3 +2443,681 @@ _No documentation_
2288
2443
  </React.Fragment>
2289
2444
  `)
2290
2445
  })
2446
+
2447
+ test('component props schema validation with zod', () => {
2448
+ const HeadingSchema = z.object({
2449
+ level: z.number().min(1).max(6),
2450
+ title: z.string().optional(),
2451
+ })
2452
+
2453
+ const CardsSchema = z.object({
2454
+ count: z.number().positive(),
2455
+ variant: z.enum(['default', 'outline']).optional(),
2456
+ })
2457
+
2458
+ const componentPropsSchema: ComponentPropsSchema = {
2459
+ Heading: HeadingSchema,
2460
+ Cards: CardsSchema,
2461
+ }
2462
+
2463
+ const code = dedent`
2464
+ <Heading level={2} title="test">Valid heading</Heading>
2465
+
2466
+ <Cards count={3} variant="outline">Valid cards</Cards>
2467
+
2468
+ <Heading level={10} title="test">Invalid heading - level too high</Heading>
2469
+
2470
+ <Cards count={-1}>Invalid cards - negative count</Cards>
2471
+
2472
+ <Cards count="not a number">Invalid cards - wrong type</Cards>
2473
+ `
2474
+
2475
+ expect(render(code, componentPropsSchema)).toMatchInlineSnapshot(`
2476
+ {
2477
+ "errors": [
2478
+ {
2479
+ "line": 5,
2480
+ "message": "Invalid props for component "Heading" at "level": Number must be less than or equal to 6",
2481
+ "schemaPath": "level",
2482
+ },
2483
+ {
2484
+ "line": 7,
2485
+ "message": "Invalid props for component "Cards" at "count": Number must be greater than 0",
2486
+ "schemaPath": "count",
2487
+ },
2488
+ {
2489
+ "line": 9,
2490
+ "message": "Invalid props for component "Cards" at "count": Expected number, received string",
2491
+ "schemaPath": "count",
2492
+ },
2493
+ ],
2494
+ "html": "<h1>Valid heading</h1><div>Valid cards</div><h1>Invalid heading - level too high</h1><div>Invalid cards - negative count</div><div>Invalid cards - wrong type</div>",
2495
+ "result": <React.Fragment>
2496
+ <Heading
2497
+ level={2}
2498
+ title="test"
2499
+ >
2500
+ Valid heading
2501
+ </Heading>
2502
+ <Cards
2503
+ count={3}
2504
+ variant="outline"
2505
+ >
2506
+ Valid cards
2507
+ </Cards>
2508
+ <Heading
2509
+ level={10}
2510
+ title="test"
2511
+ >
2512
+ Invalid heading - level too high
2513
+ </Heading>
2514
+ <Cards
2515
+ count={-1}
2516
+ >
2517
+ Invalid cards - negative count
2518
+ </Cards>
2519
+ <Cards
2520
+ count="not a number"
2521
+ >
2522
+ Invalid cards - wrong type
2523
+ </Cards>
2524
+ </React.Fragment>,
2525
+ }
2526
+ `)
2527
+ })
2528
+
2529
+ test('mdx expressions evaluation', () => {
2530
+ expect(
2531
+ render(dedent`
2532
+ # Expression Test
2533
+
2534
+ Simple math: {1 + 2}
2535
+
2536
+ <Heading>
2537
+ Inside JSX: {3 * 4}
2538
+ </Heading>
2539
+
2540
+ Boolean: {true}
2541
+ String concat: {"hello" + " world"}
2542
+ `),
2543
+ ).toMatchInlineSnapshot(`
2544
+ {
2545
+ "errors": [],
2546
+ "html": "<h1>Expression Test</h1><p>Simple math: 3</p><h1><p>Inside JSX: 12</p></h1><p>Boolean:
2547
+ String concat: hello world</p>",
2548
+ "result": <React.Fragment>
2549
+ <h1>
2550
+ Expression Test
2551
+ </h1>
2552
+ <p>
2553
+ Simple math:
2554
+ 3
2555
+ </p>
2556
+ <Heading>
2557
+ <p>
2558
+ Inside JSX:
2559
+ 12
2560
+ </p>
2561
+ </Heading>
2562
+ <p>
2563
+ Boolean:
2564
+ true
2565
+
2566
+ String concat:
2567
+ hello world
2568
+ </p>
2569
+ </React.Fragment>,
2570
+ }
2571
+ `)
2572
+ })
2573
+
2574
+ test('mdx expressions with unsupported functions', () => {
2575
+ expect(
2576
+ render(dedent`
2577
+ Math function: {Math.max(5, 10)}
2578
+ Console: {console.log("test")}
2579
+ `),
2580
+ ).toMatchInlineSnapshot(`
2581
+ {
2582
+ "errors": [
2583
+ {
2584
+ "line": 1,
2585
+ "message": "Failed to evaluate expression: Math.max(5, 10)",
2586
+ },
2587
+ {
2588
+ "line": 2,
2589
+ "message": "Failed to evaluate expression: console.log("test")",
2590
+ },
2591
+ ],
2592
+ "html": "<p>Math function:
2593
+ Console: </p>",
2594
+ "result": <React.Fragment>
2595
+ <p>
2596
+ Math function:
2597
+
2598
+ Console:
2599
+ </p>
2600
+ </React.Fragment>,
2601
+ }
2602
+ `)
2603
+ })
2604
+
2605
+ test('schema validation without errors', () => {
2606
+ const HeadingSchema = z.object({
2607
+ level: z.number().min(1).max(6),
2608
+ title: z.string().optional(),
2609
+ })
2610
+
2611
+ const componentPropsSchema: ComponentPropsSchema = {
2612
+ Heading: HeadingSchema,
2613
+ }
2614
+
2615
+ const code = dedent`
2616
+ <Heading level={2} title="test">Valid heading</Heading>
2617
+ <Heading level={1}>Another valid heading</Heading>
2618
+ `
2619
+
2620
+ expect(render(code, componentPropsSchema)).toMatchInlineSnapshot(`
2621
+ {
2622
+ "errors": [],
2623
+ "html": "<h1>Valid heading</h1><h1>Another valid heading</h1>",
2624
+ "result": <React.Fragment>
2625
+ <Heading
2626
+ level={2}
2627
+ title="test"
2628
+ >
2629
+ Valid heading
2630
+ </Heading>
2631
+ <Heading
2632
+ level={1}
2633
+ >
2634
+ Another valid heading
2635
+ </Heading>
2636
+ </React.Fragment>,
2637
+ }
2638
+ `)
2639
+ })
2640
+
2641
+ test('component without schema should not be validated', () => {
2642
+ const HeadingSchema = z.object({
2643
+ level: z.number().min(1).max(6),
2644
+ })
2645
+
2646
+ const componentPropsSchema: ComponentPropsSchema = {
2647
+ Heading: HeadingSchema,
2648
+ }
2649
+
2650
+ const code = dedent`
2651
+ <Heading level={2}>Valid heading with schema</Heading>
2652
+ <Cards invalidProp="anything">Cards without schema - should not be validated</Cards>
2653
+ `
2654
+
2655
+ expect(render(code, componentPropsSchema)).toMatchInlineSnapshot(`
2656
+ {
2657
+ "errors": [],
2658
+ "html": "<h1>Valid heading with schema</h1><div>Cards without schema - should not be validated</div>",
2659
+ "result": <React.Fragment>
2660
+ <Heading
2661
+ level={2}
2662
+ >
2663
+ Valid heading with schema
2664
+ </Heading>
2665
+ <Cards
2666
+ invalidProp="anything"
2667
+ >
2668
+ Cards without schema - should not be validated
2669
+ </Cards>
2670
+ </React.Fragment>,
2671
+ }
2672
+ `)
2673
+ })
2674
+
2675
+ test('validation error includes schema path', () => {
2676
+ const ComplexSchema = z.object({
2677
+ user: z.object({
2678
+ name: z.string(),
2679
+ age: z.number().min(0),
2680
+ }),
2681
+ settings: z.object({
2682
+ theme: z.enum(['light', 'dark']),
2683
+ }),
2684
+ })
2685
+
2686
+ const componentPropsSchema: ComponentPropsSchema = {
2687
+ Heading: ComplexSchema,
2688
+ }
2689
+
2690
+ const code = dedent`
2691
+ <Heading user={{ name: "test", age: -1 }} settings={{ theme: "invalid" }}>Complex validation</Heading>
2692
+ `
2693
+
2694
+ expect(render(code, componentPropsSchema)).toMatchInlineSnapshot(`
2695
+ {
2696
+ "errors": [
2697
+ {
2698
+ "line": 1,
2699
+ "message": "Invalid props for component "Heading" at "user.age": Number must be greater than or equal to 0",
2700
+ "schemaPath": "user.age",
2701
+ },
2702
+ {
2703
+ "line": 1,
2704
+ "message": "Invalid props for component "Heading" at "settings.theme": Invalid enum value. Expected 'light' | 'dark', received 'invalid'",
2705
+ "schemaPath": "settings.theme",
2706
+ },
2707
+ ],
2708
+ "html": "<h1>Complex validation</h1>",
2709
+ "result": <React.Fragment>
2710
+ <Heading
2711
+ settings={
2712
+ {
2713
+ "theme": "invalid",
2714
+ }
2715
+ }
2716
+ user={
2717
+ {
2718
+ "age": -1,
2719
+ "name": "test",
2720
+ }
2721
+ }
2722
+ >
2723
+ Complex validation
2724
+ </Heading>
2725
+ </React.Fragment>,
2726
+ }
2727
+ `)
2728
+ })
2729
+
2730
+ test('mdxJsxExpressionAttribute spread syntax', () => {
2731
+ expect(
2732
+ render(dedent`
2733
+ <Heading
2734
+ {...{key: '1', level: 2}}
2735
+ title="test"
2736
+ >
2737
+ Content with spread
2738
+ </Heading>
2739
+ `),
2740
+ ).toMatchInlineSnapshot(`
2741
+ {
2742
+ "errors": [],
2743
+ "html": "<h1><p>Content with spread</p></h1>",
2744
+ "result": <React.Fragment>
2745
+ <Heading
2746
+ level={2}
2747
+ title="test"
2748
+ >
2749
+ <p>
2750
+ Content with spread
2751
+ </p>
2752
+ </Heading>
2753
+ </React.Fragment>,
2754
+ }
2755
+ `)
2756
+ })
2757
+
2758
+ test('mdxJsxExpressionAttribute complex spread cases', () => {
2759
+ expect(
2760
+ render(dedent`
2761
+ <Heading
2762
+ {...{
2763
+ level: 3,
2764
+ active: true,
2765
+ disabled: false,
2766
+ count: 42,
2767
+ title: "spread title",
2768
+ nested: {
2769
+ prop: "value"
2770
+ }
2771
+ }}
2772
+ >
2773
+ Complex spread test
2774
+ </Heading>
2775
+
2776
+ <Cards
2777
+ {...{style: {color: "red", fontSize: "16px"}}}
2778
+ {...{className: "test-class", id: "test-id"}}
2779
+ >
2780
+ Multiple spreads
2781
+ </Cards>
2782
+ `),
2783
+ ).toMatchInlineSnapshot(`
2784
+ {
2785
+ "errors": [],
2786
+ "html": "<h1><p>Complex spread test</p></h1><div><p>Multiple spreads</p></div>",
2787
+ "result": <React.Fragment>
2788
+ <Heading
2789
+ active={true}
2790
+ count={42}
2791
+ disabled={false}
2792
+ level={3}
2793
+ nested={
2794
+ {
2795
+ "prop": "value",
2796
+ }
2797
+ }
2798
+ title="spread title"
2799
+ >
2800
+ <p>
2801
+ Complex spread test
2802
+ </p>
2803
+ </Heading>
2804
+ <Cards
2805
+ className="test-class"
2806
+ id="test-id"
2807
+ style={
2808
+ {
2809
+ "color": "red",
2810
+ "fontSize": "16px",
2811
+ }
2812
+ }
2813
+ >
2814
+ <p>
2815
+ Multiple spreads
2816
+ </p>
2817
+ </Cards>
2818
+ </React.Fragment>,
2819
+ }
2820
+ `)
2821
+ })
2822
+
2823
+ test('mdxJsxExpressionAttribute edge cases', () => {
2824
+ expect(
2825
+ render(dedent`
2826
+ <Heading {...{}} title="empty spread">Empty spread</Heading>
2827
+
2828
+ <Heading {...{null: null, undefined: undefined}} title="null/undefined values">Null/undefined</Heading>
2829
+
2830
+ <Heading {...{array: [1, 2, 3], object: {nested: true}}} title="complex values">Complex types</Heading>
2831
+ `),
2832
+ ).toMatchInlineSnapshot(`
2833
+ {
2834
+ "errors": [
2835
+ {
2836
+ "line": 3,
2837
+ "message": "Failed to evaluate expression attribute: ...{null: null, undefined: undefined}",
2838
+ },
2839
+ ],
2840
+ "html": "<h1>Empty spread</h1><h1>Null/undefined</h1><h1>Complex types</h1>",
2841
+ "result": <React.Fragment>
2842
+ <Heading
2843
+ title="empty spread"
2844
+ >
2845
+ Empty spread
2846
+ </Heading>
2847
+ <Heading
2848
+ title="null/undefined values"
2849
+ >
2850
+ Null/undefined
2851
+ </Heading>
2852
+ <Heading
2853
+ array={
2854
+ [
2855
+ 1,
2856
+ 2,
2857
+ 3,
2858
+ ]
2859
+ }
2860
+ object={
2861
+ {
2862
+ "nested": true,
2863
+ }
2864
+ }
2865
+ title="complex values"
2866
+ >
2867
+ Complex types
2868
+ </Heading>
2869
+ </React.Fragment>,
2870
+ }
2871
+ `)
2872
+ })
2873
+
2874
+ test('ESM imports from https URLs', () => {
2875
+ const code = dedent`
2876
+ import Button from 'https://esm.sh/some-button-component'
2877
+ import { Card, Modal } from 'https://esm.sh/some-ui-library'
2878
+
2879
+ # Hello
2880
+
2881
+ <Button>Click me</Button>
2882
+
2883
+ <Card title="Test Card">
2884
+ Content inside card
2885
+ </Card>
2886
+
2887
+ <Modal open={true}>
2888
+ Modal content
2889
+ </Modal>
2890
+ `
2891
+
2892
+ const mdast = mdxParse(code)
2893
+ const visitor = new MdastToJsx({ markdown: code, mdast, components, allowClientEsmImports: true })
2894
+ const result = visitor.run()
2895
+
2896
+ // Check that imports were parsed correctly
2897
+ expect(visitor.esmImports.size).toBe(3)
2898
+ expect(visitor.esmImports.get('Button')).toBe('https://esm.sh/some-button-component')
2899
+ expect(visitor.esmImports.get('Card')).toBe('https://esm.sh/some-ui-library#Card')
2900
+ expect(visitor.esmImports.get('Modal')).toBe('https://esm.sh/some-ui-library#Modal')
2901
+
2902
+ // Since these are dynamic imports that only work on client, the server render should return null
2903
+ const html = renderToStaticMarkup(result)
2904
+ expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1>"`)
2905
+
2906
+ expect(visitor.errors).toEqual([])
2907
+ })
2908
+
2909
+ test('ESM imports error handling', () => {
2910
+ const code = dedent`
2911
+ import Button from 'file:///local/path'
2912
+ import Component from './relative/path'
2913
+
2914
+ # Test
2915
+
2916
+ <Button>Local import should not work</Button>
2917
+ <Component>Relative import should not work</Component>
2918
+ `
2919
+
2920
+ const mdast = mdxParse(code)
2921
+ const visitor = new MdastToJsx({ markdown: code, mdast, components, allowClientEsmImports: true })
2922
+ const result = visitor.run()
2923
+
2924
+ // Only https imports should be processed
2925
+ expect(visitor.esmImports.size).toBe(0)
2926
+
2927
+ // Should have 4 errors: 2 for invalid imports, 2 for unsupported components
2928
+ expect(visitor.errors.length).toBe(4)
2929
+
2930
+ // First two errors are for invalid imports
2931
+ expect(visitor.errors[0].message).toContain('Invalid import URL')
2932
+ expect(visitor.errors[1].message).toContain('Invalid import URL')
2933
+
2934
+ // Last two errors are for unsupported components
2935
+ expect(visitor.errors[2].message).toContain('Unsupported jsx component Button')
2936
+ expect(visitor.errors[3].message).toContain('Unsupported jsx component Component')
2937
+ })
2938
+
2939
+ test('jsx components in attributes', () => {
2940
+ const code = dedent`
2941
+ # JSX Components in Attributes
2942
+
2943
+ <Heading icon={<span>👋</span>} level={1}>
2944
+ Hello World
2945
+ </Heading>
2946
+
2947
+ <Cards items={<div>Item 1</div>}>
2948
+ Some content
2949
+ </Cards>
2950
+ `
2951
+
2952
+ const { result, errors, html } = render(code)
2953
+
2954
+ // Should not have any errors
2955
+ expect(errors).toMatchInlineSnapshot(`[]`)
2956
+
2957
+ // Should render correctly
2958
+ expect(html).toMatchInlineSnapshot(`"<h1>JSX Components in Attributes</h1><h1><p>Hello World</p></h1><div><p>Some content</p></div>"`)
2959
+
2960
+ expect(result).toMatchInlineSnapshot(`
2961
+ <React.Fragment>
2962
+ <h1>
2963
+ JSX Components in Attributes
2964
+ </h1>
2965
+ <Heading
2966
+ icon={
2967
+ <span>
2968
+ 👋
2969
+ </span>
2970
+ }
2971
+ level={1}
2972
+ >
2973
+ <p>
2974
+ Hello World
2975
+ </p>
2976
+ </Heading>
2977
+ <Cards
2978
+ items={
2979
+ <div>
2980
+ Item 1
2981
+ </div>
2982
+ }
2983
+ >
2984
+ <p>
2985
+ Some content
2986
+ </p>
2987
+ </Cards>
2988
+ </React.Fragment>
2989
+ `)
2990
+ })
2991
+
2992
+ test('jsx components in attributes with ESM imports', () => {
2993
+ const code = dedent`
2994
+ import Button from 'https://esm.sh/some-button-component'
2995
+ import { Icon } from 'https://esm.sh/some-icon-library'
2996
+
2997
+ # ESM Components in Attributes
2998
+
2999
+ <Heading icon={<Icon name="star" />} level={1}>
3000
+ Hello World
3001
+ </Heading>
3002
+
3003
+ <Cards actionButton={<Button>Click me</Button>}>
3004
+ Some content
3005
+ </Cards>
3006
+ `
3007
+
3008
+ const { result, errors, html } = render(code, undefined, true)
3009
+
3010
+ // Should not have any errors
3011
+ expect(errors).toMatchInlineSnapshot(`[]`)
3012
+
3013
+ // Should render correctly - ESM components should be wrapped in DynamicEsmComponent
3014
+ expect(html).toMatchInlineSnapshot(`"<h1>ESM Components in Attributes</h1><h1><p>Hello World</p></h1><div><p>Some content</p></div>"`)
3015
+
3016
+ expect(result).toMatchInlineSnapshot(`
3017
+ <React.Fragment>
3018
+ <h1>
3019
+ ESM Components in Attributes
3020
+ </h1>
3021
+ <Heading
3022
+ icon={
3023
+ <DynamicEsmComponent
3024
+ componentName="Icon"
3025
+ importUrl="https://esm.sh/some-icon-library"
3026
+ name="star"
3027
+ />
3028
+ }
3029
+ level={1}
3030
+ >
3031
+ <p>
3032
+ Hello World
3033
+ </p>
3034
+ </Heading>
3035
+ <Cards
3036
+ actionButton={
3037
+ <DynamicEsmComponent
3038
+ componentName="default"
3039
+ importUrl="https://esm.sh/some-button-component"
3040
+ >
3041
+ Click me
3042
+ </DynamicEsmComponent>
3043
+ }
3044
+ >
3045
+ <p>
3046
+ Some content
3047
+ </p>
3048
+ </Cards>
3049
+ </React.Fragment>
3050
+ `)
3051
+ })
3052
+
3053
+ test('ESM imports disabled by default', () => {
3054
+ const code = dedent`
3055
+ import Button from 'https://esm.sh/some-button-component'
3056
+
3057
+ # Test Default Behavior
3058
+
3059
+ <Button>This should not work</Button>
3060
+ `
3061
+
3062
+ const { result, errors, html } = render(code) // No allowClientEsmImports flag
3063
+
3064
+ // ESM imports should not be processed when disabled
3065
+ const mdast = mdxParse(code)
3066
+ const visitor = new MdastToJsx({ markdown: code, mdast, components }) // Default allowClientEsmImports: false
3067
+ expect(visitor.esmImports.size).toBe(0)
3068
+
3069
+ // Should have error for unsupported component
3070
+ expect(errors.length).toBeGreaterThan(0)
3071
+ expect(errors.some(e => e.message.includes('Unsupported jsx component Button'))).toBe(true)
3072
+
3073
+ // Should render heading but not the button
3074
+ expect(html).toMatchInlineSnapshot(`"<h1>Test Default Behavior</h1>"`)
3075
+ })
3076
+
3077
+ test("jsx components in attributes error handling", () => {
3078
+ const code = dedent`
3079
+ # Error Handling Test
3080
+
3081
+ <Heading icon={<UnsupportedComponent />} level={1}>
3082
+ Hello World
3083
+ </Heading>
3084
+ `
3085
+
3086
+ const { result, errors, html } = render(code)
3087
+
3088
+ // Should have an error for the unsupported component
3089
+ expect(errors).toMatchInlineSnapshot(`
3090
+ [
3091
+ {
3092
+ "line": 3,
3093
+ "message": "Unsupported jsx component UnsupportedComponent in attribute",
3094
+ },
3095
+ {
3096
+ "line": 3,
3097
+ "message": "Failed to evaluate expression attribute: icon={<UnsupportedComponent />}",
3098
+ },
3099
+ {
3100
+ "line": 3,
3101
+ "message": "Expressions in jsx prop not evaluated: (icon={<UnsupportedComponent />})",
3102
+ },
3103
+ ]
3104
+ `)
3105
+
3106
+ // Should still render the rest of the content
3107
+ expect(html).toMatchInlineSnapshot(`"<h1>Error Handling Test</h1><h1><p>Hello World</p></h1>"`)
3108
+
3109
+ expect(result).toMatchInlineSnapshot(`
3110
+ <React.Fragment>
3111
+ <h1>
3112
+ Error Handling Test
3113
+ </h1>
3114
+ <Heading
3115
+ level={1}
3116
+ >
3117
+ <p>
3118
+ Hello World
3119
+ </p>
3120
+ </Heading>
3121
+ </React.Fragment>
3122
+ `)
3123
+ })