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/dist/safe-mdx.d.ts +2 -1
- package/dist/safe-mdx.d.ts.map +1 -1
- package/dist/safe-mdx.js +210 -5
- package/dist/safe-mdx.js.map +1 -1
- package/dist/safe-mdx.test.js +563 -0
- package/dist/safe-mdx.test.js.map +1 -1
- package/package.json +1 -1
- package/src/safe-mdx.test.tsx +617 -0
- package/src/safe-mdx.tsx +213 -6
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
|
-
|
|
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
|
|
239
|
-
if (!
|
|
240
|
-
const mod = this.modules![
|
|
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('.')
|