diff --git a/scripts/migration/cjs2esm b/scripts/migration/cjs2esm new file mode 100755 index 000000000..99a0e2223 --- /dev/null +++ b/scripts/migration/cjs2esm @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const filenames = process.argv.slice(2); // Trim off node and script name. + +/** Absolute path of repository root. */ +const repoPath = path.resolve(__dirname, '..', '..'); + +////////////////////////////////////////////////////////////////////// +// Process files mentioned on the command line. +////////////////////////////////////////////////////////////////////// + +/** RegExp matching require statements. */ +const requireRE = + /(?:const\s+(?:([$\w]+)|(\{[^}]*\}))\s*=\s*)?require\('([^']+)'\);/g; + +/** RegExp matching key: value pairs in destructuring assignments. */ +const keyValueRE = /([$\w]+)\s*:\s*([$\w]+)\s*(?=,|})/g; + +/** Prefix for RegExp matching a top-level declaration. */ +const declPrefix = '(?:const|let|var|(?:async\\s+)?function(?:\\s*\\*)?|class)'; + +for (const filename of filenames) { + let contents = null; + try { + contents = String(fs.readFileSync(filename)); + } catch (e) { + console.error(`error while reading ${filename}: ${e.message}`); + continue; + } + console.log(`Converting ${filename} from CJS to ESM...`); + + // Remove "use strict". + contents = contents.replace(/^\s*["']use strict["']\s*; *\n/m, ''); + + // Migrate from require to import. + contents = contents.replace( + requireRE, + function ( + orig, // Whole statement to be replaced. + name, // Name of named import of whole module (if applicable). + names, // {}-enclosed list of destructured imports. + moduleName, // Imported module name or path. + ) { + if (moduleName[0] === '.') { + // Relative path. Could check and add '.mjs' suffix if desired. + } + if (name) { + return `import * as ${name} from '${moduleName}';`; + } else if (names) { + names = names.replace(keyValueRE, '$1 as $2'); + return `import ${names} from '${moduleName}';`; + } else { + // Side-effect only require. + return `import '${moduleName}';`; + } + }, + ); + + // Find and update or remove old-style single-export assignments + // like: + // + // exports.bar = foo; // becomes export {foo as bar}; + // exports.foo = foo; // remove the export and export at declaration + // // instead, if possible. + /** @type {!Array<{name: string, re: RegExp>}>} */ + const easyExports = []; + contents = contents.replace( + /^\s*exports\.([$\w]+)\s*=\s*([$\w]+)\s*;\n/gm, + function ( + orig, // Whole statement to be replaced. + exportName, // Name to export item as. + declName, // Already-declared name for item being exported. + ) { + // Renamed exports have to be translated as-is. + if (exportName !== declName) { + return `export {${declName} as ${exportName}};\n`; + } + // OK, we're doing "export.foo = foo;". Can we update the + // declaration? We can't actualy modify it yet as we're in + // the middle of a search-and-replace on contents already, but + // we can delete the old export and later update the + // declaration into an export. + const declRE = new RegExp( + `^(\\s*)(${declPrefix}\\s+${declName})\\b`, + 'gm', + ); + if (contents.match(declRE)) { + easyExports.push({exportName, declRE}); + return ''; // Delete existing export assignment. + } else { + return `export ${exportName};\n`; // Safe fallback. + } + }, + ); + + // Find and update or remove old-style module.exports assignment + // like: + // + // module.exports = {foo, bar: baz, quux}; + // + // which becomes export {baz as bar}, with foo and quux exported at + // declaration instead, if possible. + contents = contents.replace( + /^module\.exports\s*=\s*\{([^\}]+)\};?(\n?)/m, + function ( + orig, // Whole statement to be replaced. + items, // List of items to be exported. + ) { + items = items.replace( + /( *)([$\w]+)\s*(?::\s*([$\w]+)\s*)?,?(\s*?\n?)/gm, + function ( + origItem, // Whole item being replaced. + indent, // Optional leading whitespace. + exportName, // Name to export item as. + declName, // Already-declared name being exported, if different. + newline, // Optional trailing whitespace. + ) { + if (!declName) declName = exportName; + + // Renamed exports have to be translated as-is. + if (exportName !== declName) { + return `${indent}${declName} as ${exportName},${newline}`; + } + // OK, this item has no rename. Can we update the + // declaration? We can't actualy modify it yet as we're in + // the middle of a search-and-replace on contents already, + // but we can delete the item and later update the + // declaration into an export. + const declRE = new RegExp( + `^(\\s*)(${declPrefix}\\s+${declName})\\b`, + 'gm', + ); + if (contents.match(declRE)) { + easyExports.push({exportName, declRE}); + return ''; // Delete existing item. + } else { + return `${indent}${exportName},${newline}`; // Safe fallback. + } + }, + ); + if (/^\s*$/s.test(items)) { + // No items left? + return ''; // Delete entire module.export assignment. + } else { + return `export {${items}};\n`; + } + }, + ); + + // Add 'export' to existing declarations where appropriate. + for (const {exportName, declRE} of easyExports) { + contents = contents.replace(declRE, '$1export $2'); + } + + // Write converted file with new extension. + const newFilename = filename.replace(/.c?js$/, '.mjs'); + fs.writeFileSync(newFilename, contents); + console.log(`Wrote ${newFilename}.`); +}