safe-mdx 1.3.9 → 1.4.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 +4 -0
- package/dist/dynamic-esm-component.js.map +1 -1
- package/dist/esm-parser.js.map +1 -1
- package/dist/esm-parser.test.js +176 -1
- package/dist/esm-parser.test.js.map +1 -1
- package/dist/html/convert-attributes.js.map +1 -1
- package/dist/html/html-and-md.test.js.map +1 -1
- 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/html/remark-mdx-jsx-normalize.d.ts.map +1 -1
- package/dist/html/remark-mdx-jsx-normalize.js.map +1 -1
- package/dist/html/valid-html-elements.d.ts.map +1 -1
- package/dist/html/valid-html-elements.js +1 -1
- package/dist/html/valid-html-elements.js.map +1 -1
- package/dist/parse.d.ts +44 -0
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +127 -0
- package/dist/parse.js.map +1 -1
- package/dist/safe-mdx.d.ts +20 -2
- package/dist/safe-mdx.d.ts.map +1 -1
- package/dist/safe-mdx.js +55 -4
- package/dist/safe-mdx.js.map +1 -1
- package/dist/safe-mdx.test.js +110 -5
- package/dist/safe-mdx.test.js.map +1 -1
- package/dist/streaming.js.map +1 -1
- package/package.json +11 -6
- package/src/esm-parser.test.ts +189 -1
- package/src/parse.ts +165 -0
- package/src/safe-mdx.test.tsx +119 -6
- package/src/safe-mdx.tsx +67 -4
- package/dist/assets/HtmlToJsxConverter-Ds0bTjpw.js +0 -24
- package/dist/assets/_commonjsHelpers-CqkleIqs.js +0 -1
- package/dist/assets/index-B5fPOjPt.css +0 -1
- package/dist/assets/index-B7ATSoRE.js +0 -9
- package/dist/assets/index-BwZ2FTRd.js +0 -146
- package/dist/assets/index-R1UqLMGJ.js +0 -1
- package/dist/assets/index-c0qeY2gs.js +0 -9
- package/dist/assets/jsx-runtime-BhZZLbvw.js +0 -9
- package/dist/assets/jsx-runtime-NArryeSM.js +0 -1
- package/dist/assets/react-Ca6JzGpx.js +0 -1
- package/dist/assets/react-dom-BYRHYqYl.js +0 -1
- package/dist/index.html +0 -19
package/src/safe-mdx.test.tsx
CHANGED
|
@@ -2529,17 +2529,17 @@ test('component props schema validation with zod', () => {
|
|
|
2529
2529
|
"errors": [
|
|
2530
2530
|
{
|
|
2531
2531
|
"line": 5,
|
|
2532
|
-
"message": "Invalid props for component "Heading" at "level":
|
|
2532
|
+
"message": "Invalid props for component "Heading" at "level": Too big: expected number to be <=6",
|
|
2533
2533
|
"schemaPath": "level",
|
|
2534
2534
|
},
|
|
2535
2535
|
{
|
|
2536
2536
|
"line": 7,
|
|
2537
|
-
"message": "Invalid props for component "Cards" at "count":
|
|
2537
|
+
"message": "Invalid props for component "Cards" at "count": Too small: expected number to be >0",
|
|
2538
2538
|
"schemaPath": "count",
|
|
2539
2539
|
},
|
|
2540
2540
|
{
|
|
2541
2541
|
"line": 9,
|
|
2542
|
-
"message": "Invalid props for component "Cards" at "count":
|
|
2542
|
+
"message": "Invalid props for component "Cards" at "count": Invalid input: expected number, received string",
|
|
2543
2543
|
"schemaPath": "count",
|
|
2544
2544
|
},
|
|
2545
2545
|
],
|
|
@@ -2748,12 +2748,12 @@ test('validation error includes schema path', () => {
|
|
|
2748
2748
|
"errors": [
|
|
2749
2749
|
{
|
|
2750
2750
|
"line": 1,
|
|
2751
|
-
"message": "Invalid props for component "Heading" at "user.age":
|
|
2751
|
+
"message": "Invalid props for component "Heading" at "user.age": Too small: expected number to be >=0",
|
|
2752
2752
|
"schemaPath": "user.age",
|
|
2753
2753
|
},
|
|
2754
2754
|
{
|
|
2755
2755
|
"line": 1,
|
|
2756
|
-
"message": "Invalid props for component "Heading" at "settings.theme": Invalid
|
|
2756
|
+
"message": "Invalid props for component "Heading" at "settings.theme": Invalid option: expected one of "light"|"dark"",
|
|
2757
2757
|
"schemaPath": "settings.theme",
|
|
2758
2758
|
},
|
|
2759
2759
|
],
|
|
@@ -3498,5 +3498,118 @@ test("skip unknown elements in complex nested HTML structures", () => {
|
|
|
3498
3498
|
</div>
|
|
3499
3499
|
`);
|
|
3500
3500
|
|
|
3501
|
-
|
|
3501
|
+
expect(html).toMatchInlineSnapshot(`"<h1>Main Title</h1><article><header><h1>Article Title</h1></header><section><blockquote><p>A famous quote</p></blockquote></section><footer></footer></article><h2>Another Section</h2><div></div>"`);
|
|
3502
3502
|
});
|
|
3503
|
+
|
|
3504
|
+
/* ── modules prop tests ─────────────────────────────────────────────── */
|
|
3505
|
+
|
|
3506
|
+
test('modules prop: resolves named import from absolute path', () => {
|
|
3507
|
+
function CustomBadge({ label }: { label: string }) {
|
|
3508
|
+
return <span className="badge">{label}</span>
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
const code = dedent`
|
|
3512
|
+
import { CustomBadge } from '/snippets/badge'
|
|
3513
|
+
|
|
3514
|
+
# Hello
|
|
3515
|
+
|
|
3516
|
+
<CustomBadge label="Works" />
|
|
3517
|
+
`
|
|
3518
|
+
const mdast = mdxParse(code)
|
|
3519
|
+
const visitor = new MdastToJsx({
|
|
3520
|
+
markdown: code,
|
|
3521
|
+
mdast,
|
|
3522
|
+
components,
|
|
3523
|
+
modules: {
|
|
3524
|
+
'./snippets/badge.tsx': { CustomBadge },
|
|
3525
|
+
},
|
|
3526
|
+
baseUrl: './pages/',
|
|
3527
|
+
})
|
|
3528
|
+
const result = visitor.run()
|
|
3529
|
+
const html = renderToStaticMarkup(result)
|
|
3530
|
+
expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1><span class="badge">Works</span>"`)
|
|
3531
|
+
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
|
|
3532
|
+
})
|
|
3533
|
+
|
|
3534
|
+
test('modules prop: resolves default import from relative path', () => {
|
|
3535
|
+
function MyCard({ children }: { children?: React.ReactNode }) {
|
|
3536
|
+
return <div className="card">{children}</div>
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
const code = dedent`
|
|
3540
|
+
import MyCard from './card'
|
|
3541
|
+
|
|
3542
|
+
<MyCard>content</MyCard>
|
|
3543
|
+
`
|
|
3544
|
+
const mdast = mdxParse(code)
|
|
3545
|
+
const visitor = new MdastToJsx({
|
|
3546
|
+
markdown: code,
|
|
3547
|
+
mdast,
|
|
3548
|
+
components,
|
|
3549
|
+
modules: {
|
|
3550
|
+
'./pages/card.tsx': { default: MyCard },
|
|
3551
|
+
},
|
|
3552
|
+
baseUrl: './pages/',
|
|
3553
|
+
})
|
|
3554
|
+
const result = visitor.run()
|
|
3555
|
+
const html = renderToStaticMarkup(result)
|
|
3556
|
+
expect(html).toMatchInlineSnapshot(`"<div class="card">content</div>"`)
|
|
3557
|
+
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
|
|
3558
|
+
})
|
|
3559
|
+
|
|
3560
|
+
test('modules prop: resolves namespace import with dot notation', () => {
|
|
3561
|
+
function Card({ title }: { title: string }) {
|
|
3562
|
+
return <div>{title}</div>
|
|
3563
|
+
}
|
|
3564
|
+
function Badge({ label }: { label: string }) {
|
|
3565
|
+
return <span>{label}</span>
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
const code = dedent`
|
|
3569
|
+
import * as UI from './ui'
|
|
3570
|
+
|
|
3571
|
+
<UI.Card title="hello" />
|
|
3572
|
+
<UI.Badge label="tag" />
|
|
3573
|
+
`
|
|
3574
|
+
const mdast = mdxParse(code)
|
|
3575
|
+
const visitor = new MdastToJsx({
|
|
3576
|
+
markdown: code,
|
|
3577
|
+
mdast,
|
|
3578
|
+
components,
|
|
3579
|
+
modules: {
|
|
3580
|
+
'./components/ui.tsx': { Card, Badge },
|
|
3581
|
+
},
|
|
3582
|
+
baseUrl: './components/',
|
|
3583
|
+
})
|
|
3584
|
+
const result = visitor.run()
|
|
3585
|
+
const html = renderToStaticMarkup(result)
|
|
3586
|
+
expect(html).toMatchInlineSnapshot(`"<div>hello</div><span>tag</span>"`)
|
|
3587
|
+
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
|
|
3588
|
+
})
|
|
3589
|
+
|
|
3590
|
+
test('modules prop: unresolved import produces error', () => {
|
|
3591
|
+
const code = dedent`
|
|
3592
|
+
import { Missing } from './nonexistent'
|
|
3593
|
+
|
|
3594
|
+
<Missing />
|
|
3595
|
+
`
|
|
3596
|
+
const mdast = mdxParse(code)
|
|
3597
|
+
const visitor = new MdastToJsx({
|
|
3598
|
+
markdown: code,
|
|
3599
|
+
mdast,
|
|
3600
|
+
components,
|
|
3601
|
+
modules: {},
|
|
3602
|
+
baseUrl: './pages/',
|
|
3603
|
+
})
|
|
3604
|
+
const result = visitor.run()
|
|
3605
|
+
const html = renderToStaticMarkup(result)
|
|
3606
|
+
expect(html).toMatchInlineSnapshot(`""`)
|
|
3607
|
+
expect(visitor.errors).toMatchInlineSnapshot(`
|
|
3608
|
+
[
|
|
3609
|
+
{
|
|
3610
|
+
"line": 3,
|
|
3611
|
+
"message": "Unsupported jsx component Missing",
|
|
3612
|
+
},
|
|
3613
|
+
]
|
|
3614
|
+
`)
|
|
3615
|
+
})
|
package/src/safe-mdx.tsx
CHANGED
|
@@ -7,8 +7,9 @@ import type { Node, Parent, Root, RootContent } from 'mdast'
|
|
|
7
7
|
import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
|
|
8
8
|
|
|
9
9
|
import { Fragment, ReactNode } from 'react'
|
|
10
|
-
import { DynamicEsmComponent } from '
|
|
10
|
+
import { DynamicEsmComponent } from 'safe-mdx/client'
|
|
11
11
|
import { extractComponentInfo, parseEsmImports } from './esm-parser.js'
|
|
12
|
+
import { resolveModulePath, type EagerModules } from './parse.js'
|
|
12
13
|
import { htmlToMdxAst } from './html/html-to-mdx-ast.js'
|
|
13
14
|
import { validHtmlElements, nativeTags } from './html/valid-html-elements.js'
|
|
14
15
|
|
|
@@ -51,15 +52,24 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
|
|
|
51
52
|
createElement,
|
|
52
53
|
allowClientEsmImports = false,
|
|
53
54
|
addMarkdownLineNumbers = false,
|
|
55
|
+
modules,
|
|
56
|
+
baseUrl,
|
|
54
57
|
}: {
|
|
55
58
|
components?: ComponentsMap
|
|
56
59
|
markdown?: string
|
|
57
|
-
mdast
|
|
60
|
+
mdast?: MyRootContent
|
|
58
61
|
renderNode?: RenderNode
|
|
59
62
|
componentPropsSchema?: ComponentPropsSchema
|
|
60
63
|
createElement?: CreateElementFunction
|
|
61
64
|
allowClientEsmImports?: boolean
|
|
62
65
|
addMarkdownLineNumbers?: boolean
|
|
66
|
+
/** Pre-resolved modules keyed by file path (e.g. from `import.meta.glob`).
|
|
67
|
+
* When MDX contains `import { Card } from './card'`, the import source is
|
|
68
|
+
* resolved against these keys using `baseUrl` for relative paths. */
|
|
69
|
+
modules?: EagerModules
|
|
70
|
+
/** Directory of the current MDX file, used to resolve relative import
|
|
71
|
+
* sources against `modules` keys. E.g. `'./pages/getting-started/'` */
|
|
72
|
+
baseUrl?: string
|
|
63
73
|
}) {
|
|
64
74
|
const visitor = new MdastToJsx({
|
|
65
75
|
markdown,
|
|
@@ -70,6 +80,8 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
|
|
|
70
80
|
createElement,
|
|
71
81
|
allowClientEsmImports,
|
|
72
82
|
addMarkdownLineNumbers,
|
|
83
|
+
modules,
|
|
84
|
+
baseUrl,
|
|
73
85
|
})
|
|
74
86
|
const result = visitor.run()
|
|
75
87
|
return result
|
|
@@ -87,6 +99,8 @@ export class MdastToJsx {
|
|
|
87
99
|
esmImports: Map<string, string> = new Map()
|
|
88
100
|
allowClientEsmImports: boolean
|
|
89
101
|
addMarkdownLineNumbers: boolean
|
|
102
|
+
modules?: EagerModules
|
|
103
|
+
baseUrl?: string
|
|
90
104
|
|
|
91
105
|
constructor({
|
|
92
106
|
markdown: code = '',
|
|
@@ -97,6 +111,8 @@ export class MdastToJsx {
|
|
|
97
111
|
createElement = React.createElement,
|
|
98
112
|
allowClientEsmImports = false,
|
|
99
113
|
addMarkdownLineNumbers = false,
|
|
114
|
+
modules,
|
|
115
|
+
baseUrl,
|
|
100
116
|
}: {
|
|
101
117
|
markdown?: string
|
|
102
118
|
mdast: MyRootContent
|
|
@@ -109,6 +125,8 @@ export class MdastToJsx {
|
|
|
109
125
|
createElement?: CreateElementFunction
|
|
110
126
|
allowClientEsmImports?: boolean
|
|
111
127
|
addMarkdownLineNumbers?: boolean
|
|
128
|
+
modules?: EagerModules
|
|
129
|
+
baseUrl?: string
|
|
112
130
|
}) {
|
|
113
131
|
this.str = code
|
|
114
132
|
|
|
@@ -124,6 +142,9 @@ export class MdastToJsx {
|
|
|
124
142
|
|
|
125
143
|
this.addMarkdownLineNumbers = addMarkdownLineNumbers
|
|
126
144
|
|
|
145
|
+
this.modules = modules
|
|
146
|
+
this.baseUrl = baseUrl
|
|
147
|
+
|
|
127
148
|
this.c = {
|
|
128
149
|
...Object.fromEntries(
|
|
129
150
|
nativeTags.map((tag) => {
|
|
@@ -132,6 +153,45 @@ export class MdastToJsx {
|
|
|
132
153
|
),
|
|
133
154
|
...components,
|
|
134
155
|
}
|
|
156
|
+
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolve import declarations from an mdxjsEsm node against `this.modules`.
|
|
161
|
+
* Resolved components are added directly to `this.c` (the component map)
|
|
162
|
+
* so the existing `accessWithDot` lookup finds them.
|
|
163
|
+
*/
|
|
164
|
+
resolveImportsFromModules(node: MyRootContent): void {
|
|
165
|
+
const estree = (node as any).data?.estree
|
|
166
|
+
if (!estree) return
|
|
167
|
+
|
|
168
|
+
const moduleKeys = Object.keys(this.modules!)
|
|
169
|
+
|
|
170
|
+
for (const statement of estree.body) {
|
|
171
|
+
if (statement.type !== 'ImportDeclaration') continue
|
|
172
|
+
const source: string = statement.source?.value
|
|
173
|
+
if (typeof source !== 'string') continue
|
|
174
|
+
|
|
175
|
+
const resolved = resolveModulePath(source, this.baseUrl || './', moduleKeys)
|
|
176
|
+
if (!resolved) continue
|
|
177
|
+
const mod = this.modules![resolved]
|
|
178
|
+
if (!mod) continue
|
|
179
|
+
|
|
180
|
+
for (const spec of statement.specifiers ?? []) {
|
|
181
|
+
if (spec.type === 'ImportDefaultSpecifier') {
|
|
182
|
+
this.c[spec.local.name] = mod.default ?? mod
|
|
183
|
+
} else if (spec.type === 'ImportSpecifier') {
|
|
184
|
+
const importedName = spec.imported.type === 'Identifier'
|
|
185
|
+
? spec.imported.name
|
|
186
|
+
: String(spec.imported.value)
|
|
187
|
+
this.c[spec.local.name] = mod[importedName]
|
|
188
|
+
} else if (spec.type === 'ImportNamespaceSpecifier') {
|
|
189
|
+
// Namespace import: import * as UI from '...'
|
|
190
|
+
// Supports <UI.Card> via accessWithDot
|
|
191
|
+
this.c[spec.local.name] = mod
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
135
195
|
}
|
|
136
196
|
|
|
137
197
|
addLineNumberToProps(
|
|
@@ -240,7 +300,6 @@ export class MdastToJsx {
|
|
|
240
300
|
// Handle ESM imported component
|
|
241
301
|
const { importUrl, componentName } =
|
|
242
302
|
extractComponentInfo(esmImportInfo)
|
|
243
|
-
|
|
244
303
|
Component = DynamicEsmComponent
|
|
245
304
|
let attrsList = this.getJsxAttrs(node, (err) => {
|
|
246
305
|
this.errors.push(err)
|
|
@@ -589,7 +648,11 @@ export class MdastToJsx {
|
|
|
589
648
|
|
|
590
649
|
switch (node.type) {
|
|
591
650
|
case 'mdxjsEsm': {
|
|
592
|
-
//
|
|
651
|
+
// Resolve imports from pre-loaded modules (server-side)
|
|
652
|
+
if (this.modules) {
|
|
653
|
+
this.resolveImportsFromModules(node)
|
|
654
|
+
}
|
|
655
|
+
// Parse ESM imports for client-side dynamic loading (only if allowed)
|
|
593
656
|
if (this.allowClientEsmImports) {
|
|
594
657
|
const parsedImports = parseEsmImports(node, (err) =>
|
|
595
658
|
this.errors.push(err),
|