safe-mdx 1.4.0 → 1.6.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.
Files changed (43) hide show
  1. package/README.md +275 -13
  2. package/dist/dynamic-esm-component.d.ts +1 -1
  3. package/dist/dynamic-esm-component.d.ts.map +1 -1
  4. package/dist/dynamic-esm-component.js +9 -1
  5. package/dist/dynamic-esm-component.js.map +1 -1
  6. package/dist/esm-parser.d.ts +1 -1
  7. package/dist/esm-parser.d.ts.map +1 -1
  8. package/dist/esm-parser.js +5 -3
  9. package/dist/esm-parser.js.map +1 -1
  10. package/dist/esm-parser.test.js +5 -2
  11. package/dist/esm-parser.test.js.map +1 -1
  12. package/dist/html/html-and-md.test.js.map +1 -1
  13. package/dist/html/html-to-mdx-ast.d.ts +1 -1
  14. package/dist/html/html-to-mdx-ast.js +4 -4
  15. package/dist/html/html-to-mdx-ast.js.map +1 -1
  16. package/dist/html/html-to-mdx-ast.test.js +3 -3
  17. package/dist/html/html-to-mdx-ast.test.js.map +1 -1
  18. package/dist/parse.d.ts +1 -1
  19. package/dist/parse.d.ts.map +1 -1
  20. package/dist/parse.js +5 -1
  21. package/dist/parse.js.map +1 -1
  22. package/dist/safe-mdx.bench.js +2 -2
  23. package/dist/safe-mdx.bench.js.map +1 -1
  24. package/dist/safe-mdx.d.ts +45 -3
  25. package/dist/safe-mdx.d.ts.map +1 -1
  26. package/dist/safe-mdx.js +62 -36
  27. package/dist/safe-mdx.js.map +1 -1
  28. package/dist/safe-mdx.test.js +221 -5
  29. package/dist/safe-mdx.test.js.map +1 -1
  30. package/dist/streaming.d.ts.map +1 -1
  31. package/dist/streaming.js +3 -1
  32. package/dist/streaming.js.map +1 -1
  33. package/package.json +30 -7
  34. package/src/esm-parser.test.ts +6 -3
  35. package/src/esm-parser.ts +6 -4
  36. package/src/html/html-and-md.test.ts +2 -2
  37. package/src/html/html-to-mdx-ast.test.ts +3 -3
  38. package/src/html/html-to-mdx-ast.ts +4 -4
  39. package/src/parse.ts +3 -1
  40. package/src/safe-mdx.bench.tsx +2 -2
  41. package/src/safe-mdx.test.tsx +251 -11
  42. package/src/safe-mdx.tsx +109 -36
  43. package/src/streaming.tsx +2 -1
package/src/safe-mdx.tsx CHANGED
@@ -8,10 +8,10 @@ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
8
8
 
9
9
  import { Fragment, ReactNode } from 'react'
10
10
  import { DynamicEsmComponent } from 'safe-mdx/client'
11
- import { extractComponentInfo, parseEsmImports } from './esm-parser.js'
12
- import { resolveModulePath, type EagerModules } from './parse.js'
13
- import { htmlToMdxAst } from './html/html-to-mdx-ast.js'
14
- import { validHtmlElements, nativeTags } from './html/valid-html-elements.js'
11
+ import { extractComponentInfo, parseEsmImports } from './esm-parser.ts'
12
+ import { resolveModulePath, type EagerModules } from './parse.ts'
13
+ import { htmlToMdxAst } from './html/html-to-mdx-ast.ts'
14
+ import { validHtmlElements, nativeTags } from './html/valid-html-elements.ts'
15
15
 
16
16
  export type MyRootContent = RootContent | Root
17
17
 
@@ -29,7 +29,10 @@ export type RenderNode = (
29
29
  transform: (node: MyRootContent) => ReactNode,
30
30
  ) => ReactNode | undefined
31
31
 
32
+ export type SafeMdxErrorType = 'validation' | 'missing-component' | 'expression' | 'esm-import'
33
+
32
34
  export interface SafeMdxError {
35
+ type: SafeMdxErrorType
33
36
  message: string
34
37
  line?: number
35
38
  schemaPath?: string
@@ -43,6 +46,18 @@ export type CreateElementFunction = (
43
46
  ...children: ReactNode[]
44
47
  ) => ReactNode
45
48
 
49
+ export interface EvaluateOptions {
50
+ /** Enable function calls in expressions. Automatically enabled when `scope` is provided. */
51
+ functions?: boolean
52
+ /** Pass `escodegen.generate` to support inline function expressions
53
+ * like arrow functions in `.map(x => x.name)`. Requires `functions: true`. */
54
+ generate?: (ast: any) => string
55
+ /** Force logical operators (`&&`, `||`) to return booleans. */
56
+ booleanLogicalOperators?: boolean
57
+ /** Throw when variables referenced in expressions are undefined. */
58
+ strict?: boolean
59
+ }
60
+
46
61
  export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
47
62
  components,
48
63
  markdown = '',
@@ -54,6 +69,9 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
54
69
  addMarkdownLineNumbers = false,
55
70
  modules,
56
71
  baseUrl,
72
+ onError,
73
+ scope,
74
+ evaluateOptions,
57
75
  }: {
58
76
  components?: ComponentsMap
59
77
  markdown?: string
@@ -70,6 +88,18 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
70
88
  /** Directory of the current MDX file, used to resolve relative import
71
89
  * sources against `modules` keys. E.g. `'./pages/getting-started/'` */
72
90
  baseUrl?: string
91
+ /** Called for each error during rendering (missing components, invalid props, failed expressions).
92
+ * Throw inside this callback to stop rendering on first error. */
93
+ onError?: (error: SafeMdxError) => void
94
+ /** Variables and functions available in MDX expressions.
95
+ * When scope contains functions, function calls in expressions are
96
+ * automatically enabled. */
97
+ scope?: Record<string, any>
98
+ /** Options passed to `eval-estree-expression` for expression evaluation.
99
+ * Pass `{ functions: true }` to enable function calls, or
100
+ * `{ functions: true, generate: escodegen.generate }` to also support
101
+ * inline arrow functions and callbacks like `.map(x => x.name)`. */
102
+ evaluateOptions?: EvaluateOptions
73
103
  }) {
74
104
  const visitor = new MdastToJsx({
75
105
  markdown,
@@ -82,6 +112,9 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
82
112
  addMarkdownLineNumbers,
83
113
  modules,
84
114
  baseUrl,
115
+ onError,
116
+ scope,
117
+ evaluateOptions,
85
118
  })
86
119
  const result = visitor.run()
87
120
  return result
@@ -101,6 +134,9 @@ export class MdastToJsx {
101
134
  addMarkdownLineNumbers: boolean
102
135
  modules?: EagerModules
103
136
  baseUrl?: string
137
+ onError?: (error: SafeMdxError) => void
138
+ scope?: Record<string, any>
139
+ evaluateOptions?: EvaluateOptions
104
140
 
105
141
  constructor({
106
142
  markdown: code = '',
@@ -113,6 +149,9 @@ export class MdastToJsx {
113
149
  addMarkdownLineNumbers = false,
114
150
  modules,
115
151
  baseUrl,
152
+ onError,
153
+ scope,
154
+ evaluateOptions,
116
155
  }: {
117
156
  markdown?: string
118
157
  mdast: MyRootContent
@@ -127,6 +166,18 @@ export class MdastToJsx {
127
166
  addMarkdownLineNumbers?: boolean
128
167
  modules?: EagerModules
129
168
  baseUrl?: string
169
+ /** Called for each error during rendering (missing components, invalid props, failed expressions).
170
+ * Throw inside this callback to stop rendering on first error. */
171
+ onError?: (error: SafeMdxError) => void
172
+ /** Variables and functions available in MDX expressions.
173
+ * When scope contains functions, function calls in expressions are
174
+ * automatically enabled. */
175
+ scope?: Record<string, any>
176
+ /** Options passed to `eval-estree-expression` for expression evaluation.
177
+ * Pass `{ functions: true }` to enable function calls, or
178
+ * `{ functions: true, generate: escodegen.generate }` to also support
179
+ * inline arrow functions and callbacks like `.map(x => x.name)`. */
180
+ evaluateOptions?: EvaluateOptions
130
181
  }) {
131
182
  this.str = code
132
183
 
@@ -144,6 +195,9 @@ export class MdastToJsx {
144
195
 
145
196
  this.modules = modules
146
197
  this.baseUrl = baseUrl
198
+ this.onError = onError
199
+ this.scope = scope
200
+ this.evaluateOptions = evaluateOptions
147
201
 
148
202
  this.c = {
149
203
  ...Object.fromEntries(
@@ -156,6 +210,11 @@ export class MdastToJsx {
156
210
 
157
211
  }
158
212
 
213
+ pushError(error: SafeMdxError): void {
214
+ this.errors.push(error)
215
+ this.onError?.(error)
216
+ }
217
+
159
218
  /**
160
219
  * Resolve import declarations from an mdxjsEsm node against `this.modules`.
161
220
  * Resolved components are added directly to `this.c` (the component map)
@@ -234,7 +293,8 @@ export class MdastToJsx {
234
293
  if (result.issues) {
235
294
  result.issues.forEach((issue) => {
236
295
  const propPath = issue.path?.join('.') || 'unknown'
237
- this.errors.push({
296
+ this.pushError({
297
+ type: 'validation',
238
298
  message: `Invalid props for component "${componentName}" at "${propPath}": ${issue.message}`,
239
299
  line,
240
300
  schemaPath: issue.path?.join('.'),
@@ -302,7 +362,7 @@ export class MdastToJsx {
302
362
  extractComponentInfo(esmImportInfo)
303
363
  Component = DynamicEsmComponent
304
364
  let attrsList = this.getJsxAttrs(node, (err) => {
305
- this.errors.push(err)
365
+ this.pushError(err)
306
366
  })
307
367
  let attrs = Object.fromEntries(attrsList)
308
368
 
@@ -318,7 +378,8 @@ export class MdastToJsx {
318
378
  Component = accessWithDot(this.c, node.name)
319
379
 
320
380
  if (!Component) {
321
- this.errors.push({
381
+ this.pushError({
382
+ type: 'missing-component',
322
383
  message: `Unsupported jsx component ${node.name}`,
323
384
  line: node.position?.start?.line,
324
385
  })
@@ -327,7 +388,7 @@ export class MdastToJsx {
327
388
  }
328
389
 
329
390
  let attrsList = this.getJsxAttrs(node, (err) => {
330
- this.errors.push(err)
391
+ this.pushError(err)
331
392
  })
332
393
 
333
394
  let attrs = Object.fromEntries(attrsList)
@@ -365,6 +426,7 @@ export class MdastToJsx {
365
426
  : null
366
427
  if (!tagName) {
367
428
  onError?.({
429
+ type: 'expression',
368
430
  message: 'JSX element missing component name',
369
431
  line: line,
370
432
  })
@@ -387,6 +449,7 @@ export class MdastToJsx {
387
449
  Component = accessWithDot(this.c, tagName)
388
450
  if (!Component) {
389
451
  onError?.({
452
+ type: 'missing-component',
390
453
  message: `Unsupported jsx component ${tagName} in attribute`,
391
454
  line: line,
392
455
  })
@@ -454,6 +517,7 @@ export class MdastToJsx {
454
517
  } catch (error) {
455
518
  // Return null if transformation fails
456
519
  onError?.({
520
+ type: 'expression',
457
521
  message: `Failed to transform JSX element: ${
458
522
  error instanceof Error ? error.message : 'Unknown error'
459
523
  }`,
@@ -464,6 +528,15 @@ export class MdastToJsx {
464
528
  return null
465
529
  }
466
530
 
531
+ evaluateExpression(expression: any) {
532
+ const hasScope = this.scope && Object.keys(this.scope).length > 0
533
+ const context = hasScope ? this.scope : undefined
534
+ const options = hasScope || this.evaluateOptions
535
+ ? { ...(hasScope ? { functions: true } : {}), ...this.evaluateOptions }
536
+ : undefined
537
+ return Evaluate.evaluate.sync(expression, context, options)
538
+ }
539
+
467
540
  getJsxAttrs(
468
541
  node: MdxJsxFlowElement | MdxJsxTextElement,
469
542
  onError: (err: SafeMdxError) => void = console.error,
@@ -476,14 +549,15 @@ export class MdastToJsx {
476
549
  if (attr.data?.estree) {
477
550
  try {
478
551
  const program = attr.data.estree
552
+ const firstBody = program.body?.[0]
479
553
  if (
480
- program.body?.length > 0 &&
481
- program.body[0].type === 'ExpressionStatement'
554
+ firstBody &&
555
+ firstBody.type === 'ExpressionStatement'
482
556
  ) {
483
- const expression = program.body[0].expression
557
+ const expression = firstBody.expression
484
558
  try {
485
559
  const result =
486
- Evaluate.evaluate.sync(expression)
560
+ this.evaluateExpression(expression)
487
561
 
488
562
  // Handle spread syntax - merge the evaluated object
489
563
  if (
@@ -495,6 +569,7 @@ export class MdastToJsx {
495
569
  }
496
570
  } catch (error) {
497
571
  onError({
572
+ type: 'expression',
498
573
  message: `Failed to evaluate expression attribute: ${attr.value
499
574
  .replace(/\n+/g, ' ')
500
575
  .replace(/ +/g, ' ')}. ${
@@ -508,6 +583,7 @@ export class MdastToJsx {
508
583
  }
509
584
  } catch (error) {
510
585
  onError({
586
+ type: 'expression',
511
587
  message: `Failed to evaluate expression attribute: ${attr.value
512
588
  .replace(/\n+/g, ' ')
513
589
  .replace(/ +/g, ' ')}. ${
@@ -520,6 +596,7 @@ export class MdastToJsx {
520
596
  }
521
597
  } else {
522
598
  onError({
599
+ type: 'expression',
523
600
  message: `Expressions in jsx props are not supported (${attr.value
524
601
  .replace(/\n+/g, ' ')
525
602
  .replace(/ +/g, ' ')})`,
@@ -531,6 +608,7 @@ export class MdastToJsx {
531
608
 
532
609
  if (attr.type !== 'mdxJsxAttribute') {
533
610
  onError({
611
+ type: 'expression',
534
612
  message: `non mdxJsxAttribute attribute is not supported: ${attr}`,
535
613
  line: node.position?.start?.line,
536
614
  })
@@ -569,11 +647,12 @@ export class MdastToJsx {
569
647
  try {
570
648
  // Extract the expression from the Program body
571
649
  const program = v.data.estree
650
+ const firstBody = program.body?.[0]
572
651
  if (
573
- program.body?.length > 0 &&
574
- program.body[0].type === 'ExpressionStatement'
652
+ firstBody &&
653
+ firstBody.type === 'ExpressionStatement'
575
654
  ) {
576
- const expression = program.body[0].expression
655
+ const expression = firstBody.expression
577
656
 
578
657
  // Check if this is a JSX element
579
658
  if (expression.type === 'JSXElement') {
@@ -592,11 +671,12 @@ export class MdastToJsx {
592
671
  try {
593
672
  // Evaluate the expression synchronously
594
673
  const result =
595
- Evaluate.evaluate.sync(expression)
674
+ this.evaluateExpression(expression)
596
675
  attrsList.push([attr.name, result])
597
676
  continue
598
677
  } catch (error) {
599
678
  onError({
679
+ type: 'expression',
600
680
  message: `Failed to evaluate expression attribute: ${
601
681
  attr.name
602
682
  }={${v.value}}. ${
@@ -614,6 +694,7 @@ export class MdastToJsx {
614
694
  }
615
695
 
616
696
  onError({
697
+ type: 'expression',
617
698
  message: `Expressions in jsx prop not evaluated: (${attr.name}={${v.value}})`,
618
699
  line: attr.position?.start?.line,
619
700
  })
@@ -623,7 +704,7 @@ export class MdastToJsx {
623
704
  }
624
705
 
625
706
  run() {
626
- const res = this.mdastTransformer(this.mdast, 'root') as ReactNode
707
+ const res = this.mdastTransformer(this.mdast, 'root')
627
708
  if (Array.isArray(res) && res.length === 1) {
628
709
  return res[0]
629
710
  }
@@ -639,7 +720,7 @@ export class MdastToJsx {
639
720
  if (this.renderNode) {
640
721
  const customResult = this.renderNode(
641
722
  node,
642
- (n: MyRootContent) => this.mdastTransformer(n, node.type),
723
+ (n) => this.mdastTransformer(n, node.type),
643
724
  )
644
725
  if (customResult !== undefined) {
645
726
  return customResult
@@ -655,7 +736,7 @@ export class MdastToJsx {
655
736
  // Parse ESM imports for client-side dynamic loading (only if allowed)
656
737
  if (this.allowClientEsmImports) {
657
738
  const parsedImports = parseEsmImports(node, (err) =>
658
- this.errors.push(err),
739
+ this.pushError(err),
659
740
  )
660
741
  parsedImports.forEach((value, key) => {
661
742
  this.esmImports.set(key, value)
@@ -693,18 +774,20 @@ export class MdastToJsx {
693
774
  try {
694
775
  // Extract the expression from the Program body
695
776
  const program = node.data.estree
777
+ const firstBody = program.body?.[0]
696
778
  if (
697
- program.body?.length > 0 &&
698
- program.body[0].type === 'ExpressionStatement'
779
+ firstBody &&
780
+ firstBody.type === 'ExpressionStatement'
699
781
  ) {
700
- const expression = program.body[0].expression
782
+ const expression = firstBody.expression
701
783
  try {
702
784
  // Evaluate the expression synchronously
703
785
  const result =
704
- Evaluate.evaluate.sync(expression)
786
+ this.evaluateExpression(expression)
705
787
  return result
706
788
  } catch (error) {
707
- this.errors.push({
789
+ this.pushError({
790
+ type: 'expression',
708
791
  message: `Failed to evaluate expression: ${
709
792
  node.value
710
793
  }. ${
@@ -717,7 +800,8 @@ export class MdastToJsx {
717
800
  }
718
801
  }
719
802
  } catch (error) {
720
- this.errors.push({
803
+ this.pushError({
804
+ type: 'expression',
721
805
  message: `Failed to evaluate expression: ${
722
806
  node.value
723
807
  }. ${
@@ -1028,9 +1112,6 @@ export class MdastToJsx {
1028
1112
  }
1029
1113
  }
1030
1114
 
1031
- function isTruthy<T>(val: T | undefined | null | false): val is T {
1032
- return Boolean(val)
1033
- }
1034
1115
 
1035
1116
  function accessWithDot(obj, path: string) {
1036
1117
  return path
@@ -1061,14 +1142,6 @@ export function mdastBfs(
1061
1142
  return result
1062
1143
  }
1063
1144
 
1064
- function safeJsonParse(str: string) {
1065
- try {
1066
- return JSON.parse(str)
1067
- } catch (err) {
1068
- return null
1069
- }
1070
- }
1071
-
1072
1145
  type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & {
1073
1146
  [key: string]: any
1074
1147
  }
package/src/streaming.tsx CHANGED
@@ -13,6 +13,7 @@ function matchJsxTag(code: string) {
13
13
  }
14
14
 
15
15
  const [fullMatch, tagName, attributes, selfClosing] = match
16
+ if (!tagName) return null
16
17
 
17
18
  const type = selfClosing
18
19
  ? 'self-closing'
@@ -24,7 +25,7 @@ function matchJsxTag(code: string) {
24
25
  tag: fullMatch,
25
26
  tagName,
26
27
  type,
27
- attributes: attributes.trim(),
28
+ attributes: (attributes ?? '').trim(),
28
29
  startIndex: match.index,
29
30
  endIndex: match.index + fullMatch.length,
30
31
  }