safe-mdx 1.9.0 → 1.11.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
@@ -135,6 +135,10 @@ export class MdastToJsx {
135
135
  baseUrl?: string
136
136
  onError?: (error: SafeMdxError) => void
137
137
  scope?: Record<string, any>
138
+ /** Whether the caller passed a non-empty scope. Used to decide if
139
+ * function calls should be auto-enabled — module imports adding to
140
+ * scope should NOT flip this flag. */
141
+ private userProvidedScope: boolean = false
138
142
  evaluateOptions?: EvaluateOptions
139
143
 
140
144
  constructor({
@@ -195,7 +199,8 @@ export class MdastToJsx {
195
199
  this.modules = modules
196
200
  this.baseUrl = baseUrl
197
201
  this.onError = onError
198
- this.scope = scope
202
+ this.scope = scope ? { ...scope } : undefined
203
+ this.userProvidedScope = !!scope && Object.keys(scope).length > 0
199
204
  this.evaluateOptions = evaluateOptions
200
205
 
201
206
  this.c = {
@@ -219,9 +224,11 @@ export class MdastToJsx {
219
224
  * Resolved components are added directly to `this.c` (the component map)
220
225
  * so the existing `accessWithDot` lookup finds them.
221
226
  */
222
- resolveImportsFromModules(node: MyRootContent): void {
227
+ /** Resolve imports from pre-loaded modules. Returns the set of local names that were resolved. */
228
+ resolveImportsFromModules(node: MyRootContent): Set<string> {
229
+ const resolved = new Set<string>()
223
230
  const estree = (node as any).data?.estree
224
- if (!estree) return
231
+ if (!estree) return resolved
225
232
 
226
233
  const moduleKeys = Object.keys(this.modules!)
227
234
 
@@ -230,26 +237,37 @@ export class MdastToJsx {
230
237
  const source: string = statement.source?.value
231
238
  if (typeof source !== 'string') continue
232
239
 
233
- const resolved = resolveModulePath(source, this.baseUrl || './', moduleKeys)
234
- if (!resolved) continue
235
- const mod = this.modules![resolved]
236
- if (!mod) continue
240
+ const resolvedPath = resolveModulePath(source, this.baseUrl || './', moduleKeys)
241
+ if (!resolvedPath) continue
242
+ const mod = this.modules![resolvedPath]
243
+ if (mod == null) continue
237
244
 
238
245
  for (const spec of statement.specifiers ?? []) {
246
+ let value: any
239
247
  if (spec.type === 'ImportDefaultSpecifier') {
240
- this.c[spec.local.name] = mod.default ?? mod
248
+ value = mod.default ?? mod
241
249
  } else if (spec.type === 'ImportSpecifier') {
242
250
  const importedName = spec.imported.type === 'Identifier'
243
251
  ? spec.imported.name
244
252
  : String(spec.imported.value)
245
- this.c[spec.local.name] = mod[importedName]
253
+ value = mod[importedName]
246
254
  } else if (spec.type === 'ImportNamespaceSpecifier') {
247
255
  // Namespace import: import * as UI from '...'
248
256
  // Supports <UI.Card> via accessWithDot
249
- this.c[spec.local.name] = mod
257
+ value = mod
258
+ } else {
259
+ continue
250
260
  }
261
+
262
+ this.c[spec.local.name] = value
263
+ resolved.add(spec.local.name)
264
+ // Also add to scope so values are available in expressions
265
+ // like {code} or prop={code}
266
+ if (!this.scope) this.scope = {}
267
+ this.scope[spec.local.name] = value
251
268
  }
252
269
  }
270
+ return resolved
253
271
  }
254
272
 
255
273
  addLineNumberToProps(
@@ -530,8 +548,8 @@ export class MdastToJsx {
530
548
  evaluateExpression(expression: any) {
531
549
  const hasScope = this.scope && Object.keys(this.scope).length > 0
532
550
  const context = hasScope ? this.scope : undefined
533
- const options = hasScope || this.evaluateOptions
534
- ? { ...(hasScope ? { functions: true } : {}), ...this.evaluateOptions }
551
+ const options = this.userProvidedScope || this.evaluateOptions
552
+ ? { ...(this.userProvidedScope ? { functions: true } : {}), ...this.evaluateOptions }
535
553
  : undefined
536
554
 
537
555
  // When functions are enabled and the user hasn't provided their own
@@ -546,6 +564,16 @@ export class MdastToJsx {
546
564
  }
547
565
  }
548
566
 
567
+ // When scope is provided, check that referenced identifiers exist
568
+ // before evaluation. eval-estree-expression silently returns undefined
569
+ // for missing identifiers, so we catch them here to produce clear errors.
570
+ if (context) {
571
+ const missing = findMissingIdentifiers(expression, context)
572
+ if (missing.length > 0) {
573
+ throw new Error(`${missing[0]} is not defined. Available variables: ${Object.keys(context).join(', ')}`)
574
+ }
575
+ }
576
+
549
577
  return Evaluate.evaluate.sync(expression, context, options)
550
578
  }
551
579
 
@@ -741,10 +769,65 @@ export class MdastToJsx {
741
769
 
742
770
  switch (node.type) {
743
771
  case 'mdxjsEsm': {
772
+ const estree = (node as any).data?.estree
773
+ const nodeLine = node.position?.start?.line
774
+
775
+ // Warn about export declarations (not supported in safe-mdx)
776
+ if (estree) {
777
+ for (const stmt of estree.body) {
778
+ if (stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration') {
779
+ const stmtLine = stmt.loc?.start?.line ?? nodeLine
780
+ const exportKind = stmt.type === 'ExportDefaultDeclaration' ? 'default' : 'named'
781
+ let detail = ''
782
+ if (stmt.declaration) {
783
+ const decl = stmt.declaration
784
+ if (decl.type === 'FunctionDeclaration' && decl.id?.name) {
785
+ detail = ` "${decl.id.name}"`
786
+ } else if (decl.type === 'VariableDeclaration') {
787
+ const names = decl.declarations
788
+ .map((d: any) => d.id?.name)
789
+ .filter(Boolean)
790
+ .join(', ')
791
+ if (names) detail = ` "${names}"`
792
+ }
793
+ }
794
+ this.pushError({
795
+ type: 'expression',
796
+ message: `Unsupported ${exportKind} export${detail}. Export declarations are not evaluated, so exported values and components are not available in the document.`,
797
+ line: stmtLine,
798
+ })
799
+ }
800
+ }
801
+ }
802
+
744
803
  // Resolve imports from pre-loaded modules (server-side)
804
+ let resolvedImportLocals = new Set<string>()
745
805
  if (this.modules) {
746
- this.resolveImportsFromModules(node)
806
+ resolvedImportLocals = this.resolveImportsFromModules(node)
747
807
  }
808
+
809
+ // Warn about import declarations that cannot be resolved
810
+ if (estree && !this.allowClientEsmImports) {
811
+ for (const stmt of estree.body) {
812
+ if (stmt.type !== 'ImportDeclaration') continue
813
+ const stmtLine = stmt.loc?.start?.line ?? nodeLine
814
+ const source: string = stmt.source?.value
815
+ if (typeof source !== 'string') continue
816
+
817
+ // Check against actually resolved imports, not this.c (which includes pre-existing components)
818
+ const specNames = (stmt.specifiers ?? []).map((s: any) => s.local?.name).filter(Boolean)
819
+ const unresolvedNames = specNames.filter((name: string) => !resolvedImportLocals.has(name))
820
+
821
+ if (unresolvedNames.length > 0) {
822
+ this.pushError({
823
+ type: 'expression',
824
+ message: `Unresolved import "${unresolvedNames.join(', ')}" from "${source}". The imported module could not be resolved, so these names are not available in the document.`,
825
+ line: stmtLine,
826
+ })
827
+ }
828
+ }
829
+ }
830
+
748
831
  // Parse ESM imports for client-side dynamic loading (only if allowed)
749
832
  if (this.allowClientEsmImports) {
750
833
  const parsedImports = parseEsmImports(node, (err) =>
@@ -1106,6 +1189,144 @@ export class MdastToJsx {
1106
1189
  }
1107
1190
 
1108
1191
 
1192
+ /** JS globals that eval-estree-expression treats as built-in values */
1193
+ const ALLOWED_GLOBALS = new Set(['undefined', 'NaN', 'Infinity', 'true', 'false', 'null'])
1194
+
1195
+ /**
1196
+ * Walk an estree expression AST and collect top-level Identifier names
1197
+ * that are not defined in the given scope context. Skips property access
1198
+ * identifiers (e.g. `foo.bar` only checks `foo`, not `bar`), function
1199
+ * parameter bindings, and JS built-in globals.
1200
+ *
1201
+ * For short-circuit operators (||, ??, &&) and ternary expressions,
1202
+ * only checks the left/test operand since the right side may never
1203
+ * be evaluated at runtime.
1204
+ */
1205
+ function findMissingIdentifiers(
1206
+ node: any,
1207
+ context: Record<string, any>,
1208
+ localBindings: Set<string> = new Set(),
1209
+ ): string[] {
1210
+ if (!node) return []
1211
+ const missing: string[] = []
1212
+
1213
+ switch (node.type) {
1214
+ case 'Identifier':
1215
+ if (!ALLOWED_GLOBALS.has(node.name) && !(node.name in context) && !localBindings.has(node.name)) {
1216
+ missing.push(node.name)
1217
+ }
1218
+ break
1219
+ case 'MemberExpression':
1220
+ // Only check the object, not the property
1221
+ missing.push(...findMissingIdentifiers(node.object, context, localBindings))
1222
+ // Check computed properties like obj[expr]
1223
+ if (node.computed) {
1224
+ missing.push(...findMissingIdentifiers(node.property, context, localBindings))
1225
+ }
1226
+ break
1227
+ case 'CallExpression':
1228
+ missing.push(...findMissingIdentifiers(node.callee, context, localBindings))
1229
+ for (const arg of node.arguments || []) {
1230
+ missing.push(...findMissingIdentifiers(arg, context, localBindings))
1231
+ }
1232
+ break
1233
+ case 'BinaryExpression':
1234
+ missing.push(...findMissingIdentifiers(node.left, context, localBindings))
1235
+ missing.push(...findMissingIdentifiers(node.right, context, localBindings))
1236
+ break
1237
+ case 'LogicalExpression':
1238
+ // For ||, ??, && only check the left side statically.
1239
+ // The right side may never be evaluated at runtime.
1240
+ missing.push(...findMissingIdentifiers(node.left, context, localBindings))
1241
+ break
1242
+ case 'UnaryExpression':
1243
+ missing.push(...findMissingIdentifiers(node.argument, context, localBindings))
1244
+ break
1245
+ case 'ConditionalExpression':
1246
+ // Only check the test statically. The branches may never run.
1247
+ missing.push(...findMissingIdentifiers(node.test, context, localBindings))
1248
+ break
1249
+ case 'TemplateLiteral':
1250
+ for (const expr of node.expressions || []) {
1251
+ missing.push(...findMissingIdentifiers(expr, context, localBindings))
1252
+ }
1253
+ break
1254
+ case 'ArrowFunctionExpression':
1255
+ case 'FunctionExpression': {
1256
+ // Collect parameter names as local bindings
1257
+ const newBindings = new Set(localBindings)
1258
+ for (const param of node.params || []) {
1259
+ collectParamNames(param, newBindings)
1260
+ }
1261
+ missing.push(...findMissingIdentifiers(node.body, context, newBindings))
1262
+ break
1263
+ }
1264
+ case 'BlockStatement':
1265
+ for (const stmt of node.body || []) {
1266
+ // Register variable declarations (including destructuring) as local bindings
1267
+ if (stmt.type === 'VariableDeclaration') {
1268
+ for (const decl of stmt.declarations || []) {
1269
+ collectParamNames(decl.id, localBindings)
1270
+ // Check the initializer for missing identifiers
1271
+ if (decl.init) {
1272
+ missing.push(...findMissingIdentifiers(decl.init, context, localBindings))
1273
+ }
1274
+ }
1275
+ // Skip re-walking this statement since we already handled it
1276
+ continue
1277
+ }
1278
+ missing.push(...findMissingIdentifiers(stmt, context, localBindings))
1279
+ }
1280
+ break
1281
+ case 'ExpressionStatement':
1282
+ missing.push(...findMissingIdentifiers(node.expression, context, localBindings))
1283
+ break
1284
+ case 'ReturnStatement':
1285
+ missing.push(...findMissingIdentifiers(node.argument, context, localBindings))
1286
+ break
1287
+ case 'ArrayExpression':
1288
+ for (const elem of node.elements || []) {
1289
+ if (elem) missing.push(...findMissingIdentifiers(elem, context, localBindings))
1290
+ }
1291
+ break
1292
+ case 'ObjectExpression':
1293
+ for (const prop of node.properties || []) {
1294
+ if (prop.value) missing.push(...findMissingIdentifiers(prop.value, context, localBindings))
1295
+ }
1296
+ break
1297
+ case 'SpreadElement':
1298
+ missing.push(...findMissingIdentifiers(node.argument, context, localBindings))
1299
+ break
1300
+ // Literal, JSXElement, etc. - no identifiers to check
1301
+ }
1302
+
1303
+ return missing
1304
+ }
1305
+
1306
+ /** Extract all bound names from a function parameter AST node */
1307
+ function collectParamNames(param: any, names: Set<string>) {
1308
+ if (!param) return
1309
+ if (param.type === 'Identifier') {
1310
+ names.add(param.name)
1311
+ } else if (param.type === 'ObjectPattern') {
1312
+ for (const prop of param.properties || []) {
1313
+ if (prop.type === 'RestElement') {
1314
+ collectParamNames(prop.argument, names)
1315
+ } else {
1316
+ collectParamNames(prop.value, names)
1317
+ }
1318
+ }
1319
+ } else if (param.type === 'ArrayPattern') {
1320
+ for (const elem of param.elements || []) {
1321
+ if (elem) collectParamNames(elem, names)
1322
+ }
1323
+ } else if (param.type === 'RestElement') {
1324
+ collectParamNames(param.argument, names)
1325
+ } else if (param.type === 'AssignmentPattern') {
1326
+ collectParamNames(param.left, names)
1327
+ }
1328
+ }
1329
+
1109
1330
  function accessWithDot(obj, path: string) {
1110
1331
  return path
1111
1332
  .split('.')