safe-mdx 1.10.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
@@ -224,9 +224,11 @@ export class MdastToJsx {
224
224
  * Resolved components are added directly to `this.c` (the component map)
225
225
  * so the existing `accessWithDot` lookup finds them.
226
226
  */
227
- 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>()
228
230
  const estree = (node as any).data?.estree
229
- if (!estree) return
231
+ if (!estree) return resolved
230
232
 
231
233
  const moduleKeys = Object.keys(this.modules!)
232
234
 
@@ -235,9 +237,9 @@ export class MdastToJsx {
235
237
  const source: string = statement.source?.value
236
238
  if (typeof source !== 'string') continue
237
239
 
238
- const resolved = resolveModulePath(source, this.baseUrl || './', moduleKeys)
239
- if (!resolved) continue
240
- const mod = this.modules![resolved]
240
+ const resolvedPath = resolveModulePath(source, this.baseUrl || './', moduleKeys)
241
+ if (!resolvedPath) continue
242
+ const mod = this.modules![resolvedPath]
241
243
  if (mod == null) continue
242
244
 
243
245
  for (const spec of statement.specifiers ?? []) {
@@ -258,12 +260,14 @@ export class MdastToJsx {
258
260
  }
259
261
 
260
262
  this.c[spec.local.name] = value
263
+ resolved.add(spec.local.name)
261
264
  // Also add to scope so values are available in expressions
262
265
  // like {code} or prop={code}
263
266
  if (!this.scope) this.scope = {}
264
267
  this.scope[spec.local.name] = value
265
268
  }
266
269
  }
270
+ return resolved
267
271
  }
268
272
 
269
273
  addLineNumberToProps(
@@ -560,6 +564,16 @@ export class MdastToJsx {
560
564
  }
561
565
  }
562
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
+
563
577
  return Evaluate.evaluate.sync(expression, context, options)
564
578
  }
565
579
 
@@ -755,10 +769,65 @@ export class MdastToJsx {
755
769
 
756
770
  switch (node.type) {
757
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
+
758
803
  // Resolve imports from pre-loaded modules (server-side)
804
+ let resolvedImportLocals = new Set<string>()
759
805
  if (this.modules) {
760
- this.resolveImportsFromModules(node)
806
+ resolvedImportLocals = this.resolveImportsFromModules(node)
761
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
+
762
831
  // Parse ESM imports for client-side dynamic loading (only if allowed)
763
832
  if (this.allowClientEsmImports) {
764
833
  const parsedImports = parseEsmImports(node, (err) =>
@@ -1120,6 +1189,144 @@ export class MdastToJsx {
1120
1189
  }
1121
1190
 
1122
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
+
1123
1330
  function accessWithDot(obj, path: string) {
1124
1331
  return path
1125
1332
  .split('.')