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.
- package/README.md +84 -8
- package/dist/dynamic-esm-component.d.ts +1 -1
- package/dist/dynamic-esm-component.d.ts.map +1 -1
- package/dist/dynamic-esm-component.js +9 -1
- package/dist/dynamic-esm-component.js.map +1 -1
- package/dist/esm-parser.d.ts +1 -1
- package/dist/esm-parser.d.ts.map +1 -1
- package/dist/esm-parser.js +3 -3
- package/dist/esm-parser.js.map +1 -1
- package/dist/esm-parser.test.js +2 -2
- package/dist/html/html-and-md.test.js.map +1 -1
- package/dist/html/html-to-mdx-ast.d.ts +1 -1
- package/dist/html/html-to-mdx-ast.js +4 -4
- package/dist/html/html-to-mdx-ast.js.map +1 -1
- package/dist/html/html-to-mdx-ast.test.js +3 -3
- package/dist/html/html-to-mdx-ast.test.js.map +1 -1
- package/dist/parse.d.ts +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +5 -1
- package/dist/parse.js.map +1 -1
- package/dist/safe-mdx.bench.js +2 -2
- package/dist/safe-mdx.bench.js.map +1 -1
- package/dist/safe-mdx.d.ts +35 -3
- package/dist/safe-mdx.d.ts.map +1 -1
- package/dist/safe-mdx.js +35 -29
- package/dist/safe-mdx.js.map +1 -1
- package/dist/safe-mdx.test.js +147 -5
- package/dist/safe-mdx.test.js.map +1 -1
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +3 -1
- package/dist/streaming.js.map +1 -1
- package/package.json +30 -7
- package/src/esm-parser.test.ts +3 -3
- package/src/esm-parser.ts +4 -4
- package/src/html/html-and-md.test.ts +2 -2
- package/src/html/html-to-mdx-ast.test.ts +3 -3
- package/src/html/html-to-mdx-ast.ts +4 -4
- package/src/parse.ts +3 -1
- package/src/safe-mdx.bench.tsx +2 -2
- package/src/safe-mdx.test.tsx +175 -11
- package/src/safe-mdx.tsx +70 -29
- 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
|
-
"
|
|
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
|
|
28
|
-
|
|
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",
|
package/src/esm-parser.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test, describe } from 'vitest'
|
|
2
|
-
import { parseEsmImports, extractComponentInfo } from './esm-parser.
|
|
3
|
-
import { mdxParse, extractImports, resolveModulePath } from './parse.
|
|
4
|
-
import type { SafeMdxError } from './safe-mdx.
|
|
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.
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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.
|
|
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.
|
|
15
|
-
import { MdastToJsx } from '../safe-mdx.
|
|
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.
|
|
10
|
-
import { parseHTML } from './domparser.
|
|
11
|
-
import { remarkMdxJsxNormalize } from './remark-mdx-jsx-normalize.
|
|
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.
|
|
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.
|
package/src/safe-mdx.bench.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { bench, describe } from 'vitest'
|
|
2
|
-
import { mdxParse } from './parse.
|
|
3
|
-
import { MdastToJsx } from './safe-mdx.
|
|
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',
|
package/src/safe-mdx.test.tsx
CHANGED
|
@@ -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.
|
|
7
|
-
import { MdastToJsx, mdastBfs, type ComponentPropsSchema } from './safe-mdx.
|
|
8
|
-
import { completeJsxTags } from './streaming.
|
|
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]
|
|
2301
|
-
expect(errors[1]
|
|
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]
|
|
3057
|
-
expect(visitor.errors[1]
|
|
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]
|
|
3061
|
-
expect(visitor.errors[3]
|
|
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.
|
|
12
|
-
import { resolveModulePath, type EagerModules } from './parse.
|
|
13
|
-
import { htmlToMdxAst } from './html/html-to-mdx-ast.
|
|
14
|
-
import { validHtmlElements, nativeTags } from './html/valid-html-elements.
|
|
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
|
-
|
|
505
|
-
|
|
554
|
+
firstBody &&
|
|
555
|
+
firstBody.type === 'ExpressionStatement'
|
|
506
556
|
) {
|
|
507
|
-
const expression =
|
|
557
|
+
const expression = firstBody.expression
|
|
508
558
|
try {
|
|
509
559
|
const result =
|
|
510
|
-
|
|
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
|
-
|
|
602
|
-
|
|
652
|
+
firstBody &&
|
|
653
|
+
firstBody.type === 'ExpressionStatement'
|
|
603
654
|
) {
|
|
604
|
-
const 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
|
-
|
|
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')
|
|
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
|
|
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
|
-
|
|
728
|
-
|
|
779
|
+
firstBody &&
|
|
780
|
+
firstBody.type === 'ExpressionStatement'
|
|
729
781
|
) {
|
|
730
|
-
const expression =
|
|
782
|
+
const expression = firstBody.expression
|
|
731
783
|
try {
|
|
732
784
|
// Evaluate the expression synchronously
|
|
733
785
|
const result =
|
|
734
|
-
|
|
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
|
}
|