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/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'])
@@ -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 &quot; string" quote="a &#x27; 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
- 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>"`);
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
+ })