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/dist/esm-parser.test.js +25 -0
- package/dist/esm-parser.test.js.map +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +11 -6
- package/dist/parse.js.map +1 -1
- package/dist/safe-mdx.d.ts +6 -1
- package/dist/safe-mdx.d.ts.map +1 -1
- package/dist/safe-mdx.js +232 -12
- package/dist/safe-mdx.js.map +1 -1
- package/dist/safe-mdx.test.js +653 -0
- package/dist/safe-mdx.test.js.map +1 -1
- package/package.json +1 -1
- package/src/esm-parser.test.ts +30 -0
- package/src/parse.ts +12 -6
- package/src/safe-mdx.test.tsx +711 -0
- package/src/safe-mdx.tsx +234 -13
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
|
-
|
|
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
|
|
234
|
-
if (!
|
|
235
|
-
const mod = this.modules![
|
|
236
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
534
|
-
? { ...(
|
|
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('.')
|