safe-mdx 1.3.10 → 1.5.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 +191 -5
- package/dist/esm-parser.d.ts.map +1 -1
- package/dist/esm-parser.js +2 -0
- package/dist/esm-parser.js.map +1 -1
- package/dist/esm-parser.test.js +179 -1
- package/dist/esm-parser.test.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 +29 -1
- package/dist/safe-mdx.d.ts.map +1 -1
- package/dist/safe-mdx.js +81 -10
- package/dist/safe-mdx.js.map +1 -1
- package/dist/safe-mdx.test.js +179 -0
- package/dist/safe-mdx.test.js.map +1 -1
- package/package.json +1 -1
- package/src/esm-parser.test.ts +192 -1
- package/src/esm-parser.ts +2 -0
- package/src/parse.ts +165 -0
- package/src/safe-mdx.test.tsx +190 -1
- package/src/safe-mdx.tsx +104 -8
package/src/safe-mdx.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
|
|
|
9
9
|
import { Fragment, ReactNode } from 'react'
|
|
10
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
|
|
|
@@ -28,7 +29,10 @@ export type RenderNode = (
|
|
|
28
29
|
transform: (node: MyRootContent) => ReactNode,
|
|
29
30
|
) => ReactNode | undefined
|
|
30
31
|
|
|
32
|
+
export type SafeMdxErrorType = 'validation' | 'missing-component' | 'expression' | 'esm-import'
|
|
33
|
+
|
|
31
34
|
export interface SafeMdxError {
|
|
35
|
+
type: SafeMdxErrorType
|
|
32
36
|
message: string
|
|
33
37
|
line?: number
|
|
34
38
|
schemaPath?: string
|
|
@@ -51,6 +55,9 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
|
|
|
51
55
|
createElement,
|
|
52
56
|
allowClientEsmImports = false,
|
|
53
57
|
addMarkdownLineNumbers = false,
|
|
58
|
+
modules,
|
|
59
|
+
baseUrl,
|
|
60
|
+
onError,
|
|
54
61
|
}: {
|
|
55
62
|
components?: ComponentsMap
|
|
56
63
|
markdown?: string
|
|
@@ -60,6 +67,16 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
|
|
|
60
67
|
createElement?: CreateElementFunction
|
|
61
68
|
allowClientEsmImports?: boolean
|
|
62
69
|
addMarkdownLineNumbers?: boolean
|
|
70
|
+
/** Pre-resolved modules keyed by file path (e.g. from `import.meta.glob`).
|
|
71
|
+
* When MDX contains `import { Card } from './card'`, the import source is
|
|
72
|
+
* resolved against these keys using `baseUrl` for relative paths. */
|
|
73
|
+
modules?: EagerModules
|
|
74
|
+
/** Directory of the current MDX file, used to resolve relative import
|
|
75
|
+
* sources against `modules` keys. E.g. `'./pages/getting-started/'` */
|
|
76
|
+
baseUrl?: string
|
|
77
|
+
/** Called for each error during rendering (missing components, invalid props, failed expressions).
|
|
78
|
+
* Throw inside this callback to stop rendering on first error. */
|
|
79
|
+
onError?: (error: SafeMdxError) => void
|
|
63
80
|
}) {
|
|
64
81
|
const visitor = new MdastToJsx({
|
|
65
82
|
markdown,
|
|
@@ -70,6 +87,9 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
|
|
|
70
87
|
createElement,
|
|
71
88
|
allowClientEsmImports,
|
|
72
89
|
addMarkdownLineNumbers,
|
|
90
|
+
modules,
|
|
91
|
+
baseUrl,
|
|
92
|
+
onError,
|
|
73
93
|
})
|
|
74
94
|
const result = visitor.run()
|
|
75
95
|
return result
|
|
@@ -87,6 +107,9 @@ export class MdastToJsx {
|
|
|
87
107
|
esmImports: Map<string, string> = new Map()
|
|
88
108
|
allowClientEsmImports: boolean
|
|
89
109
|
addMarkdownLineNumbers: boolean
|
|
110
|
+
modules?: EagerModules
|
|
111
|
+
baseUrl?: string
|
|
112
|
+
onError?: (error: SafeMdxError) => void
|
|
90
113
|
|
|
91
114
|
constructor({
|
|
92
115
|
markdown: code = '',
|
|
@@ -97,6 +120,9 @@ export class MdastToJsx {
|
|
|
97
120
|
createElement = React.createElement,
|
|
98
121
|
allowClientEsmImports = false,
|
|
99
122
|
addMarkdownLineNumbers = false,
|
|
123
|
+
modules,
|
|
124
|
+
baseUrl,
|
|
125
|
+
onError,
|
|
100
126
|
}: {
|
|
101
127
|
markdown?: string
|
|
102
128
|
mdast: MyRootContent
|
|
@@ -109,6 +135,11 @@ export class MdastToJsx {
|
|
|
109
135
|
createElement?: CreateElementFunction
|
|
110
136
|
allowClientEsmImports?: boolean
|
|
111
137
|
addMarkdownLineNumbers?: boolean
|
|
138
|
+
modules?: EagerModules
|
|
139
|
+
baseUrl?: string
|
|
140
|
+
/** Called for each error during rendering (missing components, invalid props, failed expressions).
|
|
141
|
+
* Throw inside this callback to stop rendering on first error. */
|
|
142
|
+
onError?: (error: SafeMdxError) => void
|
|
112
143
|
}) {
|
|
113
144
|
this.str = code
|
|
114
145
|
|
|
@@ -124,6 +155,10 @@ export class MdastToJsx {
|
|
|
124
155
|
|
|
125
156
|
this.addMarkdownLineNumbers = addMarkdownLineNumbers
|
|
126
157
|
|
|
158
|
+
this.modules = modules
|
|
159
|
+
this.baseUrl = baseUrl
|
|
160
|
+
this.onError = onError
|
|
161
|
+
|
|
127
162
|
this.c = {
|
|
128
163
|
...Object.fromEntries(
|
|
129
164
|
nativeTags.map((tag) => {
|
|
@@ -132,6 +167,50 @@ export class MdastToJsx {
|
|
|
132
167
|
),
|
|
133
168
|
...components,
|
|
134
169
|
}
|
|
170
|
+
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pushError(error: SafeMdxError): void {
|
|
174
|
+
this.errors.push(error)
|
|
175
|
+
this.onError?.(error)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve import declarations from an mdxjsEsm node against `this.modules`.
|
|
180
|
+
* Resolved components are added directly to `this.c` (the component map)
|
|
181
|
+
* so the existing `accessWithDot` lookup finds them.
|
|
182
|
+
*/
|
|
183
|
+
resolveImportsFromModules(node: MyRootContent): void {
|
|
184
|
+
const estree = (node as any).data?.estree
|
|
185
|
+
if (!estree) return
|
|
186
|
+
|
|
187
|
+
const moduleKeys = Object.keys(this.modules!)
|
|
188
|
+
|
|
189
|
+
for (const statement of estree.body) {
|
|
190
|
+
if (statement.type !== 'ImportDeclaration') continue
|
|
191
|
+
const source: string = statement.source?.value
|
|
192
|
+
if (typeof source !== 'string') continue
|
|
193
|
+
|
|
194
|
+
const resolved = resolveModulePath(source, this.baseUrl || './', moduleKeys)
|
|
195
|
+
if (!resolved) continue
|
|
196
|
+
const mod = this.modules![resolved]
|
|
197
|
+
if (!mod) continue
|
|
198
|
+
|
|
199
|
+
for (const spec of statement.specifiers ?? []) {
|
|
200
|
+
if (spec.type === 'ImportDefaultSpecifier') {
|
|
201
|
+
this.c[spec.local.name] = mod.default ?? mod
|
|
202
|
+
} else if (spec.type === 'ImportSpecifier') {
|
|
203
|
+
const importedName = spec.imported.type === 'Identifier'
|
|
204
|
+
? spec.imported.name
|
|
205
|
+
: String(spec.imported.value)
|
|
206
|
+
this.c[spec.local.name] = mod[importedName]
|
|
207
|
+
} else if (spec.type === 'ImportNamespaceSpecifier') {
|
|
208
|
+
// Namespace import: import * as UI from '...'
|
|
209
|
+
// Supports <UI.Card> via accessWithDot
|
|
210
|
+
this.c[spec.local.name] = mod
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
135
214
|
}
|
|
136
215
|
|
|
137
216
|
addLineNumberToProps(
|
|
@@ -174,7 +253,8 @@ export class MdastToJsx {
|
|
|
174
253
|
if (result.issues) {
|
|
175
254
|
result.issues.forEach((issue) => {
|
|
176
255
|
const propPath = issue.path?.join('.') || 'unknown'
|
|
177
|
-
this.
|
|
256
|
+
this.pushError({
|
|
257
|
+
type: 'validation',
|
|
178
258
|
message: `Invalid props for component "${componentName}" at "${propPath}": ${issue.message}`,
|
|
179
259
|
line,
|
|
180
260
|
schemaPath: issue.path?.join('.'),
|
|
@@ -242,7 +322,7 @@ export class MdastToJsx {
|
|
|
242
322
|
extractComponentInfo(esmImportInfo)
|
|
243
323
|
Component = DynamicEsmComponent
|
|
244
324
|
let attrsList = this.getJsxAttrs(node, (err) => {
|
|
245
|
-
this.
|
|
325
|
+
this.pushError(err)
|
|
246
326
|
})
|
|
247
327
|
let attrs = Object.fromEntries(attrsList)
|
|
248
328
|
|
|
@@ -258,7 +338,8 @@ export class MdastToJsx {
|
|
|
258
338
|
Component = accessWithDot(this.c, node.name)
|
|
259
339
|
|
|
260
340
|
if (!Component) {
|
|
261
|
-
this.
|
|
341
|
+
this.pushError({
|
|
342
|
+
type: 'missing-component',
|
|
262
343
|
message: `Unsupported jsx component ${node.name}`,
|
|
263
344
|
line: node.position?.start?.line,
|
|
264
345
|
})
|
|
@@ -267,7 +348,7 @@ export class MdastToJsx {
|
|
|
267
348
|
}
|
|
268
349
|
|
|
269
350
|
let attrsList = this.getJsxAttrs(node, (err) => {
|
|
270
|
-
this.
|
|
351
|
+
this.pushError(err)
|
|
271
352
|
})
|
|
272
353
|
|
|
273
354
|
let attrs = Object.fromEntries(attrsList)
|
|
@@ -305,6 +386,7 @@ export class MdastToJsx {
|
|
|
305
386
|
: null
|
|
306
387
|
if (!tagName) {
|
|
307
388
|
onError?.({
|
|
389
|
+
type: 'expression',
|
|
308
390
|
message: 'JSX element missing component name',
|
|
309
391
|
line: line,
|
|
310
392
|
})
|
|
@@ -327,6 +409,7 @@ export class MdastToJsx {
|
|
|
327
409
|
Component = accessWithDot(this.c, tagName)
|
|
328
410
|
if (!Component) {
|
|
329
411
|
onError?.({
|
|
412
|
+
type: 'missing-component',
|
|
330
413
|
message: `Unsupported jsx component ${tagName} in attribute`,
|
|
331
414
|
line: line,
|
|
332
415
|
})
|
|
@@ -394,6 +477,7 @@ export class MdastToJsx {
|
|
|
394
477
|
} catch (error) {
|
|
395
478
|
// Return null if transformation fails
|
|
396
479
|
onError?.({
|
|
480
|
+
type: 'expression',
|
|
397
481
|
message: `Failed to transform JSX element: ${
|
|
398
482
|
error instanceof Error ? error.message : 'Unknown error'
|
|
399
483
|
}`,
|
|
@@ -435,6 +519,7 @@ export class MdastToJsx {
|
|
|
435
519
|
}
|
|
436
520
|
} catch (error) {
|
|
437
521
|
onError({
|
|
522
|
+
type: 'expression',
|
|
438
523
|
message: `Failed to evaluate expression attribute: ${attr.value
|
|
439
524
|
.replace(/\n+/g, ' ')
|
|
440
525
|
.replace(/ +/g, ' ')}. ${
|
|
@@ -448,6 +533,7 @@ export class MdastToJsx {
|
|
|
448
533
|
}
|
|
449
534
|
} catch (error) {
|
|
450
535
|
onError({
|
|
536
|
+
type: 'expression',
|
|
451
537
|
message: `Failed to evaluate expression attribute: ${attr.value
|
|
452
538
|
.replace(/\n+/g, ' ')
|
|
453
539
|
.replace(/ +/g, ' ')}. ${
|
|
@@ -460,6 +546,7 @@ export class MdastToJsx {
|
|
|
460
546
|
}
|
|
461
547
|
} else {
|
|
462
548
|
onError({
|
|
549
|
+
type: 'expression',
|
|
463
550
|
message: `Expressions in jsx props are not supported (${attr.value
|
|
464
551
|
.replace(/\n+/g, ' ')
|
|
465
552
|
.replace(/ +/g, ' ')})`,
|
|
@@ -471,6 +558,7 @@ export class MdastToJsx {
|
|
|
471
558
|
|
|
472
559
|
if (attr.type !== 'mdxJsxAttribute') {
|
|
473
560
|
onError({
|
|
561
|
+
type: 'expression',
|
|
474
562
|
message: `non mdxJsxAttribute attribute is not supported: ${attr}`,
|
|
475
563
|
line: node.position?.start?.line,
|
|
476
564
|
})
|
|
@@ -537,6 +625,7 @@ export class MdastToJsx {
|
|
|
537
625
|
continue
|
|
538
626
|
} catch (error) {
|
|
539
627
|
onError({
|
|
628
|
+
type: 'expression',
|
|
540
629
|
message: `Failed to evaluate expression attribute: ${
|
|
541
630
|
attr.name
|
|
542
631
|
}={${v.value}}. ${
|
|
@@ -554,6 +643,7 @@ export class MdastToJsx {
|
|
|
554
643
|
}
|
|
555
644
|
|
|
556
645
|
onError({
|
|
646
|
+
type: 'expression',
|
|
557
647
|
message: `Expressions in jsx prop not evaluated: (${attr.name}={${v.value}})`,
|
|
558
648
|
line: attr.position?.start?.line,
|
|
559
649
|
})
|
|
@@ -588,10 +678,14 @@ export class MdastToJsx {
|
|
|
588
678
|
|
|
589
679
|
switch (node.type) {
|
|
590
680
|
case 'mdxjsEsm': {
|
|
591
|
-
//
|
|
681
|
+
// Resolve imports from pre-loaded modules (server-side)
|
|
682
|
+
if (this.modules) {
|
|
683
|
+
this.resolveImportsFromModules(node)
|
|
684
|
+
}
|
|
685
|
+
// Parse ESM imports for client-side dynamic loading (only if allowed)
|
|
592
686
|
if (this.allowClientEsmImports) {
|
|
593
687
|
const parsedImports = parseEsmImports(node, (err) =>
|
|
594
|
-
this.
|
|
688
|
+
this.pushError(err),
|
|
595
689
|
)
|
|
596
690
|
parsedImports.forEach((value, key) => {
|
|
597
691
|
this.esmImports.set(key, value)
|
|
@@ -640,7 +734,8 @@ export class MdastToJsx {
|
|
|
640
734
|
Evaluate.evaluate.sync(expression)
|
|
641
735
|
return result
|
|
642
736
|
} catch (error) {
|
|
643
|
-
this.
|
|
737
|
+
this.pushError({
|
|
738
|
+
type: 'expression',
|
|
644
739
|
message: `Failed to evaluate expression: ${
|
|
645
740
|
node.value
|
|
646
741
|
}. ${
|
|
@@ -653,7 +748,8 @@ export class MdastToJsx {
|
|
|
653
748
|
}
|
|
654
749
|
}
|
|
655
750
|
} catch (error) {
|
|
656
|
-
this.
|
|
751
|
+
this.pushError({
|
|
752
|
+
type: 'expression',
|
|
657
753
|
message: `Failed to evaluate expression: ${
|
|
658
754
|
node.value
|
|
659
755
|
}. ${
|