safe-mdx 1.5.0 → 1.7.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 (42) hide show
  1. package/README.md +94 -8
  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 +3 -3
  9. package/dist/esm-parser.js.map +1 -1
  10. package/dist/esm-parser.test.js +2 -2
  11. package/dist/html/html-and-md.test.js.map +1 -1
  12. package/dist/html/html-to-mdx-ast.d.ts +1 -1
  13. package/dist/html/html-to-mdx-ast.js +4 -4
  14. package/dist/html/html-to-mdx-ast.js.map +1 -1
  15. package/dist/html/html-to-mdx-ast.test.js +3 -3
  16. package/dist/html/html-to-mdx-ast.test.js.map +1 -1
  17. package/dist/parse.d.ts +1 -1
  18. package/dist/parse.d.ts.map +1 -1
  19. package/dist/parse.js +5 -1
  20. package/dist/parse.js.map +1 -1
  21. package/dist/safe-mdx.bench.js +2 -2
  22. package/dist/safe-mdx.bench.js.map +1 -1
  23. package/dist/safe-mdx.d.ts +48 -3
  24. package/dist/safe-mdx.d.ts.map +1 -1
  25. package/dist/safe-mdx.js +219 -26
  26. package/dist/safe-mdx.js.map +1 -1
  27. package/dist/safe-mdx.test.js +420 -5
  28. package/dist/safe-mdx.test.js.map +1 -1
  29. package/dist/streaming.d.ts.map +1 -1
  30. package/dist/streaming.js +3 -1
  31. package/dist/streaming.js.map +1 -1
  32. package/package.json +30 -7
  33. package/src/esm-parser.test.ts +3 -3
  34. package/src/esm-parser.ts +4 -4
  35. package/src/html/html-and-md.test.ts +2 -2
  36. package/src/html/html-to-mdx-ast.test.ts +3 -3
  37. package/src/html/html-to-mdx-ast.ts +4 -4
  38. package/src/parse.ts +3 -1
  39. package/src/safe-mdx.bench.tsx +2 -2
  40. package/src/safe-mdx.test.tsx +519 -11
  41. package/src/safe-mdx.tsx +315 -28
  42. 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
 
@@ -46,6 +46,18 @@ export type CreateElementFunction = (
46
46
  ...children: ReactNode[]
47
47
  ) => ReactNode
48
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
+
49
61
  export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
50
62
  components,
51
63
  markdown = '',
@@ -58,6 +70,8 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
58
70
  modules,
59
71
  baseUrl,
60
72
  onError,
73
+ scope,
74
+ evaluateOptions,
61
75
  }: {
62
76
  components?: ComponentsMap
63
77
  markdown?: string
@@ -77,6 +91,15 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
77
91
  /** Called for each error during rendering (missing components, invalid props, failed expressions).
78
92
  * Throw inside this callback to stop rendering on first error. */
79
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
80
103
  }) {
81
104
  const visitor = new MdastToJsx({
82
105
  markdown,
@@ -90,6 +113,8 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
90
113
  modules,
91
114
  baseUrl,
92
115
  onError,
116
+ scope,
117
+ evaluateOptions,
93
118
  })
94
119
  const result = visitor.run()
95
120
  return result
@@ -110,6 +135,8 @@ export class MdastToJsx {
110
135
  modules?: EagerModules
111
136
  baseUrl?: string
112
137
  onError?: (error: SafeMdxError) => void
138
+ scope?: Record<string, any>
139
+ evaluateOptions?: EvaluateOptions
113
140
 
114
141
  constructor({
115
142
  markdown: code = '',
@@ -123,6 +150,8 @@ export class MdastToJsx {
123
150
  modules,
124
151
  baseUrl,
125
152
  onError,
153
+ scope,
154
+ evaluateOptions,
126
155
  }: {
127
156
  markdown?: string
128
157
  mdast: MyRootContent
@@ -140,6 +169,15 @@ export class MdastToJsx {
140
169
  /** Called for each error during rendering (missing components, invalid props, failed expressions).
141
170
  * Throw inside this callback to stop rendering on first error. */
142
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
143
181
  }) {
144
182
  this.str = code
145
183
 
@@ -158,6 +196,8 @@ export class MdastToJsx {
158
196
  this.modules = modules
159
197
  this.baseUrl = baseUrl
160
198
  this.onError = onError
199
+ this.scope = scope
200
+ this.evaluateOptions = evaluateOptions
161
201
 
162
202
  this.c = {
163
203
  ...Object.fromEntries(
@@ -488,6 +528,28 @@ export class MdastToJsx {
488
528
  return null
489
529
  }
490
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
+
538
+ // When functions are enabled and the user hasn't provided their own
539
+ // `generate` (escodegen), inject our safe AST-interpreting visitors
540
+ // that handle ArrowFunctionExpression and FunctionExpression without
541
+ // using `new Function()` or `eval()`. This makes arrow function
542
+ // callbacks like `.map(x => x.name)` work in Cloudflare Workers.
543
+ if (options && options.functions && !options.generate) {
544
+ ;(options as any).visitors = {
545
+ ...(options as any).visitors,
546
+ ...createSafeFunctionVisitors(),
547
+ }
548
+ }
549
+
550
+ return Evaluate.evaluate.sync(expression, context, options)
551
+ }
552
+
491
553
  getJsxAttrs(
492
554
  node: MdxJsxFlowElement | MdxJsxTextElement,
493
555
  onError: (err: SafeMdxError) => void = console.error,
@@ -500,14 +562,15 @@ export class MdastToJsx {
500
562
  if (attr.data?.estree) {
501
563
  try {
502
564
  const program = attr.data.estree
565
+ const firstBody = program.body?.[0]
503
566
  if (
504
- program.body?.length > 0 &&
505
- program.body[0].type === 'ExpressionStatement'
567
+ firstBody &&
568
+ firstBody.type === 'ExpressionStatement'
506
569
  ) {
507
- const expression = program.body[0].expression
570
+ const expression = firstBody.expression
508
571
  try {
509
572
  const result =
510
- Evaluate.evaluate.sync(expression)
573
+ this.evaluateExpression(expression)
511
574
 
512
575
  // Handle spread syntax - merge the evaluated object
513
576
  if (
@@ -597,11 +660,12 @@ export class MdastToJsx {
597
660
  try {
598
661
  // Extract the expression from the Program body
599
662
  const program = v.data.estree
663
+ const firstBody = program.body?.[0]
600
664
  if (
601
- program.body?.length > 0 &&
602
- program.body[0].type === 'ExpressionStatement'
665
+ firstBody &&
666
+ firstBody.type === 'ExpressionStatement'
603
667
  ) {
604
- const expression = program.body[0].expression
668
+ const expression = firstBody.expression
605
669
 
606
670
  // Check if this is a JSX element
607
671
  if (expression.type === 'JSXElement') {
@@ -620,7 +684,7 @@ export class MdastToJsx {
620
684
  try {
621
685
  // Evaluate the expression synchronously
622
686
  const result =
623
- Evaluate.evaluate.sync(expression)
687
+ this.evaluateExpression(expression)
624
688
  attrsList.push([attr.name, result])
625
689
  continue
626
690
  } catch (error) {
@@ -653,7 +717,7 @@ export class MdastToJsx {
653
717
  }
654
718
 
655
719
  run() {
656
- const res = this.mdastTransformer(this.mdast, 'root') as ReactNode
720
+ const res = this.mdastTransformer(this.mdast, 'root')
657
721
  if (Array.isArray(res) && res.length === 1) {
658
722
  return res[0]
659
723
  }
@@ -669,7 +733,7 @@ export class MdastToJsx {
669
733
  if (this.renderNode) {
670
734
  const customResult = this.renderNode(
671
735
  node,
672
- (n: MyRootContent) => this.mdastTransformer(n, node.type),
736
+ (n) => this.mdastTransformer(n, node.type),
673
737
  )
674
738
  if (customResult !== undefined) {
675
739
  return customResult
@@ -723,15 +787,16 @@ export class MdastToJsx {
723
787
  try {
724
788
  // Extract the expression from the Program body
725
789
  const program = node.data.estree
790
+ const firstBody = program.body?.[0]
726
791
  if (
727
- program.body?.length > 0 &&
728
- program.body[0].type === 'ExpressionStatement'
792
+ firstBody &&
793
+ firstBody.type === 'ExpressionStatement'
729
794
  ) {
730
- const expression = program.body[0].expression
795
+ const expression = firstBody.expression
731
796
  try {
732
797
  // Evaluate the expression synchronously
733
798
  const result =
734
- Evaluate.evaluate.sync(expression)
799
+ this.evaluateExpression(expression)
735
800
  return result
736
801
  } catch (error) {
737
802
  this.pushError({
@@ -1060,9 +1125,6 @@ export class MdastToJsx {
1060
1125
  }
1061
1126
  }
1062
1127
 
1063
- function isTruthy<T>(val: T | undefined | null | false): val is T {
1064
- return Boolean(val)
1065
- }
1066
1128
 
1067
1129
  function accessWithDot(obj, path: string) {
1068
1130
  return path
@@ -1093,14 +1155,239 @@ export function mdastBfs(
1093
1155
  return result
1094
1156
  }
1095
1157
 
1096
- function safeJsonParse(str: string) {
1097
- try {
1098
- return JSON.parse(str)
1099
- } catch (err) {
1100
- return null
1158
+ type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & {
1159
+ [key: string]: any
1160
+ }
1161
+
1162
+ /**
1163
+ * Bind function parameters to argument values, handling Identifier,
1164
+ * ObjectPattern, ArrayPattern, RestElement, and AssignmentPattern nodes.
1165
+ * Writes bindings into `ctx` in place.
1166
+ */
1167
+ function bindParams(
1168
+ params: any[],
1169
+ args: any[],
1170
+ ctx: Record<string, any>,
1171
+ visit: (node: any, context: any, parent?: any) => any,
1172
+ ) {
1173
+ for (let i = 0; i < params.length; i++) {
1174
+ const param = params[i]
1175
+ switch (param.type) {
1176
+ case 'Identifier':
1177
+ ctx[param.name] = args[i]
1178
+ break
1179
+ case 'RestElement':
1180
+ if (param.argument.type === 'Identifier') {
1181
+ ctx[param.argument.name] = args.slice(i)
1182
+ }
1183
+ break
1184
+ case 'AssignmentPattern': {
1185
+ const val =
1186
+ args[i] !== undefined
1187
+ ? args[i]
1188
+ : visit(param.right, ctx, param)
1189
+ if (param.left.type === 'Identifier') {
1190
+ ctx[param.left.name] = val
1191
+ }
1192
+ break
1193
+ }
1194
+ case 'ObjectPattern': {
1195
+ const obj = args[i] || {}
1196
+ for (const prop of param.properties) {
1197
+ if (prop.type === 'RestElement') {
1198
+ const used = new Set(
1199
+ param.properties
1200
+ .filter((p: any) => p !== prop)
1201
+ .map(
1202
+ (p: any) =>
1203
+ p.key?.name ?? p.key?.value,
1204
+ ),
1205
+ )
1206
+ const rest: Record<string, any> = {}
1207
+ for (const key of Object.keys(obj)) {
1208
+ if (!used.has(key)) rest[key] = obj[key]
1209
+ }
1210
+ if (prop.argument.type === 'Identifier') {
1211
+ ctx[prop.argument.name] = rest
1212
+ }
1213
+ } else {
1214
+ const key =
1215
+ prop.key.type === 'Identifier'
1216
+ ? prop.key.name
1217
+ : prop.key.value
1218
+ if (prop.value.type === 'Identifier') {
1219
+ ctx[prop.value.name] = obj[key]
1220
+ } else if (
1221
+ prop.value.type === 'AssignmentPattern'
1222
+ ) {
1223
+ const val =
1224
+ obj[key] !== undefined
1225
+ ? obj[key]
1226
+ : visit(
1227
+ prop.value.right,
1228
+ ctx,
1229
+ prop.value,
1230
+ )
1231
+ if (
1232
+ prop.value.left.type === 'Identifier'
1233
+ ) {
1234
+ ctx[prop.value.left.name] = val
1235
+ }
1236
+ }
1237
+ }
1238
+ }
1239
+ break
1240
+ }
1241
+ case 'ArrayPattern': {
1242
+ const arr = args[i] || []
1243
+ for (let j = 0; j < param.elements.length; j++) {
1244
+ const elem = param.elements[j]
1245
+ if (!elem) continue
1246
+ if (elem.type === 'Identifier') {
1247
+ ctx[elem.name] = arr[j]
1248
+ } else if (
1249
+ elem.type === 'RestElement' &&
1250
+ elem.argument.type === 'Identifier'
1251
+ ) {
1252
+ ctx[elem.argument.name] = arr.slice(j)
1253
+ }
1254
+ }
1255
+ break
1256
+ }
1257
+ }
1101
1258
  }
1102
1259
  }
1103
1260
 
1104
- type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & {
1105
- [key: string]: any
1261
+ // Sentinel value to signal a return from inside a block body
1262
+ const RETURN_SENTINEL = Symbol('return')
1263
+
1264
+ /**
1265
+ * Execute a block statement body (array of statements) using the
1266
+ * eval-estree-expression visitor's `this.visit`. Returns the value
1267
+ * from the first ReturnStatement encountered, or undefined.
1268
+ */
1269
+ function executeBlockBody(
1270
+ body: any[],
1271
+ ctx: Record<string, any>,
1272
+ visit: (node: any, context: any, parent?: any) => any,
1273
+ parentNode: any,
1274
+ ): any {
1275
+ for (const stmt of body) {
1276
+ switch (stmt.type) {
1277
+ case 'ReturnStatement':
1278
+ return stmt.argument
1279
+ ? visit(stmt.argument, ctx, stmt)
1280
+ : undefined
1281
+ case 'ExpressionStatement':
1282
+ visit(stmt.expression, ctx, stmt)
1283
+ break
1284
+ case 'VariableDeclaration':
1285
+ for (const decl of stmt.declarations) {
1286
+ const value = decl.init
1287
+ ? visit(decl.init, ctx, decl)
1288
+ : undefined
1289
+ if (decl.id.type === 'Identifier') {
1290
+ ctx[decl.id.name] = value
1291
+ }
1292
+ }
1293
+ break
1294
+ case 'IfStatement': {
1295
+ const test = visit(stmt.test, ctx, stmt)
1296
+ if (test) {
1297
+ if (stmt.consequent.type === 'BlockStatement') {
1298
+ const result = executeBlockBody(
1299
+ stmt.consequent.body,
1300
+ ctx,
1301
+ visit,
1302
+ stmt,
1303
+ )
1304
+ if (result !== undefined) return result
1305
+ } else if (
1306
+ stmt.consequent.type === 'ReturnStatement'
1307
+ ) {
1308
+ return stmt.consequent.argument
1309
+ ? visit(
1310
+ stmt.consequent.argument,
1311
+ ctx,
1312
+ stmt.consequent,
1313
+ )
1314
+ : undefined
1315
+ } else {
1316
+ visit(stmt.consequent, ctx, stmt)
1317
+ }
1318
+ } else if (stmt.alternate) {
1319
+ if (stmt.alternate.type === 'BlockStatement') {
1320
+ const result = executeBlockBody(
1321
+ stmt.alternate.body,
1322
+ ctx,
1323
+ visit,
1324
+ stmt,
1325
+ )
1326
+ if (result !== undefined) return result
1327
+ } else if (
1328
+ stmt.alternate.type === 'ReturnStatement'
1329
+ ) {
1330
+ return stmt.alternate.argument
1331
+ ? visit(
1332
+ stmt.alternate.argument,
1333
+ ctx,
1334
+ stmt.alternate,
1335
+ )
1336
+ : undefined
1337
+ } else {
1338
+ visit(stmt.alternate, ctx, stmt)
1339
+ }
1340
+ }
1341
+ break
1342
+ }
1343
+ }
1344
+ }
1345
+ return undefined
1346
+ }
1347
+
1348
+ /**
1349
+ * Custom visitors for eval-estree-expression that interpret arrow functions
1350
+ * and function expressions by walking the AST recursively, without using
1351
+ * `new Function()` or `eval()`. This makes them safe for Cloudflare Workers
1352
+ * and other edge runtimes that block dynamic code evaluation.
1353
+ *
1354
+ * The visitors are called with `this` bound to the Expression evaluator
1355
+ * instance, giving access to `this.visit()` for recursive evaluation.
1356
+ */
1357
+ export function createSafeFunctionVisitors() {
1358
+ // Using a regular function (not arrow) so `this` is the Expression instance
1359
+ function functionExpressionVisitor(
1360
+ this: any,
1361
+ node: any,
1362
+ context: any,
1363
+ ) {
1364
+ const self = this
1365
+ return function (this: any, ...args: any[]) {
1366
+ const newContext = { ...context }
1367
+ bindParams(node.params, args, newContext, (n, ctx, p) =>
1368
+ self.visit(n, ctx, p),
1369
+ )
1370
+
1371
+ if (
1372
+ node.expression ||
1373
+ node.body.type !== 'BlockStatement'
1374
+ ) {
1375
+ // Expression body: x => x.name
1376
+ return self.visit(node.body, newContext, node)
1377
+ }
1378
+
1379
+ // Block body: x => { ... return ... }
1380
+ return executeBlockBody(
1381
+ node.body.body,
1382
+ newContext,
1383
+ (n, ctx, p) => self.visit(n, ctx, p),
1384
+ node,
1385
+ )
1386
+ }
1387
+ }
1388
+
1389
+ return {
1390
+ ArrowFunctionExpression: functionExpressionVisitor,
1391
+ FunctionExpression: functionExpressionVisitor,
1392
+ }
1106
1393
  }
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
  }