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/parse.ts
CHANGED
|
@@ -9,6 +9,62 @@ import { parseHtmlToMdxAst, remarkMdxJsxNormalize } from './html/html-to-mdx-ast
|
|
|
9
9
|
|
|
10
10
|
export { parseHtmlToMdxAst, remarkMdxJsxNormalize }
|
|
11
11
|
|
|
12
|
+
/* ── Import extraction ──────────────────────────────────────────────── */
|
|
13
|
+
|
|
14
|
+
export type MdxImportSpecifier = {
|
|
15
|
+
/** Name used in MDX (e.g. `Card`, `MyButton`, `Utils`) */
|
|
16
|
+
local: string
|
|
17
|
+
/** Original export name. `'default'` for default imports, local name for named, `'*'` for namespace */
|
|
18
|
+
imported: string
|
|
19
|
+
type: 'named' | 'default' | 'namespace'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MdxImport = {
|
|
23
|
+
/** Raw source string as written: `'./card'`, `'/snippets/ui'`, `'some-pkg'` */
|
|
24
|
+
source: string
|
|
25
|
+
specifiers: MdxImportSpecifier[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract all import declarations from a parsed mdast tree.
|
|
30
|
+
* Unlike `parseEsmImports`, this accepts ANY source (not just HTTPS URLs).
|
|
31
|
+
*/
|
|
32
|
+
export function extractImports(ast: Root): MdxImport[] {
|
|
33
|
+
const imports: MdxImport[] = []
|
|
34
|
+
|
|
35
|
+
for (const node of ast.children) {
|
|
36
|
+
if (node.type !== 'mdxjsEsm') continue
|
|
37
|
+
const estree = (node as any).data?.estree
|
|
38
|
+
if (!estree) continue
|
|
39
|
+
|
|
40
|
+
for (const statement of estree.body) {
|
|
41
|
+
if (statement.type !== 'ImportDeclaration') continue
|
|
42
|
+
const source = statement.source?.value
|
|
43
|
+
if (typeof source !== 'string') continue
|
|
44
|
+
|
|
45
|
+
const specifiers: MdxImportSpecifier[] = []
|
|
46
|
+
for (const spec of statement.specifiers ?? []) {
|
|
47
|
+
if (spec.type === 'ImportDefaultSpecifier') {
|
|
48
|
+
specifiers.push({ local: spec.local.name, imported: 'default', type: 'default' })
|
|
49
|
+
} else if (spec.type === 'ImportSpecifier') {
|
|
50
|
+
const importedName = spec.imported.type === 'Identifier'
|
|
51
|
+
? spec.imported.name
|
|
52
|
+
: String(spec.imported.value)
|
|
53
|
+
specifiers.push({ local: spec.local.name, imported: importedName, type: 'named' })
|
|
54
|
+
} else if (spec.type === 'ImportNamespaceSpecifier') {
|
|
55
|
+
specifiers.push({ local: spec.local.name, imported: '*', type: 'namespace' })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (specifiers.length > 0) {
|
|
60
|
+
imports.push({ source, specifiers })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return imports
|
|
66
|
+
}
|
|
67
|
+
|
|
12
68
|
export function mdxParse(code: string) {
|
|
13
69
|
const file = mdxProcessor.processSync(code)
|
|
14
70
|
return file.data.ast as Root
|
|
@@ -95,6 +151,115 @@ export function remarkMarkAndUnravel() {
|
|
|
95
151
|
}
|
|
96
152
|
}
|
|
97
153
|
|
|
154
|
+
/* ── Module resolution ───────────────────────────────────────────────── */
|
|
155
|
+
|
|
156
|
+
/** Extensions tried when resolving a bare import against glob keys */
|
|
157
|
+
const RESOLVE_EXTENSIONS = [
|
|
158
|
+
'', '.tsx', '.ts', '.jsx', '.js', '.mdx', '.md',
|
|
159
|
+
'/index.tsx', '/index.ts', '/index.jsx', '/index.js',
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Given an import source and a baseUrl, resolve the source to a key
|
|
164
|
+
* that exists in `moduleKeys`. Handles:
|
|
165
|
+
* - Relative imports (`./card`) resolved from `baseUrl`
|
|
166
|
+
* - Absolute imports (`/snippets/card`) normalized to `./snippets/card`
|
|
167
|
+
* - Extension resolution (tries .tsx, .ts, .jsx, .js, .mdx, .md, /index.*)
|
|
168
|
+
*/
|
|
169
|
+
export function resolveModulePath(
|
|
170
|
+
source: string,
|
|
171
|
+
baseUrl: string,
|
|
172
|
+
moduleKeys: string[],
|
|
173
|
+
): string | undefined {
|
|
174
|
+
let normalized: string
|
|
175
|
+
|
|
176
|
+
if (source.startsWith('/')) {
|
|
177
|
+
// Absolute import from project root: /snippets/card → ./snippets/card
|
|
178
|
+
normalized = '.' + source
|
|
179
|
+
} else if (source.startsWith('./') || source.startsWith('../')) {
|
|
180
|
+
// Relative import: resolve from baseUrl
|
|
181
|
+
const joined = joinPaths(baseUrl, source)
|
|
182
|
+
if (!joined) return undefined // .. escaped above root
|
|
183
|
+
normalized = joined
|
|
184
|
+
} else {
|
|
185
|
+
// Bare specifier (npm package etc.) — not resolvable from glob
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Try each extension
|
|
190
|
+
for (const ext of RESOLVE_EXTENSIONS) {
|
|
191
|
+
const candidate = normalized + ext
|
|
192
|
+
if (moduleKeys.includes(candidate)) {
|
|
193
|
+
return candidate
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Simple path join that normalizes `./a/b/../c` segments.
|
|
201
|
+
* Both inputs and output use `./` prefix (matching Vite glob key format).
|
|
202
|
+
* Returns `undefined` if `..` escapes above the project root. */
|
|
203
|
+
function joinPaths(base: string, relative: string): string | undefined {
|
|
204
|
+
// Strip ./ prefix and trailing /
|
|
205
|
+
const baseParts = base.replace(/^\.\//, '').replace(/\/$/, '').split('/').filter(Boolean)
|
|
206
|
+
const relParts = relative.replace(/^\.\//, '').split('/').filter(Boolean)
|
|
207
|
+
|
|
208
|
+
for (const part of relParts) {
|
|
209
|
+
if (part === '..') {
|
|
210
|
+
if (baseParts.length === 0) return undefined // escaped above root
|
|
211
|
+
baseParts.pop()
|
|
212
|
+
} else if (part !== '.') {
|
|
213
|
+
baseParts.push(part)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return './' + baseParts.join('/')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type LazyGlob = Record<string, () => Promise<Record<string, any>>>
|
|
221
|
+
export type EagerModules = Record<string, Record<string, any>>
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Given a lazy Vite glob and a parsed mdast, resolve only the imported
|
|
225
|
+
* modules eagerly. Returns the exact shape `SafeMdxRenderer.modules` expects.
|
|
226
|
+
*
|
|
227
|
+
* Usage:
|
|
228
|
+
* ```ts
|
|
229
|
+
* const lazyGlob = import.meta.glob('./snippets/*.tsx')
|
|
230
|
+
* const mdast = mdxParse(mdxString)
|
|
231
|
+
* const modules = await resolveModules({ glob: lazyGlob, mdast, baseUrl: './pages/' })
|
|
232
|
+
* <SafeMdxRenderer modules={modules} baseUrl="./pages/" ... />
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
export async function resolveModules({
|
|
236
|
+
glob,
|
|
237
|
+
mdast,
|
|
238
|
+
baseUrl,
|
|
239
|
+
}: {
|
|
240
|
+
glob: LazyGlob
|
|
241
|
+
mdast: Root
|
|
242
|
+
baseUrl: string
|
|
243
|
+
}): Promise<EagerModules> {
|
|
244
|
+
const imports = extractImports(mdast)
|
|
245
|
+
if (imports.length === 0) return {}
|
|
246
|
+
|
|
247
|
+
const keys = Object.keys(glob)
|
|
248
|
+
const result: EagerModules = {}
|
|
249
|
+
|
|
250
|
+
await Promise.all(
|
|
251
|
+
imports.map(async (imp) => {
|
|
252
|
+
const resolved = resolveModulePath(imp.source, baseUrl, keys)
|
|
253
|
+
if (!resolved || !glob[resolved]) return
|
|
254
|
+
// Avoid loading the same module twice
|
|
255
|
+
if (result[resolved]) return
|
|
256
|
+
result[resolved] = await glob[resolved]()
|
|
257
|
+
}),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
98
263
|
const mdxProcessor = remark()
|
|
99
264
|
.use(remarkMdx)
|
|
100
265
|
.use(remarkFrontmatter, ['yaml', 'toml'])
|
package/src/safe-mdx.test.tsx
CHANGED
|
@@ -483,6 +483,7 @@ test('missing components are ignored', () => {
|
|
|
483
483
|
{
|
|
484
484
|
"line": 1,
|
|
485
485
|
"message": "Unsupported jsx component MissingComponent",
|
|
486
|
+
"type": "missing-component",
|
|
486
487
|
},
|
|
487
488
|
],
|
|
488
489
|
"html": "",
|
|
@@ -520,10 +521,12 @@ test('props parsing', () => {
|
|
|
520
521
|
{
|
|
521
522
|
"line": 8,
|
|
522
523
|
"message": "Failed to evaluate expression attribute: expression2={Boolean(1)}. Functions are not supported",
|
|
524
|
+
"type": "expression",
|
|
523
525
|
},
|
|
524
526
|
{
|
|
525
527
|
"line": 8,
|
|
526
528
|
"message": "Expressions in jsx prop not evaluated: (expression2={Boolean(1)})",
|
|
529
|
+
"type": "expression",
|
|
527
530
|
},
|
|
528
531
|
{
|
|
529
532
|
"line": 9,
|
|
@@ -532,10 +535,12 @@ test('props parsing', () => {
|
|
|
532
535
|
{
|
|
533
536
|
"line": 9,
|
|
534
537
|
"message": "Failed to evaluate expression attribute: jsx={<SomeComponent />}. visitor "JSXElement" is not supported",
|
|
538
|
+
"type": "expression",
|
|
535
539
|
},
|
|
536
540
|
{
|
|
537
541
|
"line": 9,
|
|
538
542
|
"message": "Expressions in jsx prop not evaluated: (jsx={<SomeComponent />})",
|
|
543
|
+
"type": "expression",
|
|
539
544
|
},
|
|
540
545
|
],
|
|
541
546
|
"html": "<h1 num="2" doublequote="a " string" quote="a ' string" backTick="some undefined value" expression1="4" someJson="[object Object]"><p>hi</p></h1>",
|
|
@@ -2531,16 +2536,19 @@ test('component props schema validation with zod', () => {
|
|
|
2531
2536
|
"line": 5,
|
|
2532
2537
|
"message": "Invalid props for component "Heading" at "level": Too big: expected number to be <=6",
|
|
2533
2538
|
"schemaPath": "level",
|
|
2539
|
+
"type": "validation",
|
|
2534
2540
|
},
|
|
2535
2541
|
{
|
|
2536
2542
|
"line": 7,
|
|
2537
2543
|
"message": "Invalid props for component "Cards" at "count": Too small: expected number to be >0",
|
|
2538
2544
|
"schemaPath": "count",
|
|
2545
|
+
"type": "validation",
|
|
2539
2546
|
},
|
|
2540
2547
|
{
|
|
2541
2548
|
"line": 9,
|
|
2542
2549
|
"message": "Invalid props for component "Cards" at "count": Invalid input: expected number, received string",
|
|
2543
2550
|
"schemaPath": "count",
|
|
2551
|
+
"type": "validation",
|
|
2544
2552
|
},
|
|
2545
2553
|
],
|
|
2546
2554
|
"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 +2586,66 @@ test('component props schema validation with zod', () => {
|
|
|
2578
2586
|
`)
|
|
2579
2587
|
})
|
|
2580
2588
|
|
|
2589
|
+
test('onError callback is called for each error', () => {
|
|
2590
|
+
const errors: any[] = []
|
|
2591
|
+
const code = dedent`
|
|
2592
|
+
<Missing>should error</Missing>
|
|
2593
|
+
|
|
2594
|
+
<Heading level={10}>invalid level</Heading>
|
|
2595
|
+
`
|
|
2596
|
+
const componentPropsSchema: ComponentPropsSchema = {
|
|
2597
|
+
Heading: z.object({
|
|
2598
|
+
level: z.number().min(1).max(6),
|
|
2599
|
+
}),
|
|
2600
|
+
}
|
|
2601
|
+
const mdast = mdxParse(code)
|
|
2602
|
+
const visitor = new MdastToJsx({
|
|
2603
|
+
markdown: code,
|
|
2604
|
+
mdast,
|
|
2605
|
+
components,
|
|
2606
|
+
componentPropsSchema,
|
|
2607
|
+
onError: (err) => errors.push(err),
|
|
2608
|
+
})
|
|
2609
|
+
visitor.run()
|
|
2610
|
+
expect(errors).toMatchInlineSnapshot(`
|
|
2611
|
+
[
|
|
2612
|
+
{
|
|
2613
|
+
"line": 1,
|
|
2614
|
+
"message": "Unsupported jsx component Missing",
|
|
2615
|
+
"type": "missing-component",
|
|
2616
|
+
},
|
|
2617
|
+
{
|
|
2618
|
+
"line": 3,
|
|
2619
|
+
"message": "Invalid props for component "Heading" at "level": Too big: expected number to be <=6",
|
|
2620
|
+
"schemaPath": "level",
|
|
2621
|
+
"type": "validation",
|
|
2622
|
+
},
|
|
2623
|
+
]
|
|
2624
|
+
`)
|
|
2625
|
+
// errors array and onError callback should have the same errors
|
|
2626
|
+
expect(visitor.errors).toEqual(errors)
|
|
2627
|
+
})
|
|
2628
|
+
|
|
2629
|
+
test('onError callback can throw to stop rendering', () => {
|
|
2630
|
+
const code = dedent`
|
|
2631
|
+
<Missing>should throw</Missing>
|
|
2632
|
+
|
|
2633
|
+
<Heading>should not reach</Heading>
|
|
2634
|
+
`
|
|
2635
|
+
const mdast = mdxParse(code)
|
|
2636
|
+
expect(() => {
|
|
2637
|
+
const visitor = new MdastToJsx({
|
|
2638
|
+
markdown: code,
|
|
2639
|
+
mdast,
|
|
2640
|
+
components,
|
|
2641
|
+
onError: (err) => {
|
|
2642
|
+
throw new Error(`MDX error on line ${err.line}: ${err.message}`)
|
|
2643
|
+
},
|
|
2644
|
+
})
|
|
2645
|
+
visitor.run()
|
|
2646
|
+
}).toThrow('MDX error on line 1: Unsupported jsx component Missing')
|
|
2647
|
+
})
|
|
2648
|
+
|
|
2581
2649
|
test('mdx expressions evaluation', () => {
|
|
2582
2650
|
expect(
|
|
2583
2651
|
render(dedent`
|
|
@@ -2635,10 +2703,12 @@ test('mdx expressions with unsupported functions', () => {
|
|
|
2635
2703
|
{
|
|
2636
2704
|
"line": 1,
|
|
2637
2705
|
"message": "Failed to evaluate expression: Math.max(5, 10). Functions are not supported",
|
|
2706
|
+
"type": "expression",
|
|
2638
2707
|
},
|
|
2639
2708
|
{
|
|
2640
2709
|
"line": 2,
|
|
2641
2710
|
"message": "Failed to evaluate expression: console.log("test"). Functions are not supported",
|
|
2711
|
+
"type": "expression",
|
|
2642
2712
|
},
|
|
2643
2713
|
],
|
|
2644
2714
|
"html": "<p>Math function:
|
|
@@ -2750,11 +2820,13 @@ test('validation error includes schema path', () => {
|
|
|
2750
2820
|
"line": 1,
|
|
2751
2821
|
"message": "Invalid props for component "Heading" at "user.age": Too small: expected number to be >=0",
|
|
2752
2822
|
"schemaPath": "user.age",
|
|
2823
|
+
"type": "validation",
|
|
2753
2824
|
},
|
|
2754
2825
|
{
|
|
2755
2826
|
"line": 1,
|
|
2756
2827
|
"message": "Invalid props for component "Heading" at "settings.theme": Invalid option: expected one of "light"|"dark"",
|
|
2757
2828
|
"schemaPath": "settings.theme",
|
|
2829
|
+
"type": "validation",
|
|
2758
2830
|
},
|
|
2759
2831
|
],
|
|
2760
2832
|
"html": "<h1 user="[object Object]" settings="[object Object]">Complex validation</h1>",
|
|
@@ -2887,6 +2959,7 @@ test('mdxJsxExpressionAttribute edge cases', () => {
|
|
|
2887
2959
|
{
|
|
2888
2960
|
"line": 3,
|
|
2889
2961
|
"message": "Failed to evaluate expression attribute: ...{null: null, undefined: undefined}. undefined is undefined",
|
|
2962
|
+
"type": "expression",
|
|
2890
2963
|
},
|
|
2891
2964
|
],
|
|
2892
2965
|
"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>",
|
|
@@ -3147,10 +3220,12 @@ test("jsx components in attributes error handling", () => {
|
|
|
3147
3220
|
{
|
|
3148
3221
|
"line": 3,
|
|
3149
3222
|
"message": "Failed to evaluate expression attribute: icon={<UnsupportedComponent />}. visitor "JSXElement" is not supported",
|
|
3223
|
+
"type": "expression",
|
|
3150
3224
|
},
|
|
3151
3225
|
{
|
|
3152
3226
|
"line": 3,
|
|
3153
3227
|
"message": "Expressions in jsx prop not evaluated: (icon={<UnsupportedComponent />})",
|
|
3228
|
+
"type": "expression",
|
|
3154
3229
|
},
|
|
3155
3230
|
]
|
|
3156
3231
|
`)
|
|
@@ -3498,5 +3573,119 @@ test("skip unknown elements in complex nested HTML structures", () => {
|
|
|
3498
3573
|
</div>
|
|
3499
3574
|
`);
|
|
3500
3575
|
|
|
3501
|
-
|
|
3576
|
+
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
3577
|
});
|
|
3578
|
+
|
|
3579
|
+
/* ── modules prop tests ─────────────────────────────────────────────── */
|
|
3580
|
+
|
|
3581
|
+
test('modules prop: resolves named import from absolute path', () => {
|
|
3582
|
+
function CustomBadge({ label }: { label: string }) {
|
|
3583
|
+
return <span className="badge">{label}</span>
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
const code = dedent`
|
|
3587
|
+
import { CustomBadge } from '/snippets/badge'
|
|
3588
|
+
|
|
3589
|
+
# Hello
|
|
3590
|
+
|
|
3591
|
+
<CustomBadge label="Works" />
|
|
3592
|
+
`
|
|
3593
|
+
const mdast = mdxParse(code)
|
|
3594
|
+
const visitor = new MdastToJsx({
|
|
3595
|
+
markdown: code,
|
|
3596
|
+
mdast,
|
|
3597
|
+
components,
|
|
3598
|
+
modules: {
|
|
3599
|
+
'./snippets/badge.tsx': { CustomBadge },
|
|
3600
|
+
},
|
|
3601
|
+
baseUrl: './pages/',
|
|
3602
|
+
})
|
|
3603
|
+
const result = visitor.run()
|
|
3604
|
+
const html = renderToStaticMarkup(result)
|
|
3605
|
+
expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1><span class="badge">Works</span>"`)
|
|
3606
|
+
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
|
|
3607
|
+
})
|
|
3608
|
+
|
|
3609
|
+
test('modules prop: resolves default import from relative path', () => {
|
|
3610
|
+
function MyCard({ children }: { children?: React.ReactNode }) {
|
|
3611
|
+
return <div className="card">{children}</div>
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
const code = dedent`
|
|
3615
|
+
import MyCard from './card'
|
|
3616
|
+
|
|
3617
|
+
<MyCard>content</MyCard>
|
|
3618
|
+
`
|
|
3619
|
+
const mdast = mdxParse(code)
|
|
3620
|
+
const visitor = new MdastToJsx({
|
|
3621
|
+
markdown: code,
|
|
3622
|
+
mdast,
|
|
3623
|
+
components,
|
|
3624
|
+
modules: {
|
|
3625
|
+
'./pages/card.tsx': { default: MyCard },
|
|
3626
|
+
},
|
|
3627
|
+
baseUrl: './pages/',
|
|
3628
|
+
})
|
|
3629
|
+
const result = visitor.run()
|
|
3630
|
+
const html = renderToStaticMarkup(result)
|
|
3631
|
+
expect(html).toMatchInlineSnapshot(`"<div class="card">content</div>"`)
|
|
3632
|
+
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
|
|
3633
|
+
})
|
|
3634
|
+
|
|
3635
|
+
test('modules prop: resolves namespace import with dot notation', () => {
|
|
3636
|
+
function Card({ title }: { title: string }) {
|
|
3637
|
+
return <div>{title}</div>
|
|
3638
|
+
}
|
|
3639
|
+
function Badge({ label }: { label: string }) {
|
|
3640
|
+
return <span>{label}</span>
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
const code = dedent`
|
|
3644
|
+
import * as UI from './ui'
|
|
3645
|
+
|
|
3646
|
+
<UI.Card title="hello" />
|
|
3647
|
+
<UI.Badge label="tag" />
|
|
3648
|
+
`
|
|
3649
|
+
const mdast = mdxParse(code)
|
|
3650
|
+
const visitor = new MdastToJsx({
|
|
3651
|
+
markdown: code,
|
|
3652
|
+
mdast,
|
|
3653
|
+
components,
|
|
3654
|
+
modules: {
|
|
3655
|
+
'./components/ui.tsx': { Card, Badge },
|
|
3656
|
+
},
|
|
3657
|
+
baseUrl: './components/',
|
|
3658
|
+
})
|
|
3659
|
+
const result = visitor.run()
|
|
3660
|
+
const html = renderToStaticMarkup(result)
|
|
3661
|
+
expect(html).toMatchInlineSnapshot(`"<div>hello</div><span>tag</span>"`)
|
|
3662
|
+
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
|
|
3663
|
+
})
|
|
3664
|
+
|
|
3665
|
+
test('modules prop: unresolved import produces error', () => {
|
|
3666
|
+
const code = dedent`
|
|
3667
|
+
import { Missing } from './nonexistent'
|
|
3668
|
+
|
|
3669
|
+
<Missing />
|
|
3670
|
+
`
|
|
3671
|
+
const mdast = mdxParse(code)
|
|
3672
|
+
const visitor = new MdastToJsx({
|
|
3673
|
+
markdown: code,
|
|
3674
|
+
mdast,
|
|
3675
|
+
components,
|
|
3676
|
+
modules: {},
|
|
3677
|
+
baseUrl: './pages/',
|
|
3678
|
+
})
|
|
3679
|
+
const result = visitor.run()
|
|
3680
|
+
const html = renderToStaticMarkup(result)
|
|
3681
|
+
expect(html).toMatchInlineSnapshot(`""`)
|
|
3682
|
+
expect(visitor.errors).toMatchInlineSnapshot(`
|
|
3683
|
+
[
|
|
3684
|
+
{
|
|
3685
|
+
"line": 3,
|
|
3686
|
+
"message": "Unsupported jsx component Missing",
|
|
3687
|
+
"type": "missing-component",
|
|
3688
|
+
},
|
|
3689
|
+
]
|
|
3690
|
+
`)
|
|
3691
|
+
})
|