overleaf-es-codemod.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. // Performs a few useful codemod transformations for Overleaf's esm migration.
  2. // The transformations mostly address specific issues faced commonly in Overleaf's `web` service.
  3. // * Replaces `sandboxed-module` imports with `esmock` imports.
  4. // * Replaces `sandboxed-module` invocation with `esmock` invocation (Assumes `SandboxedModule.require` is used for the invocation).
  5. // * Fixes `mongodb-legacy` import to use `mongodb` import and extract `ObjectId` from the import.
  6. // * Replaces `require('path').join` with `path.join` (importing the path module if not already imported).
  7. // * Adds `const __dirname = fileURLToPath(new URL('.', import.meta.url))` if `__dirname` is used in the file.
  8. // * Adds `.js` or `.mjs` extension (as appropriate) to relative path imports.
  9. // 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).
  10. const fs = require('node:fs')
  11. const Path = require('node:path')
  12. module.exports = function (fileInfo, api) {
  13. const j = api.jscodeshift
  14. const root = j(fileInfo.source)
  15. const body = root.get().value.program.body
  16. /**
  17. * Conditionally adds an import statement to the top of the file if it doesn't already exist.
  18. * @param moduleName A plain text name for the module to import (e.g. 'node:path').
  19. * @param specifier A jscodeshift specifier for the import statement (provides e.g. `{ promises }` from `import { promises } from 'fs'`.
  20. * @param existingImportCheck A function that checks if a specific import statement is the one we're looking for.
  21. */
  22. function addImport(moduleName, specifier, existingImportCheck) {
  23. // Add import path from 'path' at the top if not already present
  24. const importDeclaration = j.importDeclaration(
  25. specifier,
  26. j.literal(moduleName)
  27. )
  28. if (!existingImportCheck) {
  29. existingImportCheck = node => node.source.value === moduleName
  30. }
  31. const existingImport = body.find(
  32. node => node.type === 'ImportDeclaration' && existingImportCheck(node)
  33. )
  34. if (!existingImport) {
  35. const lastImportIndex = body.reduce((lastIndex, node, index) => {
  36. return node.type === 'ImportDeclaration' ? index : lastIndex
  37. }, -1)
  38. body.splice(lastImportIndex, 0, importDeclaration)
  39. }
  40. }
  41. // Replace sandboxed-module imports
  42. root
  43. .find(j.ImportDeclaration, {
  44. source: { value: 'sandboxed-module' },
  45. })
  46. .forEach(path => {
  47. path.node.source.value = 'esmock'
  48. if (path.node.specifiers.length > 0 && path.node.specifiers[0].local) {
  49. path.node.specifiers[0].local.name = 'esmock'
  50. }
  51. })
  52. // Replace sandboxedModule.require calls with awaited esmock calls
  53. root
  54. .find(j.CallExpression, {
  55. callee: {
  56. object: { name: 'SandboxedModule' },
  57. property: { name: 'require' },
  58. },
  59. })
  60. .forEach(path => {
  61. const args = path.node.arguments
  62. if (args.length > 0) {
  63. const firstArg = args[0]
  64. const esmockArgs = [firstArg]
  65. // Check if there's a second argument with a 'requires' property
  66. if (args.length > 1 && args[1].type === 'ObjectExpression') {
  67. const requiresProp = args[1].properties.find(
  68. prop =>
  69. prop.key.name === 'requires' || prop.key.value === 'requires'
  70. )
  71. if (requiresProp) {
  72. // Move contents of 'requires' to top level
  73. esmockArgs.push(requiresProp.value)
  74. }
  75. }
  76. // Create the await expression with restructured arguments
  77. const awaitExpression = j.awaitExpression(
  78. j.callExpression(
  79. j.memberExpression(j.identifier('esmock'), j.identifier('strict')),
  80. esmockArgs
  81. )
  82. )
  83. // Replace the original call with the await expression
  84. j(path).replaceWith(awaitExpression)
  85. // Find the closest function and make it async
  86. let functionPath = path
  87. while ((functionPath = functionPath.parent)) {
  88. if (
  89. functionPath.node.type === 'FunctionDeclaration' ||
  90. functionPath.node.type === 'FunctionExpression' ||
  91. functionPath.node.type === 'ArrowFunctionExpression'
  92. ) {
  93. functionPath.node.async = true
  94. break
  95. }
  96. }
  97. }
  98. })
  99. // Fix mongodb-legacy import
  100. root
  101. .find(j.ImportDeclaration, {
  102. source: { value: 'mongodb-legacy' },
  103. specifiers: [{ imported: { name: 'ObjectId' } }],
  104. })
  105. .forEach(path => {
  106. // Create new import declaration
  107. const newImport = j.importDeclaration(
  108. [j.importDefaultSpecifier(j.identifier('mongodb'))],
  109. j.literal('mongodb-legacy')
  110. )
  111. // Create new constant declaration
  112. const newConst = j.variableDeclaration('const', [
  113. j.variableDeclarator(
  114. j.objectPattern([
  115. j.property(
  116. 'init',
  117. j.identifier('ObjectId'),
  118. j.identifier('ObjectId')
  119. ),
  120. ]),
  121. j.identifier('mongodb')
  122. ),
  123. ])
  124. // Replace the old import with the new import and constant declaration
  125. j(path).replaceWith(newImport)
  126. path.insertAfter(newConst)
  127. })
  128. root
  129. .find(j.CallExpression, {
  130. callee: {
  131. object: { callee: { name: 'require' }, arguments: [{ value: 'path' }] },
  132. property: { name: 'join' },
  133. },
  134. })
  135. .forEach(path => {
  136. // Replace with path.join
  137. j(path).replaceWith(
  138. j.callExpression(
  139. j.memberExpression(j.identifier('path'), j.identifier('join')),
  140. path.node.arguments
  141. )
  142. )
  143. // Add import path from 'path' at the top if not already presen
  144. addImport(
  145. 'node:path',
  146. [j.importDefaultSpecifier(j.identifier('path'))],
  147. node =>
  148. node.source.value === 'path' || node.source.value === 'node:path'
  149. )
  150. })
  151. // Add const __dirname = fileURLToPath(new URL('.', import.meta.url)) if there is a usage of __dirname
  152. const dirnameDeclaration = j.variableDeclaration('const', [
  153. j.variableDeclarator(
  154. j.identifier('__dirname'),
  155. j.callExpression(j.identifier('fileURLToPath'), [
  156. j.newExpression(j.identifier('URL'), [
  157. j.literal('.'),
  158. j.memberExpression(j.identifier('import'), j.identifier('meta.url')),
  159. ]),
  160. ])
  161. ),
  162. ])
  163. const existingDirnameDeclaration = body.find(
  164. node =>
  165. node.type === 'VariableDeclaration' &&
  166. node.declarations[0].id.name === '__dirname'
  167. )
  168. const firstDirnameUsage = root.find(j.Identifier, { name: '__dirname' }).at(0)
  169. if (firstDirnameUsage.size() > 0 && !existingDirnameDeclaration) {
  170. // Add import path from 'path' at the top if not already present
  171. addImport(
  172. 'node:url',
  173. [j.importSpecifier(j.identifier('fileURLToPath'))],
  174. node => node.source.value === 'url' || node.source.value === 'node:url'
  175. )
  176. const lastImportIndex = body.reduce((lastIndex, node, index) => {
  177. return node.type === 'ImportDeclaration' ? index : lastIndex
  178. }, -1)
  179. body.splice(lastImportIndex + 1, 0, dirnameDeclaration)
  180. }
  181. // Add extension to relative path imports
  182. root
  183. .find(j.ImportDeclaration)
  184. .filter(path => path.node.source.value.startsWith('.'))
  185. .forEach(path => {
  186. const importPath = path.node.source.value
  187. const fullPathJs = Path.resolve(
  188. Path.dirname(fileInfo.path),
  189. `${importPath}.js`
  190. )
  191. const fullPathMjs = Path.resolve(
  192. Path.dirname(fileInfo.path),
  193. `${importPath}.mjs`
  194. )
  195. if (fs.existsSync(fullPathJs)) {
  196. path.node.source.value = `${importPath}.js`
  197. } else if (fs.existsSync(fullPathMjs)) {
  198. path.node.source.value = `${importPath}.mjs`
  199. }
  200. })
  201. return root.toSource({
  202. quote: 'single',
  203. })
  204. }