overleaf/services/web/transform/cjs-to-esm/overleaf-es-codemod.js
Andrew Rumble ba7d11d854 Merge pull request #21105 from overleaf/ar-es-module-helper-scripts
[web] ES module helper scripts

GitOrigin-RevId: ebbb123b6e7336215038156b83e96ad42869bb07
2024-11-04 09:04:30 +00:00

230 lines
7.7 KiB
JavaScript
Executable file

// 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',
})
}