Script to automate 90% of CommonJS -> ESM (#20016)
* Create convert-cjs-to-esm.mjs * Update convert-cjs-to-esm.mjs * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * update script * Address a goofy export * Update convert-cjs-to-esm.mjs * Fix more exports * Revert "Merge branch 'goofy-export' into cjs-esm-script" This reverts commit c23927b824916ee5bc1443849a0edc3a5765cec6, reversing changes made to 4e760d33f63838b164bc95f18447945d173cc849. * Apply suggestions from code review Co-authored-by: James M. Greene <JamesMGreene@github.com> Co-authored-by: James M. Greene <JamesMGreene@github.com>
This commit is contained in:
271
script/code/convert-cjs-to-esm.mjs
Executable file
271
script/code/convert-cjs-to-esm.mjs
Executable file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/*
|
||||
|
||||
This script does most of the work to convert our *.js files to use ESM
|
||||
instead of CommonJS.
|
||||
|
||||
This is based on https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
|
||||
|
||||
This file assumes the two ESLint rules have already been installed and enforced:
|
||||
|
||||
- https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/global-require.md
|
||||
- https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-dynamic-require.md
|
||||
|
||||
(for reference) https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/prefer-module.md
|
||||
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises'
|
||||
import semver from 'semver'
|
||||
import walkSync from 'walk-sync'
|
||||
|
||||
// https://stackoverflow.com/a/31102605
|
||||
function orderKeys (unordered) {
|
||||
return Object.keys(unordered).sort().reduce(
|
||||
(obj, key) => {
|
||||
obj[key] = unordered[key].constructor === {}.constructor
|
||||
? orderKeys(unordered[key])
|
||||
: unordered[key]
|
||||
return obj
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
async function readPackageFile () {
|
||||
return JSON.parse(await fs.readFile('./package.json', 'utf8'))
|
||||
}
|
||||
|
||||
async function writePackageFile (packageFile) {
|
||||
return fs.writeFile('./package.json', JSON.stringify(orderKeys(packageFile), ' ', 2) + '\n')
|
||||
}
|
||||
|
||||
async function readAllJsFiles () {
|
||||
const paths = walkSync('./', {
|
||||
directories: false,
|
||||
includeBasePath: true,
|
||||
globs: ['**/*.js'],
|
||||
ignore: ['node_modules', 'dist']
|
||||
})
|
||||
return await Promise.all(
|
||||
paths.map(
|
||||
async path => [path, await fs.readFile(path, 'utf8')]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function listJsonPaths () {
|
||||
const paths = walkSync('./', {
|
||||
directories: false,
|
||||
includeBasePath: true,
|
||||
globs: ['**/*.json'],
|
||||
ignore: ['node_modules', 'dist']
|
||||
})
|
||||
return paths.map(p => p.replace('.json', ''))
|
||||
}
|
||||
|
||||
function withAllFiles(jsFiles, fn) {
|
||||
return jsFiles.map(
|
||||
([path, file]) => [path, fn(path, file)]
|
||||
)
|
||||
}
|
||||
|
||||
async function writeAllJsFiles (jsFiles) {
|
||||
return await Promise.all(
|
||||
jsFiles.map(
|
||||
async ([path, file]) => await fs.writeFile(path, file)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Converts a path to an import name
|
||||
/* Example:
|
||||
import xLunrJa from 'lunr-languages/lunr.ja'
|
||||
xLunrJa(lunr)
|
||||
*/
|
||||
function nameImport(p2) {
|
||||
const myString = p2.split('/').pop()
|
||||
const string = myString.replace(/[-\.]([a-z])/g, (g) => g[1].toUpperCase())
|
||||
return `x${string.charAt(0).toUpperCase()}${string.slice(1)}`
|
||||
}
|
||||
|
||||
// Add "type": "module" to your package.json.
|
||||
async function addTypeModule () {
|
||||
const packageFile = await readPackageFile()
|
||||
packageFile.type = 'module'
|
||||
return writePackageFile(packageFile)
|
||||
}
|
||||
|
||||
// Replace "main": "index.js" with "exports": "./index.js" in your package.json.
|
||||
async function updateMainExport () {
|
||||
const packageFile = await readPackageFile()
|
||||
const main = packageFile.main
|
||||
if (!main) return
|
||||
delete packageFile.main
|
||||
packageFile.exports = './' + main
|
||||
return writePackageFile(packageFile)
|
||||
}
|
||||
|
||||
// Update the "engines" field in package.json to Node.js 12: "node": "^12.20.0 || ^14.13.1 || >=16.0.0".
|
||||
// If 12 is already required, we will skip this change.
|
||||
async function checkEngines () {
|
||||
const packageFile = await readPackageFile()
|
||||
const nodeVersion = packageFile.engines.node
|
||||
if (semver.gt(semver.minVersion(nodeVersion), '12.0.0')) return
|
||||
packageFile.engines.node = "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
await writePackageFile(packageFile)
|
||||
}
|
||||
|
||||
// Remove 'use strict'; from all JavaScript files.
|
||||
function noStrict (path, file) {
|
||||
if (file.includes('use strict')) {
|
||||
throw new Error(`Cannot use strict in ${path}. Please remove and run this script again.`)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// Read JSON requires separately
|
||||
/*
|
||||
import { promises as fs } from 'fs'
|
||||
|
||||
const packageJson = JSON.parse(await fs.readFile('package.json', 'utf8'))
|
||||
*/
|
||||
function noJsonReads (jsonPaths) {
|
||||
return (path, file) => {
|
||||
const found = [...file.matchAll(/require\('[\.\/]+(.*?)'\)/gm)]
|
||||
if (!found) return file
|
||||
const matchesJsonPath = found
|
||||
.map(f => f[1])
|
||||
.filter(f => jsonPaths.some(p => p.endsWith(f)))
|
||||
if (matchesJsonPath.length) {
|
||||
throw new Error(`${path} has possible JSON requires: ${matchesJsonPath}. Please fix this manually then run the script again.`)
|
||||
}
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
// Replace all require()/module.export with import/export.
|
||||
// Use only full relative file paths for imports: import x from '.'; → import x from './index.js';.
|
||||
// Add `.js` if starts with `./`
|
||||
// Replace `:` to as
|
||||
|
||||
// Fix up standard const x = require('x') statements
|
||||
function updateStandardImport (path, file) {
|
||||
return file.replaceAll(
|
||||
/^const\s(.*?)\s*?=\s*?require\('(.*?)'\)$/gm,
|
||||
(_, p1, p2) => {
|
||||
// Replace `:` to as
|
||||
p1 = p1.replace(/\s*:\s*/g, ' as ')
|
||||
// Add `.js` if path starts with `.`
|
||||
if (p2.startsWith('.') && !p2.endsWith('.js')) p2 = p2 + '.js'
|
||||
return `import ${p1} from '${p2}'`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Fix up inlined requires that are still "top-level"
|
||||
function updateInlineImport (path, file) {
|
||||
return file.replaceAll(
|
||||
/^(.*?)require\('(.*?)'\)(.*)$/gm,
|
||||
(_, p1, p2, p3) => {
|
||||
// Generate a new import name based on the path
|
||||
const name = nameImport(p2)
|
||||
// Add `.js` if starts with `.`
|
||||
if (p2.startsWith('.') && !p2.endsWith('.js')) p2 = p2 + '.js'
|
||||
// Fix up unused require('x') statements
|
||||
if (!p1 && !p3) return `import '${p2}'`
|
||||
return `import ${name} from '${p2}'\n${p1}${name}${p3}`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Handle module.exports =
|
||||
function updateDefaultExport (path, file) {
|
||||
return file.replaceAll(
|
||||
/^module.exports\s*?=\s*?(\S.*)/gm,
|
||||
'export default $1'
|
||||
)
|
||||
}
|
||||
|
||||
// Handle exports.x =
|
||||
function updateNamedExport (path, file) {
|
||||
return file.replaceAll(
|
||||
/^exports\.(\S+)\s*?=\s*?(\S.*)/gm,
|
||||
'export const $1 = $2'
|
||||
)
|
||||
}
|
||||
|
||||
// Replace __filename and __dirname
|
||||
function updateFileAndDir (path, file) {
|
||||
if (!file.includes('__filename') && !file.includes('__dirname')) return file
|
||||
|
||||
return [
|
||||
'import { fileURLToPath } from \'node:url\'',
|
||||
'import path from \'node:path\'',
|
||||
file.includes('__filename') && 'const __filename = fileURLToPath(import.meta.url)',
|
||||
file.includes('__dirname') && 'const __dirname = path.dirname(fileURLToPath(import.meta.url))',
|
||||
file
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
// lodash => lodash-es
|
||||
function useEsLodash (path, file) {
|
||||
return file.replace("'lodash'", "'lodash-es'")
|
||||
}
|
||||
|
||||
// Pull all imports to the top of the file to avoid syntax issues
|
||||
function moveImportsToTop (path, file) {
|
||||
if (!file.includes('import')) return file
|
||||
const isTop = line => /^import/gm.test(line)
|
||||
const lineEnd = /\r?\n|\r/g
|
||||
return file.split(lineEnd).filter(isTop).join('\n') +
|
||||
'\n' +
|
||||
file.split(lineEnd).filter(line => !isTop(line)).join('\n')
|
||||
}
|
||||
|
||||
// Make sure script declarations on the top of the file before imports
|
||||
function updateScriptDeclaration (path, file) {
|
||||
if (!path.startsWith('./script')) return file
|
||||
file = file.replace('#!/usr/bin/env node\n', '')
|
||||
return '#!/usr/bin/env node\n' + file
|
||||
}
|
||||
|
||||
// Check there's no `require(` ... anywhere
|
||||
function checkRequire (path, file) {
|
||||
if (/require\s*\(/.test(file)) {
|
||||
throw new Error(`"require(" still in ${path}`)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// Check there's no `exports` ... anywhere
|
||||
function checkExports (path, file) {
|
||||
if (file.includes('exports')) {
|
||||
throw new Error(`"exports" still in ${path}`)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
async function main () {
|
||||
await addTypeModule()
|
||||
await updateMainExport()
|
||||
await checkEngines()
|
||||
const jsonPaths = listJsonPaths()
|
||||
let jsFiles = await readAllJsFiles()
|
||||
jsFiles = withAllFiles(jsFiles, noStrict)
|
||||
jsFiles = withAllFiles(jsFiles, noJsonReads(jsonPaths))
|
||||
jsFiles = withAllFiles(jsFiles, updateStandardImport)
|
||||
jsFiles = withAllFiles(jsFiles, updateInlineImport)
|
||||
jsFiles = withAllFiles(jsFiles, updateDefaultExport)
|
||||
jsFiles = withAllFiles(jsFiles, updateNamedExport)
|
||||
jsFiles = withAllFiles(jsFiles, updateFileAndDir)
|
||||
jsFiles = withAllFiles(jsFiles, useEsLodash)
|
||||
jsFiles = withAllFiles(jsFiles, moveImportsToTop)
|
||||
jsFiles = withAllFiles(jsFiles, updateScriptDeclaration)
|
||||
jsFiles = withAllFiles(jsFiles, checkRequire)
|
||||
jsFiles = withAllFiles(jsFiles, checkExports)
|
||||
// TBD enable await writeAllJsFiles(jsFiles)
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user