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/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.errors.push({
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.errors.push(err)
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.errors.push({
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.errors.push(err)
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
- // Parse ESM imports and merge into our imports map (only if allowed)
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.errors.push(err),
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.errors.push({
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.errors.push({
751
+ this.pushError({
752
+ type: 'expression',
657
753
  message: `Failed to evaluate expression: ${
658
754
  node.value
659
755
  }. ${