|
|
@@ -0,0 +1,230 @@
|
|
|
+// Performs a few useful codemod transformations for Overleaf's esm migration.
|
|
|
+// The transformations mostly address specific issues faced commonly in Overleaf's `web` service.
|
|
|
+// * Replaces `sandboxed-module` imports with `esmock` imports.
|
|
|
+// * Replaces `sandboxed-module` invocation with `esmock` invocation (Assumes `SandboxedModule.require` is used for the invocation).
|
|
|
+// * Fixes `mongodb-legacy` import to use `mongodb` import and extract `ObjectId` from the import.
|
|
|
+// * Replaces `require('path').join` with `path.join` (importing the path module if not already imported).
|
|
|
+// * Adds `const __dirname = fileURLToPath(new URL('.', import.meta.url))` if `__dirname` is used in the file.
|
|
|
+// * Adds `.js` or `.mjs` extension (as appropriate) to relative path imports.
|
|
|
+// call this with `jscodeshift -t overleaf-es-codemod.js <file>` or using the `cjs-to-esm.js` script (which does this as the final step before formatting).
|
|
|
+
|
|
|
+const fs = require('node:fs')
|
|
|
+const Path = require('node:path')
|
|
|
+
|
|
|
+module.exports = function (fileInfo, api) {
|
|
|
+ const j = api.jscodeshift
|
|
|
+ const root = j(fileInfo.source)
|
|
|
+ const body = root.get().value.program.body
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Conditionally adds an import statement to the top of the file if it doesn't already exist.
|
|
|
+ * @param moduleName A plain text name for the module to import (e.g. 'node:path').
|
|
|
+ * @param specifier A jscodeshift specifier for the import statement (provides e.g. `{ promises }` from `import { promises } from 'fs'`.
|
|
|
+ * @param existingImportCheck A function that checks if a specific import statement is the one we're looking for.
|
|
|
+ */
|
|
|
+ function addImport(moduleName, specifier, existingImportCheck) {
|
|
|
+ // Add import path from 'path' at the top if not already present
|
|
|
+ const importDeclaration = j.importDeclaration(
|
|
|
+ specifier,
|
|
|
+ j.literal(moduleName)
|
|
|
+ )
|
|
|
+
|
|
|
+ if (!existingImportCheck) {
|
|
|
+ existingImportCheck = node => node.source.value === moduleName
|
|
|
+ }
|
|
|
+
|
|
|
+ const existingImport = body.find(
|
|
|
+ node => node.type === 'ImportDeclaration' && existingImportCheck(node)
|
|
|
+ )
|
|
|
+
|
|
|
+ if (!existingImport) {
|
|
|
+ const lastImportIndex = body.reduce((lastIndex, node, index) => {
|
|
|
+ return node.type === 'ImportDeclaration' ? index : lastIndex
|
|
|
+ }, -1)
|
|
|
+ body.splice(lastImportIndex, 0, importDeclaration)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Replace sandboxed-module imports
|
|
|
+ root
|
|
|
+ .find(j.ImportDeclaration, {
|
|
|
+ source: { value: 'sandboxed-module' },
|
|
|
+ })
|
|
|
+ .forEach(path => {
|
|
|
+ path.node.source.value = 'esmock'
|
|
|
+ if (path.node.specifiers.length > 0 && path.node.specifiers[0].local) {
|
|
|
+ path.node.specifiers[0].local.name = 'esmock'
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // Replace sandboxedModule.require calls with awaited esmock calls
|
|
|
+ root
|
|
|
+ .find(j.CallExpression, {
|
|
|
+ callee: {
|
|
|
+ object: { name: 'SandboxedModule' },
|
|
|
+ property: { name: 'require' },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ .forEach(path => {
|
|
|
+ const args = path.node.arguments
|
|
|
+ if (args.length > 0) {
|
|
|
+ const firstArg = args[0]
|
|
|
+ const esmockArgs = [firstArg]
|
|
|
+
|
|
|
+ // Check if there's a second argument with a 'requires' property
|
|
|
+ if (args.length > 1 && args[1].type === 'ObjectExpression') {
|
|
|
+ const requiresProp = args[1].properties.find(
|
|
|
+ prop =>
|
|
|
+ prop.key.name === 'requires' || prop.key.value === 'requires'
|
|
|
+ )
|
|
|
+
|
|
|
+ if (requiresProp) {
|
|
|
+ // Move contents of 'requires' to top level
|
|
|
+ esmockArgs.push(requiresProp.value)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create the await expression with restructured arguments
|
|
|
+ const awaitExpression = j.awaitExpression(
|
|
|
+ j.callExpression(
|
|
|
+ j.memberExpression(j.identifier('esmock'), j.identifier('strict')),
|
|
|
+ esmockArgs
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ // Replace the original call with the await expression
|
|
|
+ j(path).replaceWith(awaitExpression)
|
|
|
+
|
|
|
+ // Find the closest function and make it async
|
|
|
+ let functionPath = path
|
|
|
+ while ((functionPath = functionPath.parent)) {
|
|
|
+ if (
|
|
|
+ functionPath.node.type === 'FunctionDeclaration' ||
|
|
|
+ functionPath.node.type === 'FunctionExpression' ||
|
|
|
+ functionPath.node.type === 'ArrowFunctionExpression'
|
|
|
+ ) {
|
|
|
+ functionPath.node.async = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // Fix mongodb-legacy import
|
|
|
+ root
|
|
|
+ .find(j.ImportDeclaration, {
|
|
|
+ source: { value: 'mongodb-legacy' },
|
|
|
+ specifiers: [{ imported: { name: 'ObjectId' } }],
|
|
|
+ })
|
|
|
+ .forEach(path => {
|
|
|
+ // Create new import declaration
|
|
|
+ const newImport = j.importDeclaration(
|
|
|
+ [j.importDefaultSpecifier(j.identifier('mongodb'))],
|
|
|
+ j.literal('mongodb-legacy')
|
|
|
+ )
|
|
|
+
|
|
|
+ // Create new constant declaration
|
|
|
+ const newConst = j.variableDeclaration('const', [
|
|
|
+ j.variableDeclarator(
|
|
|
+ j.objectPattern([
|
|
|
+ j.property(
|
|
|
+ 'init',
|
|
|
+ j.identifier('ObjectId'),
|
|
|
+ j.identifier('ObjectId')
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
+ j.identifier('mongodb')
|
|
|
+ ),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // Replace the old import with the new import and constant declaration
|
|
|
+ j(path).replaceWith(newImport)
|
|
|
+ path.insertAfter(newConst)
|
|
|
+ })
|
|
|
+
|
|
|
+ root
|
|
|
+ .find(j.CallExpression, {
|
|
|
+ callee: {
|
|
|
+ object: { callee: { name: 'require' }, arguments: [{ value: 'path' }] },
|
|
|
+ property: { name: 'join' },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ .forEach(path => {
|
|
|
+ // Replace with path.join
|
|
|
+ j(path).replaceWith(
|
|
|
+ j.callExpression(
|
|
|
+ j.memberExpression(j.identifier('path'), j.identifier('join')),
|
|
|
+ path.node.arguments
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ // Add import path from 'path' at the top if not already presen
|
|
|
+ addImport(
|
|
|
+ 'node:path',
|
|
|
+ [j.importDefaultSpecifier(j.identifier('path'))],
|
|
|
+ node =>
|
|
|
+ node.source.value === 'path' || node.source.value === 'node:path'
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ // Add const __dirname = fileURLToPath(new URL('.', import.meta.url)) if there is a usage of __dirname
|
|
|
+ const dirnameDeclaration = j.variableDeclaration('const', [
|
|
|
+ j.variableDeclarator(
|
|
|
+ j.identifier('__dirname'),
|
|
|
+ j.callExpression(j.identifier('fileURLToPath'), [
|
|
|
+ j.newExpression(j.identifier('URL'), [
|
|
|
+ j.literal('.'),
|
|
|
+ j.memberExpression(j.identifier('import'), j.identifier('meta.url')),
|
|
|
+ ]),
|
|
|
+ ])
|
|
|
+ ),
|
|
|
+ ])
|
|
|
+
|
|
|
+ const existingDirnameDeclaration = body.find(
|
|
|
+ node =>
|
|
|
+ node.type === 'VariableDeclaration' &&
|
|
|
+ node.declarations[0].id.name === '__dirname'
|
|
|
+ )
|
|
|
+ const firstDirnameUsage = root.find(j.Identifier, { name: '__dirname' }).at(0)
|
|
|
+
|
|
|
+ if (firstDirnameUsage.size() > 0 && !existingDirnameDeclaration) {
|
|
|
+ // Add import path from 'path' at the top if not already present
|
|
|
+ addImport(
|
|
|
+ 'node:url',
|
|
|
+ [j.importSpecifier(j.identifier('fileURLToPath'))],
|
|
|
+ node => node.source.value === 'url' || node.source.value === 'node:url'
|
|
|
+ )
|
|
|
+
|
|
|
+ const lastImportIndex = body.reduce((lastIndex, node, index) => {
|
|
|
+ return node.type === 'ImportDeclaration' ? index : lastIndex
|
|
|
+ }, -1)
|
|
|
+
|
|
|
+ body.splice(lastImportIndex + 1, 0, dirnameDeclaration)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Add extension to relative path imports
|
|
|
+ root
|
|
|
+ .find(j.ImportDeclaration)
|
|
|
+ .filter(path => path.node.source.value.startsWith('.'))
|
|
|
+ .forEach(path => {
|
|
|
+ const importPath = path.node.source.value
|
|
|
+ const fullPathJs = Path.resolve(
|
|
|
+ Path.dirname(fileInfo.path),
|
|
|
+ `${importPath}.js`
|
|
|
+ )
|
|
|
+ const fullPathMjs = Path.resolve(
|
|
|
+ Path.dirname(fileInfo.path),
|
|
|
+ `${importPath}.mjs`
|
|
|
+ )
|
|
|
+
|
|
|
+ if (fs.existsSync(fullPathJs)) {
|
|
|
+ path.node.source.value = `${importPath}.js`
|
|
|
+ } else if (fs.existsSync(fullPathMjs)) {
|
|
|
+ path.node.source.value = `${importPath}.mjs`
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return root.toSource({
|
|
|
+ quote: 'single',
|
|
|
+ })
|
|
|
+}
|