safe-mdx 1.4.0 → 1.6.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.
Files changed (43) hide show
  1. package/README.md +275 -13
  2. package/dist/dynamic-esm-component.d.ts +1 -1
  3. package/dist/dynamic-esm-component.d.ts.map +1 -1
  4. package/dist/dynamic-esm-component.js +9 -1
  5. package/dist/dynamic-esm-component.js.map +1 -1
  6. package/dist/esm-parser.d.ts +1 -1
  7. package/dist/esm-parser.d.ts.map +1 -1
  8. package/dist/esm-parser.js +5 -3
  9. package/dist/esm-parser.js.map +1 -1
  10. package/dist/esm-parser.test.js +5 -2
  11. package/dist/esm-parser.test.js.map +1 -1
  12. package/dist/html/html-and-md.test.js.map +1 -1
  13. package/dist/html/html-to-mdx-ast.d.ts +1 -1
  14. package/dist/html/html-to-mdx-ast.js +4 -4
  15. package/dist/html/html-to-mdx-ast.js.map +1 -1
  16. package/dist/html/html-to-mdx-ast.test.js +3 -3
  17. package/dist/html/html-to-mdx-ast.test.js.map +1 -1
  18. package/dist/parse.d.ts +1 -1
  19. package/dist/parse.d.ts.map +1 -1
  20. package/dist/parse.js +5 -1
  21. package/dist/parse.js.map +1 -1
  22. package/dist/safe-mdx.bench.js +2 -2
  23. package/dist/safe-mdx.bench.js.map +1 -1
  24. package/dist/safe-mdx.d.ts +45 -3
  25. package/dist/safe-mdx.d.ts.map +1 -1
  26. package/dist/safe-mdx.js +62 -36
  27. package/dist/safe-mdx.js.map +1 -1
  28. package/dist/safe-mdx.test.js +221 -5
  29. package/dist/safe-mdx.test.js.map +1 -1
  30. package/dist/streaming.d.ts.map +1 -1
  31. package/dist/streaming.js +3 -1
  32. package/dist/streaming.js.map +1 -1
  33. package/package.json +30 -7
  34. package/src/esm-parser.test.ts +6 -3
  35. package/src/esm-parser.ts +6 -4
  36. package/src/html/html-and-md.test.ts +2 -2
  37. package/src/html/html-to-mdx-ast.test.ts +3 -3
  38. package/src/html/html-to-mdx-ast.ts +4 -4
  39. package/src/parse.ts +3 -1
  40. package/src/safe-mdx.bench.tsx +2 -2
  41. package/src/safe-mdx.test.tsx +251 -11
  42. package/src/safe-mdx.tsx +109 -36
  43. package/src/streaming.tsx +2 -1
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "safe-mdx",
3
- "version": "1.4.0",
4
- "private": false,
5
- "description": "Render MDX in React without eval",
6
- "repository": "https://github.com/holocron-hq/safe-mdx",
3
+ "version": "1.6.0",
4
+ "description": "Render MDX in React without eval, works in Cloudflare Workers and Vercel Edge",
7
5
  "type": "module",
6
+ "main": "./dist/safe-mdx.js",
8
7
  "types": "./dist/safe-mdx.d.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/holocron-hq/safe-mdx"
11
+ },
12
+ "homepage": "https://github.com/holocron-hq/safe-mdx",
13
+ "bugs": "https://github.com/holocron-hq/safe-mdx/issues",
9
14
  "exports": {
15
+ "./package.json": "./package.json",
10
16
  ".": {
11
17
  "types": "./dist/safe-mdx.d.ts",
12
18
  "default": "./dist/safe-mdx.js"
@@ -24,15 +30,29 @@
24
30
  "browser": "./dist/html/domparser-browser.js",
25
31
  "default": "./dist/html/domparser.js"
26
32
  },
27
- "./src/*": "./src/*.tsx",
28
- "./package.json": "./package.json"
33
+ "./src": {
34
+ "types": "./src/safe-mdx.tsx",
35
+ "default": "./src/safe-mdx.tsx"
36
+ },
37
+ "./src/*": {
38
+ "types": "./src/*.tsx",
39
+ "default": "./src/*.tsx"
40
+ }
29
41
  },
30
42
  "files": [
31
43
  "dist",
32
44
  "src"
33
45
  ],
34
46
  "keywords": [
35
- "mdx"
47
+ "mdx",
48
+ "react",
49
+ "server-components",
50
+ "rsc",
51
+ "safe",
52
+ "no-eval",
53
+ "cloudflare-workers",
54
+ "edge",
55
+ "markdown"
36
56
  ],
37
57
  "author": "remorses <beats.by.morse@gmail.com>",
38
58
  "license": "MIT",
@@ -59,6 +79,7 @@
59
79
  "@changesets/cli": "^2.28.1",
60
80
  "@tailwindcss/typography": "^0.5.16",
61
81
  "@tailwindcss/vite": "^4.1.11",
82
+ "@types/escodegen": "^0.0.10",
62
83
  "@types/estree-jsx": "^1.0.5",
63
84
  "@types/mdast": "^4.0.0",
64
85
  "@types/node": "^22.15.17",
@@ -68,12 +89,14 @@
68
89
  "@vitejs/plugin-react": "^4.7.0",
69
90
  "@vitest/coverage-v8": "^1.6.0",
70
91
  "dedent": "^1.5.1",
92
+ "escodegen": "^2.1.0",
71
93
  "importmap-vite-plugin": "^0.0.0",
72
94
  "mdast-util-mdx-jsx": "^3.2.0",
73
95
  "react": "^19.2.1",
74
96
  "react-dom": "^19.2.1",
75
97
  "remark-parse": "^11.0.0",
76
98
  "remark-stringify": "^11.0.0",
99
+ "rimraf": "^6.0.1",
77
100
  "tailwindcss": "^4.1.11",
78
101
  "typescript": "5.8.3",
79
102
  "vite": "^6.2.6",
@@ -1,7 +1,7 @@
1
1
  import { expect, test, describe } from 'vitest'
2
- import { parseEsmImports, extractComponentInfo } from './esm-parser.js'
3
- import { mdxParse, extractImports, resolveModulePath } from './parse.js'
4
- import type { SafeMdxError } from './safe-mdx.js'
2
+ import { parseEsmImports, extractComponentInfo } from './esm-parser.ts'
3
+ import { mdxParse, extractImports, resolveModulePath } from './parse.ts'
4
+ import type { SafeMdxError } from './safe-mdx.tsx'
5
5
 
6
6
  describe('parseEsmImports', () => {
7
7
  test('parses default imports from HTTPS URLs', () => {
@@ -67,14 +67,17 @@ import Component3 from './relative/path'
67
67
  {
68
68
  "line": 2,
69
69
  "message": "Invalid import URL: "http://insecure.com/component". Only HTTPS URLs are allowed for security reasons.",
70
+ "type": "esm-import",
70
71
  },
71
72
  {
72
73
  "line": 2,
73
74
  "message": "Invalid import URL: "file:///local/path". Only HTTPS URLs are allowed for security reasons.",
75
+ "type": "esm-import",
74
76
  },
75
77
  {
76
78
  "line": 2,
77
79
  "message": "Invalid import URL: "./relative/path". Only HTTPS URLs are allowed for security reasons.",
80
+ "type": "esm-import",
78
81
  },
79
82
  ]
80
83
  `)
package/src/esm-parser.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SafeMdxError } from './safe-mdx.js'
1
+ import type { SafeMdxError } from './safe-mdx.tsx'
2
2
 
3
3
  export interface ParsedImport {
4
4
  componentName: string
@@ -45,6 +45,7 @@ export function parseEsmImports(
45
45
  // Validate URL
46
46
  if (!isValidHttpsUrl(importUrl)) {
47
47
  onError({
48
+ type: 'esm-import',
48
49
  message: `Invalid import URL: "${importUrl}". Only HTTPS URLs are allowed for security reasons.`,
49
50
  line: node.position?.start?.line,
50
51
  })
@@ -69,6 +70,7 @@ export function parseEsmImports(
69
70
  }
70
71
  } catch (error) {
71
72
  onError({
73
+ type: 'esm-import',
72
74
  message: `Failed to parse ESM import: ${error instanceof Error ? error.message : 'Unknown error'}`,
73
75
  line: node.position?.start?.line,
74
76
  })
@@ -81,9 +83,9 @@ export function parseEsmImports(
81
83
  * Extracts component info from an import map entry
82
84
  */
83
85
  export function extractComponentInfo(importInfo: string): { importUrl: string; componentName: string } {
84
- const [importUrl, componentName] = importInfo.includes('#')
85
- ? importInfo.split('#')
86
- : [importInfo, 'default']
86
+ const parts = importInfo.split('#')
87
+ const importUrl = parts[0] ?? importInfo
88
+ const componentName = importInfo.includes('#') ? (parts[1] ?? 'default') : 'default'
87
89
 
88
90
  return { importUrl, componentName }
89
91
  }
@@ -55,7 +55,7 @@ async function htmlToMdxString({
55
55
  parent &&
56
56
  typeof index === 'number'
57
57
  ) {
58
- const htmlValue = node.value as string
58
+ const htmlValue = node.value
59
59
 
60
60
  // Parse HTML to MDX AST with processor for markdown parsing
61
61
  const mdxNodes = parseHtmlToMdxAst({
@@ -77,7 +77,7 @@ async function htmlToMdxString({
77
77
 
78
78
  // Replace the HTML node with the MDX nodes
79
79
  if (mdxNodes.length === 1) {
80
- parent.children[index] = mdxNodes[0]
80
+ parent.children[index] = mdxNodes[0]!
81
81
  } else if (mdxNodes.length > 1) {
82
82
  parent.children.splice(index, 1, ...mdxNodes)
83
83
  } else {
@@ -5,14 +5,14 @@ import {
5
5
  parseHtmlToMdxAst,
6
6
  htmlToMdxAst,
7
7
  remarkMdxJsxNormalize,
8
- } from './html-to-mdx-ast.js'
8
+ } from './html-to-mdx-ast.ts'
9
9
  import { unified } from 'unified'
10
10
  import remarkMdx from 'remark-mdx'
11
11
  import remarkStringify from 'remark-stringify'
12
12
  import remarkParse from 'remark-parse'
13
13
  import type { RootContent } from 'mdast'
14
- import { mdxParse } from '../parse.js'
15
- import { MdastToJsx } from '../safe-mdx.js'
14
+ import { mdxParse } from '../parse.ts'
15
+ import { MdastToJsx } from '../safe-mdx.tsx'
16
16
 
17
17
  // Default components for testing
18
18
  const components = {
@@ -6,9 +6,9 @@ import type {
6
6
  } from 'mdast-util-mdx-jsx'
7
7
  import type { Processor } from 'unified'
8
8
  import { unified } from 'unified'
9
- import { convertAttributeNameToJSX } from './convert-attributes.js'
10
- import { parseHTML } from './domparser.js'
11
- import { remarkMdxJsxNormalize } from './remark-mdx-jsx-normalize.js'
9
+ import { convertAttributeNameToJSX } from './convert-attributes.ts'
10
+ import { parseHTML } from './domparser.ts'
11
+ import { remarkMdxJsxNormalize } from './remark-mdx-jsx-normalize.ts'
12
12
 
13
13
  // Re-export the normalize plugin
14
14
  export { remarkMdxJsxNormalize }
@@ -77,7 +77,7 @@ function deindent(text: string): string {
77
77
  for (const line of lines) {
78
78
  if (line.trim()) {
79
79
  const match = line.match(/^(\s*)/)
80
- if (match) {
80
+ if (match?.[1] != null) {
81
81
  minIndent = Math.min(minIndent, match[1].length)
82
82
  }
83
83
  }
package/src/parse.ts CHANGED
@@ -5,7 +5,7 @@ import { Root, RootContent } from 'mdast'
5
5
  import { remark } from 'remark'
6
6
  import remarkGfm from 'remark-gfm'
7
7
  import remarkMdx from 'remark-mdx'
8
- import { parseHtmlToMdxAst, remarkMdxJsxNormalize } from './html/html-to-mdx-ast.js'
8
+ import { parseHtmlToMdxAst, remarkMdxJsxNormalize } from './html/html-to-mdx-ast.ts'
9
9
 
10
10
  export { parseHtmlToMdxAst, remarkMdxJsxNormalize }
11
11
 
@@ -95,6 +95,7 @@ export function remarkMarkAndUnravel() {
95
95
 
96
96
  while (++offset < children.length) {
97
97
  const child = children[offset]
98
+ if (!child) continue
98
99
 
99
100
  if (
100
101
  child.type === 'mdxJsxTextElement' ||
@@ -122,6 +123,7 @@ export function remarkMarkAndUnravel() {
122
123
 
123
124
  while (++offset < children.length) {
124
125
  const child = children[offset]
126
+ if (!child) continue
125
127
 
126
128
  if (child.type === 'mdxJsxTextElement') {
127
129
  // @ts-expect-error: mutate because it is faster; content model is fine.
@@ -1,6 +1,6 @@
1
1
  import { bench, describe } from 'vitest'
2
- import { mdxParse } from './parse.js'
3
- import { MdastToJsx } from './safe-mdx.js'
2
+ import { mdxParse } from './parse.ts'
3
+ import { MdastToJsx } from './safe-mdx.tsx'
4
4
 
5
5
  let longMdxContent = await fetch(
6
6
  'https://raw.githubusercontent.com/colinhacks/zod/0a49fa39348b7c72b19ddedc3b0f879bd395304b/packages/docs/content/packages/v3.mdx',
@@ -1,11 +1,12 @@
1
1
  import dedent from 'dedent'
2
+ import { generate } from 'escodegen'
2
3
  import React from 'react'
3
4
  import { renderToStaticMarkup } from 'react-dom/server'
4
5
  import { expect, test } from 'vitest'
5
6
  import { z } from 'zod'
6
- import { mdxParse } from './parse.js'
7
- import { MdastToJsx, mdastBfs, type ComponentPropsSchema } from './safe-mdx.js'
8
- import { completeJsxTags } from './streaming.js'
7
+ import { mdxParse } from './parse.ts'
8
+ import { MdastToJsx, mdastBfs, type ComponentPropsSchema, type EvaluateOptions } from './safe-mdx.tsx'
9
+ import { completeJsxTags } from './streaming.tsx'
9
10
 
10
11
  const components = {
11
12
  Heading({ level, children, ...props }) {
@@ -19,9 +20,9 @@ const components = {
19
20
  },
20
21
  }
21
22
 
22
- function render(code, componentPropsSchema?: ComponentPropsSchema, allowClientEsmImports?: boolean, addMarkdownLineNumbers?: boolean) {
23
+ function render(code, componentPropsSchema?: ComponentPropsSchema, allowClientEsmImports?: boolean, addMarkdownLineNumbers?: boolean, scope?: Record<string, any>, evaluateOptions?: EvaluateOptions) {
23
24
  const mdast = mdxParse(code)
24
- const visitor = new MdastToJsx({ markdown: code, mdast, components, componentPropsSchema, allowClientEsmImports, addMarkdownLineNumbers })
25
+ const visitor = new MdastToJsx({ markdown: code, mdast, components, componentPropsSchema, allowClientEsmImports, addMarkdownLineNumbers, scope, evaluateOptions })
25
26
  const result = visitor.run()
26
27
  const html = renderToStaticMarkup(result)
27
28
  // console.log(JSON.stringify(result, null, 2))
@@ -483,6 +484,7 @@ test('missing components are ignored', () => {
483
484
  {
484
485
  "line": 1,
485
486
  "message": "Unsupported jsx component MissingComponent",
487
+ "type": "missing-component",
486
488
  },
487
489
  ],
488
490
  "html": "",
@@ -520,22 +522,27 @@ test('props parsing', () => {
520
522
  {
521
523
  "line": 8,
522
524
  "message": "Failed to evaluate expression attribute: expression2={Boolean(1)}. Functions are not supported",
525
+ "type": "expression",
523
526
  },
524
527
  {
525
528
  "line": 8,
526
529
  "message": "Expressions in jsx prop not evaluated: (expression2={Boolean(1)})",
530
+ "type": "expression",
527
531
  },
528
532
  {
529
533
  "line": 9,
530
534
  "message": "Unsupported jsx component SomeComponent in attribute",
535
+ "type": "missing-component",
531
536
  },
532
537
  {
533
538
  "line": 9,
534
539
  "message": "Failed to evaluate expression attribute: jsx={<SomeComponent />}. visitor "JSXElement" is not supported",
540
+ "type": "expression",
535
541
  },
536
542
  {
537
543
  "line": 9,
538
544
  "message": "Expressions in jsx prop not evaluated: (jsx={<SomeComponent />})",
545
+ "type": "expression",
539
546
  },
540
547
  ],
541
548
  "html": "<h1 num="2" doublequote="a &quot; string" quote="a &#x27; string" backTick="some undefined value" expression1="4" someJson="[object Object]"><p>hi</p></h1>",
@@ -2292,8 +2299,8 @@ test('mdx jsx with unknown components are ignored', () => {
2292
2299
 
2293
2300
  // Check that errors were generated for unknown components
2294
2301
  expect(errors).toHaveLength(2)
2295
- expect(errors[0].message).toContain('Unsupported jsx component CustomElement')
2296
- expect(errors[1].message).toContain('Unsupported jsx component AnotherUnknown')
2302
+ expect(errors[0]!.message).toContain('Unsupported jsx component CustomElement')
2303
+ expect(errors[1]!.message).toContain('Unsupported jsx component AnotherUnknown')
2297
2304
 
2298
2305
  expect(result).toMatchInlineSnapshot(`
2299
2306
  <React.Fragment>
@@ -2531,16 +2538,19 @@ test('component props schema validation with zod', () => {
2531
2538
  "line": 5,
2532
2539
  "message": "Invalid props for component "Heading" at "level": Too big: expected number to be <=6",
2533
2540
  "schemaPath": "level",
2541
+ "type": "validation",
2534
2542
  },
2535
2543
  {
2536
2544
  "line": 7,
2537
2545
  "message": "Invalid props for component "Cards" at "count": Too small: expected number to be >0",
2538
2546
  "schemaPath": "count",
2547
+ "type": "validation",
2539
2548
  },
2540
2549
  {
2541
2550
  "line": 9,
2542
2551
  "message": "Invalid props for component "Cards" at "count": Invalid input: expected number, received string",
2543
2552
  "schemaPath": "count",
2553
+ "type": "validation",
2544
2554
  },
2545
2555
  ],
2546
2556
  "html": "<h1 title="test">Valid heading</h1><div count="3" variant="outline">Valid cards</div><h1 title="test">Invalid heading - level too high</h1><div count="-1">Invalid cards - negative count</div><div count="not a number">Invalid cards - wrong type</div>",
@@ -2578,6 +2588,66 @@ test('component props schema validation with zod', () => {
2578
2588
  `)
2579
2589
  })
2580
2590
 
2591
+ test('onError callback is called for each error', () => {
2592
+ const errors: any[] = []
2593
+ const code = dedent`
2594
+ <Missing>should error</Missing>
2595
+
2596
+ <Heading level={10}>invalid level</Heading>
2597
+ `
2598
+ const componentPropsSchema: ComponentPropsSchema = {
2599
+ Heading: z.object({
2600
+ level: z.number().min(1).max(6),
2601
+ }),
2602
+ }
2603
+ const mdast = mdxParse(code)
2604
+ const visitor = new MdastToJsx({
2605
+ markdown: code,
2606
+ mdast,
2607
+ components,
2608
+ componentPropsSchema,
2609
+ onError: (err) => errors.push(err),
2610
+ })
2611
+ visitor.run()
2612
+ expect(errors).toMatchInlineSnapshot(`
2613
+ [
2614
+ {
2615
+ "line": 1,
2616
+ "message": "Unsupported jsx component Missing",
2617
+ "type": "missing-component",
2618
+ },
2619
+ {
2620
+ "line": 3,
2621
+ "message": "Invalid props for component "Heading" at "level": Too big: expected number to be <=6",
2622
+ "schemaPath": "level",
2623
+ "type": "validation",
2624
+ },
2625
+ ]
2626
+ `)
2627
+ // errors array and onError callback should have the same errors
2628
+ expect(visitor.errors).toEqual(errors)
2629
+ })
2630
+
2631
+ test('onError callback can throw to stop rendering', () => {
2632
+ const code = dedent`
2633
+ <Missing>should throw</Missing>
2634
+
2635
+ <Heading>should not reach</Heading>
2636
+ `
2637
+ const mdast = mdxParse(code)
2638
+ expect(() => {
2639
+ const visitor = new MdastToJsx({
2640
+ markdown: code,
2641
+ mdast,
2642
+ components,
2643
+ onError: (err) => {
2644
+ throw new Error(`MDX error on line ${err.line}: ${err.message}`)
2645
+ },
2646
+ })
2647
+ visitor.run()
2648
+ }).toThrow('MDX error on line 1: Unsupported jsx component Missing')
2649
+ })
2650
+
2581
2651
  test('mdx expressions evaluation', () => {
2582
2652
  expect(
2583
2653
  render(dedent`
@@ -2635,10 +2705,12 @@ test('mdx expressions with unsupported functions', () => {
2635
2705
  {
2636
2706
  "line": 1,
2637
2707
  "message": "Failed to evaluate expression: Math.max(5, 10). Functions are not supported",
2708
+ "type": "expression",
2638
2709
  },
2639
2710
  {
2640
2711
  "line": 2,
2641
2712
  "message": "Failed to evaluate expression: console.log("test"). Functions are not supported",
2713
+ "type": "expression",
2642
2714
  },
2643
2715
  ],
2644
2716
  "html": "<p>Math function:
@@ -2750,11 +2822,13 @@ test('validation error includes schema path', () => {
2750
2822
  "line": 1,
2751
2823
  "message": "Invalid props for component "Heading" at "user.age": Too small: expected number to be >=0",
2752
2824
  "schemaPath": "user.age",
2825
+ "type": "validation",
2753
2826
  },
2754
2827
  {
2755
2828
  "line": 1,
2756
2829
  "message": "Invalid props for component "Heading" at "settings.theme": Invalid option: expected one of "light"|"dark"",
2757
2830
  "schemaPath": "settings.theme",
2831
+ "type": "validation",
2758
2832
  },
2759
2833
  ],
2760
2834
  "html": "<h1 user="[object Object]" settings="[object Object]">Complex validation</h1>",
@@ -2887,6 +2961,7 @@ test('mdxJsxExpressionAttribute edge cases', () => {
2887
2961
  {
2888
2962
  "line": 3,
2889
2963
  "message": "Failed to evaluate expression attribute: ...{null: null, undefined: undefined}. undefined is undefined",
2964
+ "type": "expression",
2890
2965
  },
2891
2966
  ],
2892
2967
  "html": "<h1 title="empty spread">Empty spread</h1><h1 title="null/undefined values">Null/undefined</h1><h1 array="1,2,3" object="[object Object]" title="complex values">Complex types</h1>",
@@ -2980,12 +3055,12 @@ test('ESM imports error handling', () => {
2980
3055
  expect(visitor.errors.length).toBe(4)
2981
3056
 
2982
3057
  // First two errors are for invalid imports
2983
- expect(visitor.errors[0].message).toContain('Invalid import URL')
2984
- expect(visitor.errors[1].message).toContain('Invalid import URL')
3058
+ expect(visitor.errors[0]!.message).toContain('Invalid import URL')
3059
+ expect(visitor.errors[1]!.message).toContain('Invalid import URL')
2985
3060
 
2986
3061
  // Last two errors are for unsupported components
2987
- expect(visitor.errors[2].message).toContain('Unsupported jsx component Button')
2988
- expect(visitor.errors[3].message).toContain('Unsupported jsx component Component')
3062
+ expect(visitor.errors[2]!.message).toContain('Unsupported jsx component Button')
3063
+ expect(visitor.errors[3]!.message).toContain('Unsupported jsx component Component')
2989
3064
  })
2990
3065
 
2991
3066
  test('jsx components in attributes', () => {
@@ -3143,14 +3218,17 @@ test("jsx components in attributes error handling", () => {
3143
3218
  {
3144
3219
  "line": 3,
3145
3220
  "message": "Unsupported jsx component UnsupportedComponent in attribute",
3221
+ "type": "missing-component",
3146
3222
  },
3147
3223
  {
3148
3224
  "line": 3,
3149
3225
  "message": "Failed to evaluate expression attribute: icon={<UnsupportedComponent />}. visitor "JSXElement" is not supported",
3226
+ "type": "expression",
3150
3227
  },
3151
3228
  {
3152
3229
  "line": 3,
3153
3230
  "message": "Expressions in jsx prop not evaluated: (icon={<UnsupportedComponent />})",
3231
+ "type": "expression",
3154
3232
  },
3155
3233
  ]
3156
3234
  `)
@@ -3609,7 +3687,169 @@ test('modules prop: unresolved import produces error', () => {
3609
3687
  {
3610
3688
  "line": 3,
3611
3689
  "message": "Unsupported jsx component Missing",
3690
+ "type": "missing-component",
3612
3691
  },
3613
3692
  ]
3614
3693
  `)
3615
3694
  })
3695
+
3696
+ test('modules prop: rendering a subset of mdast with import nodes prepended resolves components', () => {
3697
+ // Simulates the pattern used by holocron: the full mdast is split into
3698
+ // sections, and import nodes (mdxjsEsm) are prepended to each section's
3699
+ // nodes so SafeMdxRenderer can resolve imported components.
3700
+ function CustomHero({ title }: { title: string }) {
3701
+ return <div data-testid="hero">{title}</div>
3702
+ }
3703
+
3704
+ const code = dedent`
3705
+ import { CustomHero } from './components/hero'
3706
+
3707
+ # Main content
3708
+
3709
+ <CustomHero title="Hello" />
3710
+ `
3711
+ const mdast = mdxParse(code)
3712
+
3713
+ // Split: extract import nodes and content nodes separately
3714
+ const importNodes = mdast.children.filter((node) => node.type === 'mdxjsEsm')
3715
+ const contentNodes = mdast.children.filter((node) => node.type !== 'mdxjsEsm')
3716
+
3717
+ // Render only content nodes WITH import nodes prepended
3718
+ const syntheticRoot = { type: 'root' as const, children: [...importNodes, ...contentNodes] }
3719
+ const visitor = new MdastToJsx({
3720
+ markdown: code,
3721
+ mdast: syntheticRoot,
3722
+ components,
3723
+ modules: {
3724
+ './components/hero.tsx': { CustomHero },
3725
+ },
3726
+ baseUrl: './',
3727
+ })
3728
+ const result = visitor.run()
3729
+ const html = renderToStaticMarkup(result)
3730
+ expect(html).toContain('data-testid="hero"')
3731
+ expect(html).toContain('Hello')
3732
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`)
3733
+ })
3734
+
3735
+ test('modules prop: rendering subset WITHOUT import nodes fails to resolve components', () => {
3736
+ // Demonstrates the bug: if import nodes are NOT prepended, the component
3737
+ // is not found and a missing-component error is produced.
3738
+ function CustomHero({ title }: { title: string }) {
3739
+ return <div data-testid="hero">{title}</div>
3740
+ }
3741
+
3742
+ const code = dedent`
3743
+ import { CustomHero } from './components/hero'
3744
+
3745
+ <CustomHero title="Hello" />
3746
+ `
3747
+ const mdast = mdxParse(code)
3748
+
3749
+ // Only render the JSX node WITHOUT the import node
3750
+ const contentNodes = mdast.children.filter((node) => node.type !== 'mdxjsEsm')
3751
+ const syntheticRoot = { type: 'root' as const, children: contentNodes }
3752
+ const visitor = new MdastToJsx({
3753
+ markdown: code,
3754
+ mdast: syntheticRoot,
3755
+ components,
3756
+ modules: {
3757
+ './components/hero.tsx': { CustomHero },
3758
+ },
3759
+ baseUrl: './',
3760
+ })
3761
+ const result = visitor.run()
3762
+ const html = renderToStaticMarkup(result)
3763
+ // Without import nodes, the component is not resolved
3764
+ expect(html).not.toContain('data-testid="hero"')
3765
+ expect(visitor.errors).toMatchInlineSnapshot(`
3766
+ [
3767
+ {
3768
+ "line": 3,
3769
+ "message": "Unsupported jsx component CustomHero",
3770
+ "type": "missing-component",
3771
+ },
3772
+ ]
3773
+ `)
3774
+ })
3775
+
3776
+ test('scope with function in jsx prop receiving object arg', () => {
3777
+ const scope = {
3778
+ formatTitle: (opts: { text: string; uppercase?: boolean }) => {
3779
+ return opts.uppercase ? opts.text.toUpperCase() : opts.text
3780
+ },
3781
+ }
3782
+
3783
+ const code = dedent`
3784
+ <Heading level={1} title={formatTitle({ text: "hello world", uppercase: true })}>Content</Heading>
3785
+ `
3786
+
3787
+ const { html, errors } = render(code, undefined, undefined, undefined, scope)
3788
+ expect(errors).toMatchInlineSnapshot(`[]`)
3789
+ expect(html).toMatchInlineSnapshot(`"<h1 title="HELLO WORLD">Content</h1>"`)
3790
+ })
3791
+
3792
+ test('scope with variables and functions in inline expressions', () => {
3793
+ const scope = {
3794
+ greeting: 'Hello',
3795
+ getName: (user: { first: string; last: string }) => `${user.first} ${user.last}`,
3796
+ }
3797
+
3798
+ const code = dedent`
3799
+ {greeting} {getName({ first: "John", last: "Doe" })}
3800
+ `
3801
+
3802
+ const { html, errors } = render(code, undefined, undefined, undefined, scope)
3803
+ expect(errors).toMatchInlineSnapshot(`[]`)
3804
+ expect(html).toMatchInlineSnapshot(`"HelloJohn Doe"`)
3805
+ })
3806
+
3807
+ test('scope with function in spread attribute', () => {
3808
+ const scope = {
3809
+ getProps: (config: { level: number }) => ({ level: config.level }),
3810
+ }
3811
+
3812
+ const code = dedent`
3813
+ <Heading {...getProps({ level: 2 })}>Spread test</Heading>
3814
+ `
3815
+
3816
+ const { html, errors } = render(code, undefined, undefined, undefined, scope)
3817
+ expect(errors).toMatchInlineSnapshot(`[]`)
3818
+ expect(html).toMatchInlineSnapshot(`"<h1>Spread test</h1>"`)
3819
+ })
3820
+
3821
+ test('scope with .map and arrow function callback fails without generate', () => {
3822
+ const scope = {
3823
+ items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }],
3824
+ }
3825
+
3826
+ const code = dedent`
3827
+ {items.map(item => item.name).join(", ")}
3828
+ `
3829
+
3830
+ const { html, errors } = render(code, undefined, undefined, undefined, scope)
3831
+ expect(errors).toMatchInlineSnapshot(`
3832
+ [
3833
+ {
3834
+ "line": 1,
3835
+ "message": "Failed to evaluate expression: items.map(item => item.name).join(", "). Expected options.generate to be the "generate" function from "escodegen"",
3836
+ "type": "expression",
3837
+ },
3838
+ ]
3839
+ `)
3840
+ expect(html).toMatchInlineSnapshot(`""`)
3841
+ })
3842
+
3843
+ test('scope with .map and arrow function callback works with generate', () => {
3844
+ const scope = {
3845
+ items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }],
3846
+ }
3847
+
3848
+ const code = dedent`
3849
+ {items.map(item => item.name).join(", ")}
3850
+ `
3851
+
3852
+ const { html, errors } = render(code, undefined, undefined, undefined, scope, { generate })
3853
+ expect(errors).toMatchInlineSnapshot(`[]`)
3854
+ expect(html).toMatchInlineSnapshot(`"Alice, Bob, Charlie"`)
3855
+ })