safe-mdx 1.3.10 → 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/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'])
@@ -3498,5 +3498,118 @@ test("skip unknown elements in complex nested HTML structures", () => {
3498
3498
  </div>
3499
3499
  `);
3500
3500
 
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>"`);
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
@@ -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
 
@@ -51,6 +52,8 @@ 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
@@ -60,6 +63,13 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
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(
@@ -588,7 +648,11 @@ export class MdastToJsx {
588
648
 
589
649
  switch (node.type) {
590
650
  case 'mdxjsEsm': {
591
- // Parse ESM imports and merge into our imports map (only if allowed)
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)
592
656
  if (this.allowClientEsmImports) {
593
657
  const parsedImports = parseEsmImports(node, (err) =>
594
658
  this.errors.push(err),