safe-mdx 1.6.0 → 1.8.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 +78 -12
  2. package/dist/html/html-and-md.test.js +14 -41
  3. package/dist/html/html-and-md.test.js.map +1 -1
  4. package/dist/html/html-to-mdx-ast.d.ts +26 -1
  5. package/dist/html/html-to-mdx-ast.d.ts.map +1 -1
  6. package/dist/html/html-to-mdx-ast.js +40 -0
  7. package/dist/html/html-to-mdx-ast.js.map +1 -1
  8. package/dist/incremental-parse.d.ts +41 -0
  9. package/dist/incremental-parse.d.ts.map +1 -0
  10. package/dist/incremental-parse.js +139 -0
  11. package/dist/incremental-parse.js.map +1 -0
  12. package/dist/incremental-parse.test.d.ts +2 -0
  13. package/dist/incremental-parse.test.d.ts.map +1 -0
  14. package/dist/incremental-parse.test.js +299 -0
  15. package/dist/incremental-parse.test.js.map +1 -0
  16. package/dist/markdown-html.test.d.ts +2 -0
  17. package/dist/markdown-html.test.d.ts.map +1 -0
  18. package/dist/markdown-html.test.js +129 -0
  19. package/dist/markdown-html.test.js.map +1 -0
  20. package/dist/markdown.d.ts +3 -0
  21. package/dist/markdown.d.ts.map +1 -0
  22. package/dist/markdown.js +4 -0
  23. package/dist/markdown.js.map +1 -0
  24. package/dist/parse.d.ts +9 -2
  25. package/dist/parse.d.ts.map +1 -1
  26. package/dist/parse.js +24 -12
  27. package/dist/parse.js.map +1 -1
  28. package/dist/safe-mdx.d.ts +13 -0
  29. package/dist/safe-mdx.d.ts.map +1 -1
  30. package/dist/safe-mdx.js +193 -24
  31. package/dist/safe-mdx.js.map +1 -1
  32. package/dist/safe-mdx.test.js +284 -11
  33. package/dist/safe-mdx.test.js.map +1 -1
  34. package/package.json +9 -1
  35. package/src/html/html-and-md.test.ts +15 -47
  36. package/src/html/html-to-mdx-ast.ts +53 -1
  37. package/src/incremental-parse.test.ts +315 -0
  38. package/src/incremental-parse.ts +219 -0
  39. package/src/markdown-html.test.tsx +144 -0
  40. package/src/markdown.ts +4 -0
  41. package/src/parse.ts +36 -13
  42. package/src/safe-mdx.test.tsx +357 -11
  43. package/src/safe-mdx.tsx +252 -26
package/src/safe-mdx.tsx CHANGED
@@ -10,8 +10,7 @@ import { Fragment, ReactNode } from 'react'
10
10
  import { DynamicEsmComponent } from 'safe-mdx/client'
11
11
  import { extractComponentInfo, parseEsmImports } from './esm-parser.ts'
12
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'
13
+ import { nativeTags } from './html/valid-html-elements.ts'
15
14
 
16
15
  export type MyRootContent = RootContent | Root
17
16
 
@@ -534,6 +533,19 @@ export class MdastToJsx {
534
533
  const options = hasScope || this.evaluateOptions
535
534
  ? { ...(hasScope ? { functions: true } : {}), ...this.evaluateOptions }
536
535
  : undefined
536
+
537
+ // When functions are enabled and the user hasn't provided their own
538
+ // `generate` (escodegen), inject our safe AST-interpreting visitors
539
+ // that handle ArrowFunctionExpression and FunctionExpression without
540
+ // using `new Function()` or `eval()`. This makes arrow function
541
+ // callbacks like `.map(x => x.name)` work in Cloudflare Workers.
542
+ if (options && options.functions && !options.generate) {
543
+ ;(options as any).visitors = {
544
+ ...(options as any).visitors,
545
+ ...createSafeFunctionVisitors(),
546
+ }
547
+ }
548
+
537
549
  return Evaluate.evaluate.sync(expression, context, options)
538
550
  }
539
551
 
@@ -1068,30 +1080,11 @@ export class MdastToJsx {
1068
1080
  return []
1069
1081
  }
1070
1082
  case 'html': {
1071
- const start = node.position?.start?.offset
1072
- const end = node.position?.end?.offset
1073
- const text = this.str.slice(start, end)
1074
- if (!text) {
1075
- return []
1076
- }
1077
-
1078
- // Parse HTML to MDX AST using the new approach - always returns an array
1079
- const mdxAst = htmlToMdxAst({
1080
- html: text,
1081
- parentType: parentType || 'root',
1082
- convertTagName: ({ tagName }) => {
1083
- const lowerTag = tagName.toLowerCase()
1084
- // Only keep valid HTML elements
1085
- if (validHtmlElements.has(lowerTag)) {
1086
- return lowerTag
1087
- }
1088
- // Return empty string for non-HTML elements
1089
- return ''
1090
- }
1091
- })
1092
-
1093
- // Process the MDX AST nodes
1094
- return mdxAst.map(child => this.mdastTransformer(child, 'html'))
1083
+ // html nodes appear when rendering plain markdown (not MDX) without
1084
+ // the remarkHtmlToMdx pre-processing plugin. They are intentionally
1085
+ // ignored here use remarkHtmlToMdx from 'safe-mdx/markdown' to convert
1086
+ // them to mdxJsx nodes before passing the AST to MdastToJsx.
1087
+ return []
1095
1088
  }
1096
1089
  case 'imageReference': {
1097
1090
  return []
@@ -1145,3 +1138,236 @@ export function mdastBfs(
1145
1138
  type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & {
1146
1139
  [key: string]: any
1147
1140
  }
1141
+
1142
+ /**
1143
+ * Bind function parameters to argument values, handling Identifier,
1144
+ * ObjectPattern, ArrayPattern, RestElement, and AssignmentPattern nodes.
1145
+ * Writes bindings into `ctx` in place.
1146
+ */
1147
+ function bindParams(
1148
+ params: any[],
1149
+ args: any[],
1150
+ ctx: Record<string, any>,
1151
+ visit: (node: any, context: any, parent?: any) => any,
1152
+ ) {
1153
+ for (let i = 0; i < params.length; i++) {
1154
+ const param = params[i]
1155
+ switch (param.type) {
1156
+ case 'Identifier':
1157
+ ctx[param.name] = args[i]
1158
+ break
1159
+ case 'RestElement':
1160
+ if (param.argument.type === 'Identifier') {
1161
+ ctx[param.argument.name] = args.slice(i)
1162
+ }
1163
+ break
1164
+ case 'AssignmentPattern': {
1165
+ const val =
1166
+ args[i] !== undefined
1167
+ ? args[i]
1168
+ : visit(param.right, ctx, param)
1169
+ if (param.left.type === 'Identifier') {
1170
+ ctx[param.left.name] = val
1171
+ }
1172
+ break
1173
+ }
1174
+ case 'ObjectPattern': {
1175
+ const obj = args[i] || {}
1176
+ for (const prop of param.properties) {
1177
+ if (prop.type === 'RestElement') {
1178
+ const used = new Set(
1179
+ param.properties
1180
+ .filter((p: any) => p !== prop)
1181
+ .map(
1182
+ (p: any) =>
1183
+ p.key?.name ?? p.key?.value,
1184
+ ),
1185
+ )
1186
+ const rest: Record<string, any> = {}
1187
+ for (const key of Object.keys(obj)) {
1188
+ if (!used.has(key)) rest[key] = obj[key]
1189
+ }
1190
+ if (prop.argument.type === 'Identifier') {
1191
+ ctx[prop.argument.name] = rest
1192
+ }
1193
+ } else {
1194
+ const key =
1195
+ prop.key.type === 'Identifier'
1196
+ ? prop.key.name
1197
+ : prop.key.value
1198
+ if (prop.value.type === 'Identifier') {
1199
+ ctx[prop.value.name] = obj[key]
1200
+ } else if (
1201
+ prop.value.type === 'AssignmentPattern'
1202
+ ) {
1203
+ const val =
1204
+ obj[key] !== undefined
1205
+ ? obj[key]
1206
+ : visit(
1207
+ prop.value.right,
1208
+ ctx,
1209
+ prop.value,
1210
+ )
1211
+ if (
1212
+ prop.value.left.type === 'Identifier'
1213
+ ) {
1214
+ ctx[prop.value.left.name] = val
1215
+ }
1216
+ }
1217
+ }
1218
+ }
1219
+ break
1220
+ }
1221
+ case 'ArrayPattern': {
1222
+ const arr = args[i] || []
1223
+ for (let j = 0; j < param.elements.length; j++) {
1224
+ const elem = param.elements[j]
1225
+ if (!elem) continue
1226
+ if (elem.type === 'Identifier') {
1227
+ ctx[elem.name] = arr[j]
1228
+ } else if (
1229
+ elem.type === 'RestElement' &&
1230
+ elem.argument.type === 'Identifier'
1231
+ ) {
1232
+ ctx[elem.argument.name] = arr.slice(j)
1233
+ }
1234
+ }
1235
+ break
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ // Sentinel value to signal a return from inside a block body
1242
+ const RETURN_SENTINEL = Symbol('return')
1243
+
1244
+ /**
1245
+ * Execute a block statement body (array of statements) using the
1246
+ * eval-estree-expression visitor's `this.visit`. Returns the value
1247
+ * from the first ReturnStatement encountered, or undefined.
1248
+ */
1249
+ function executeBlockBody(
1250
+ body: any[],
1251
+ ctx: Record<string, any>,
1252
+ visit: (node: any, context: any, parent?: any) => any,
1253
+ parentNode: any,
1254
+ ): any {
1255
+ for (const stmt of body) {
1256
+ switch (stmt.type) {
1257
+ case 'ReturnStatement':
1258
+ return stmt.argument
1259
+ ? visit(stmt.argument, ctx, stmt)
1260
+ : undefined
1261
+ case 'ExpressionStatement':
1262
+ visit(stmt.expression, ctx, stmt)
1263
+ break
1264
+ case 'VariableDeclaration':
1265
+ for (const decl of stmt.declarations) {
1266
+ const value = decl.init
1267
+ ? visit(decl.init, ctx, decl)
1268
+ : undefined
1269
+ if (decl.id.type === 'Identifier') {
1270
+ ctx[decl.id.name] = value
1271
+ }
1272
+ }
1273
+ break
1274
+ case 'IfStatement': {
1275
+ const test = visit(stmt.test, ctx, stmt)
1276
+ if (test) {
1277
+ if (stmt.consequent.type === 'BlockStatement') {
1278
+ const result = executeBlockBody(
1279
+ stmt.consequent.body,
1280
+ ctx,
1281
+ visit,
1282
+ stmt,
1283
+ )
1284
+ if (result !== undefined) return result
1285
+ } else if (
1286
+ stmt.consequent.type === 'ReturnStatement'
1287
+ ) {
1288
+ return stmt.consequent.argument
1289
+ ? visit(
1290
+ stmt.consequent.argument,
1291
+ ctx,
1292
+ stmt.consequent,
1293
+ )
1294
+ : undefined
1295
+ } else {
1296
+ visit(stmt.consequent, ctx, stmt)
1297
+ }
1298
+ } else if (stmt.alternate) {
1299
+ if (stmt.alternate.type === 'BlockStatement') {
1300
+ const result = executeBlockBody(
1301
+ stmt.alternate.body,
1302
+ ctx,
1303
+ visit,
1304
+ stmt,
1305
+ )
1306
+ if (result !== undefined) return result
1307
+ } else if (
1308
+ stmt.alternate.type === 'ReturnStatement'
1309
+ ) {
1310
+ return stmt.alternate.argument
1311
+ ? visit(
1312
+ stmt.alternate.argument,
1313
+ ctx,
1314
+ stmt.alternate,
1315
+ )
1316
+ : undefined
1317
+ } else {
1318
+ visit(stmt.alternate, ctx, stmt)
1319
+ }
1320
+ }
1321
+ break
1322
+ }
1323
+ }
1324
+ }
1325
+ return undefined
1326
+ }
1327
+
1328
+ /**
1329
+ * Custom visitors for eval-estree-expression that interpret arrow functions
1330
+ * and function expressions by walking the AST recursively, without using
1331
+ * `new Function()` or `eval()`. This makes them safe for Cloudflare Workers
1332
+ * and other edge runtimes that block dynamic code evaluation.
1333
+ *
1334
+ * The visitors are called with `this` bound to the Expression evaluator
1335
+ * instance, giving access to `this.visit()` for recursive evaluation.
1336
+ */
1337
+ export function createSafeFunctionVisitors() {
1338
+ // Using a regular function (not arrow) so `this` is the Expression instance
1339
+ function functionExpressionVisitor(
1340
+ this: any,
1341
+ node: any,
1342
+ context: any,
1343
+ ) {
1344
+ const self = this
1345
+ return function (this: any, ...args: any[]) {
1346
+ const newContext = { ...context }
1347
+ bindParams(node.params, args, newContext, (n, ctx, p) =>
1348
+ self.visit(n, ctx, p),
1349
+ )
1350
+
1351
+ if (
1352
+ node.expression ||
1353
+ node.body.type !== 'BlockStatement'
1354
+ ) {
1355
+ // Expression body: x => x.name
1356
+ return self.visit(node.body, newContext, node)
1357
+ }
1358
+
1359
+ // Block body: x => { ... return ... }
1360
+ return executeBlockBody(
1361
+ node.body.body,
1362
+ newContext,
1363
+ (n, ctx, p) => self.visit(n, ctx, p),
1364
+ node,
1365
+ )
1366
+ }
1367
+ }
1368
+
1369
+ return {
1370
+ ArrowFunctionExpression: functionExpressionVisitor,
1371
+ FunctionExpression: functionExpressionVisitor,
1372
+ }
1373
+ }