safe-mdx 1.5.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 (42) hide show
  1. package/README.md +84 -8
  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 +3 -3
  9. package/dist/esm-parser.js.map +1 -1
  10. package/dist/esm-parser.test.js +2 -2
  11. package/dist/html/html-and-md.test.js.map +1 -1
  12. package/dist/html/html-to-mdx-ast.d.ts +1 -1
  13. package/dist/html/html-to-mdx-ast.js +4 -4
  14. package/dist/html/html-to-mdx-ast.js.map +1 -1
  15. package/dist/html/html-to-mdx-ast.test.js +3 -3
  16. package/dist/html/html-to-mdx-ast.test.js.map +1 -1
  17. package/dist/parse.d.ts +1 -1
  18. package/dist/parse.d.ts.map +1 -1
  19. package/dist/parse.js +5 -1
  20. package/dist/parse.js.map +1 -1
  21. package/dist/safe-mdx.bench.js +2 -2
  22. package/dist/safe-mdx.bench.js.map +1 -1
  23. package/dist/safe-mdx.d.ts +35 -3
  24. package/dist/safe-mdx.d.ts.map +1 -1
  25. package/dist/safe-mdx.js +35 -29
  26. package/dist/safe-mdx.js.map +1 -1
  27. package/dist/safe-mdx.test.js +147 -5
  28. package/dist/safe-mdx.test.js.map +1 -1
  29. package/dist/streaming.d.ts.map +1 -1
  30. package/dist/streaming.js +3 -1
  31. package/dist/streaming.js.map +1 -1
  32. package/package.json +30 -7
  33. package/src/esm-parser.test.ts +3 -3
  34. package/src/esm-parser.ts +4 -4
  35. package/src/html/html-and-md.test.ts +2 -2
  36. package/src/html/html-to-mdx-ast.test.ts +3 -3
  37. package/src/html/html-to-mdx-ast.ts +4 -4
  38. package/src/parse.ts +3 -1
  39. package/src/safe-mdx.bench.tsx +2 -2
  40. package/src/safe-mdx.test.tsx +175 -11
  41. package/src/safe-mdx.tsx +70 -29
  42. package/src/streaming.tsx +2 -1
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "safe-mdx",
3
- "version": "1.5.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', () => {
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
@@ -83,9 +83,9 @@ export function parseEsmImports(
83
83
  * Extracts component info from an import map entry
84
84
  */
85
85
  export function extractComponentInfo(importInfo: string): { importUrl: string; componentName: string } {
86
- const [importUrl, componentName] = importInfo.includes('#')
87
- ? importInfo.split('#')
88
- : [importInfo, 'default']
86
+ const parts = importInfo.split('#')
87
+ const importUrl = parts[0] ?? importInfo
88
+ const componentName = importInfo.includes('#') ? (parts[1] ?? 'default') : 'default'
89
89
 
90
90
  return { importUrl, componentName }
91
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))
@@ -531,6 +532,7 @@ test('props parsing', () => {
531
532
  {
532
533
  "line": 9,
533
534
  "message": "Unsupported jsx component SomeComponent in attribute",
535
+ "type": "missing-component",
534
536
  },
535
537
  {
536
538
  "line": 9,
@@ -2297,8 +2299,8 @@ test('mdx jsx with unknown components are ignored', () => {
2297
2299
 
2298
2300
  // Check that errors were generated for unknown components
2299
2301
  expect(errors).toHaveLength(2)
2300
- expect(errors[0].message).toContain('Unsupported jsx component CustomElement')
2301
- 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')
2302
2304
 
2303
2305
  expect(result).toMatchInlineSnapshot(`
2304
2306
  <React.Fragment>
@@ -3053,12 +3055,12 @@ test('ESM imports error handling', () => {
3053
3055
  expect(visitor.errors.length).toBe(4)
3054
3056
 
3055
3057
  // First two errors are for invalid imports
3056
- expect(visitor.errors[0].message).toContain('Invalid import URL')
3057
- 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')
3058
3060
 
3059
3061
  // Last two errors are for unsupported components
3060
- expect(visitor.errors[2].message).toContain('Unsupported jsx component Button')
3061
- 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')
3062
3064
  })
3063
3065
 
3064
3066
  test('jsx components in attributes', () => {
@@ -3216,6 +3218,7 @@ test("jsx components in attributes error handling", () => {
3216
3218
  {
3217
3219
  "line": 3,
3218
3220
  "message": "Unsupported jsx component UnsupportedComponent in attribute",
3221
+ "type": "missing-component",
3219
3222
  },
3220
3223
  {
3221
3224
  "line": 3,
@@ -3689,3 +3692,164 @@ test('modules prop: unresolved import produces error', () => {
3689
3692
  ]
3690
3693
  `)
3691
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
+ })
package/src/safe-mdx.tsx CHANGED
@@ -8,10 +8,10 @@ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
8
8
 
9
9
  import { Fragment, ReactNode } from 'react'
10
10
  import { DynamicEsmComponent } from 'safe-mdx/client'
11
- import { extractComponentInfo, parseEsmImports } from './esm-parser.js'
12
- import { resolveModulePath, type EagerModules } from './parse.js'
13
- import { htmlToMdxAst } from './html/html-to-mdx-ast.js'
14
- import { validHtmlElements, nativeTags } from './html/valid-html-elements.js'
11
+ import { extractComponentInfo, parseEsmImports } from './esm-parser.ts'
12
+ import { resolveModulePath, type EagerModules } from './parse.ts'
13
+ import { htmlToMdxAst } from './html/html-to-mdx-ast.ts'
14
+ import { validHtmlElements, nativeTags } from './html/valid-html-elements.ts'
15
15
 
16
16
  export type MyRootContent = RootContent | Root
17
17
 
@@ -46,6 +46,18 @@ export type CreateElementFunction = (
46
46
  ...children: ReactNode[]
47
47
  ) => ReactNode
48
48
 
49
+ export interface EvaluateOptions {
50
+ /** Enable function calls in expressions. Automatically enabled when `scope` is provided. */
51
+ functions?: boolean
52
+ /** Pass `escodegen.generate` to support inline function expressions
53
+ * like arrow functions in `.map(x => x.name)`. Requires `functions: true`. */
54
+ generate?: (ast: any) => string
55
+ /** Force logical operators (`&&`, `||`) to return booleans. */
56
+ booleanLogicalOperators?: boolean
57
+ /** Throw when variables referenced in expressions are undefined. */
58
+ strict?: boolean
59
+ }
60
+
49
61
  export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
50
62
  components,
51
63
  markdown = '',
@@ -58,6 +70,8 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
58
70
  modules,
59
71
  baseUrl,
60
72
  onError,
73
+ scope,
74
+ evaluateOptions,
61
75
  }: {
62
76
  components?: ComponentsMap
63
77
  markdown?: string
@@ -77,6 +91,15 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
77
91
  /** Called for each error during rendering (missing components, invalid props, failed expressions).
78
92
  * Throw inside this callback to stop rendering on first error. */
79
93
  onError?: (error: SafeMdxError) => void
94
+ /** Variables and functions available in MDX expressions.
95
+ * When scope contains functions, function calls in expressions are
96
+ * automatically enabled. */
97
+ scope?: Record<string, any>
98
+ /** Options passed to `eval-estree-expression` for expression evaluation.
99
+ * Pass `{ functions: true }` to enable function calls, or
100
+ * `{ functions: true, generate: escodegen.generate }` to also support
101
+ * inline arrow functions and callbacks like `.map(x => x.name)`. */
102
+ evaluateOptions?: EvaluateOptions
80
103
  }) {
81
104
  const visitor = new MdastToJsx({
82
105
  markdown,
@@ -90,6 +113,8 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
90
113
  modules,
91
114
  baseUrl,
92
115
  onError,
116
+ scope,
117
+ evaluateOptions,
93
118
  })
94
119
  const result = visitor.run()
95
120
  return result
@@ -110,6 +135,8 @@ export class MdastToJsx {
110
135
  modules?: EagerModules
111
136
  baseUrl?: string
112
137
  onError?: (error: SafeMdxError) => void
138
+ scope?: Record<string, any>
139
+ evaluateOptions?: EvaluateOptions
113
140
 
114
141
  constructor({
115
142
  markdown: code = '',
@@ -123,6 +150,8 @@ export class MdastToJsx {
123
150
  modules,
124
151
  baseUrl,
125
152
  onError,
153
+ scope,
154
+ evaluateOptions,
126
155
  }: {
127
156
  markdown?: string
128
157
  mdast: MyRootContent
@@ -140,6 +169,15 @@ export class MdastToJsx {
140
169
  /** Called for each error during rendering (missing components, invalid props, failed expressions).
141
170
  * Throw inside this callback to stop rendering on first error. */
142
171
  onError?: (error: SafeMdxError) => void
172
+ /** Variables and functions available in MDX expressions.
173
+ * When scope contains functions, function calls in expressions are
174
+ * automatically enabled. */
175
+ scope?: Record<string, any>
176
+ /** Options passed to `eval-estree-expression` for expression evaluation.
177
+ * Pass `{ functions: true }` to enable function calls, or
178
+ * `{ functions: true, generate: escodegen.generate }` to also support
179
+ * inline arrow functions and callbacks like `.map(x => x.name)`. */
180
+ evaluateOptions?: EvaluateOptions
143
181
  }) {
144
182
  this.str = code
145
183
 
@@ -158,6 +196,8 @@ export class MdastToJsx {
158
196
  this.modules = modules
159
197
  this.baseUrl = baseUrl
160
198
  this.onError = onError
199
+ this.scope = scope
200
+ this.evaluateOptions = evaluateOptions
161
201
 
162
202
  this.c = {
163
203
  ...Object.fromEntries(
@@ -488,6 +528,15 @@ export class MdastToJsx {
488
528
  return null
489
529
  }
490
530
 
531
+ evaluateExpression(expression: any) {
532
+ const hasScope = this.scope && Object.keys(this.scope).length > 0
533
+ const context = hasScope ? this.scope : undefined
534
+ const options = hasScope || this.evaluateOptions
535
+ ? { ...(hasScope ? { functions: true } : {}), ...this.evaluateOptions }
536
+ : undefined
537
+ return Evaluate.evaluate.sync(expression, context, options)
538
+ }
539
+
491
540
  getJsxAttrs(
492
541
  node: MdxJsxFlowElement | MdxJsxTextElement,
493
542
  onError: (err: SafeMdxError) => void = console.error,
@@ -500,14 +549,15 @@ export class MdastToJsx {
500
549
  if (attr.data?.estree) {
501
550
  try {
502
551
  const program = attr.data.estree
552
+ const firstBody = program.body?.[0]
503
553
  if (
504
- program.body?.length > 0 &&
505
- program.body[0].type === 'ExpressionStatement'
554
+ firstBody &&
555
+ firstBody.type === 'ExpressionStatement'
506
556
  ) {
507
- const expression = program.body[0].expression
557
+ const expression = firstBody.expression
508
558
  try {
509
559
  const result =
510
- Evaluate.evaluate.sync(expression)
560
+ this.evaluateExpression(expression)
511
561
 
512
562
  // Handle spread syntax - merge the evaluated object
513
563
  if (
@@ -597,11 +647,12 @@ export class MdastToJsx {
597
647
  try {
598
648
  // Extract the expression from the Program body
599
649
  const program = v.data.estree
650
+ const firstBody = program.body?.[0]
600
651
  if (
601
- program.body?.length > 0 &&
602
- program.body[0].type === 'ExpressionStatement'
652
+ firstBody &&
653
+ firstBody.type === 'ExpressionStatement'
603
654
  ) {
604
- const expression = program.body[0].expression
655
+ const expression = firstBody.expression
605
656
 
606
657
  // Check if this is a JSX element
607
658
  if (expression.type === 'JSXElement') {
@@ -620,7 +671,7 @@ export class MdastToJsx {
620
671
  try {
621
672
  // Evaluate the expression synchronously
622
673
  const result =
623
- Evaluate.evaluate.sync(expression)
674
+ this.evaluateExpression(expression)
624
675
  attrsList.push([attr.name, result])
625
676
  continue
626
677
  } catch (error) {
@@ -653,7 +704,7 @@ export class MdastToJsx {
653
704
  }
654
705
 
655
706
  run() {
656
- const res = this.mdastTransformer(this.mdast, 'root') as ReactNode
707
+ const res = this.mdastTransformer(this.mdast, 'root')
657
708
  if (Array.isArray(res) && res.length === 1) {
658
709
  return res[0]
659
710
  }
@@ -669,7 +720,7 @@ export class MdastToJsx {
669
720
  if (this.renderNode) {
670
721
  const customResult = this.renderNode(
671
722
  node,
672
- (n: MyRootContent) => this.mdastTransformer(n, node.type),
723
+ (n) => this.mdastTransformer(n, node.type),
673
724
  )
674
725
  if (customResult !== undefined) {
675
726
  return customResult
@@ -723,15 +774,16 @@ export class MdastToJsx {
723
774
  try {
724
775
  // Extract the expression from the Program body
725
776
  const program = node.data.estree
777
+ const firstBody = program.body?.[0]
726
778
  if (
727
- program.body?.length > 0 &&
728
- program.body[0].type === 'ExpressionStatement'
779
+ firstBody &&
780
+ firstBody.type === 'ExpressionStatement'
729
781
  ) {
730
- const expression = program.body[0].expression
782
+ const expression = firstBody.expression
731
783
  try {
732
784
  // Evaluate the expression synchronously
733
785
  const result =
734
- Evaluate.evaluate.sync(expression)
786
+ this.evaluateExpression(expression)
735
787
  return result
736
788
  } catch (error) {
737
789
  this.pushError({
@@ -1060,9 +1112,6 @@ export class MdastToJsx {
1060
1112
  }
1061
1113
  }
1062
1114
 
1063
- function isTruthy<T>(val: T | undefined | null | false): val is T {
1064
- return Boolean(val)
1065
- }
1066
1115
 
1067
1116
  function accessWithDot(obj, path: string) {
1068
1117
  return path
@@ -1093,14 +1142,6 @@ export function mdastBfs(
1093
1142
  return result
1094
1143
  }
1095
1144
 
1096
- function safeJsonParse(str: string) {
1097
- try {
1098
- return JSON.parse(str)
1099
- } catch (err) {
1100
- return null
1101
- }
1102
- }
1103
-
1104
1145
  type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & {
1105
1146
  [key: string]: any
1106
1147
  }