feat(scripts): Create script to help with CJS -> ESM migration (#8197)

* feat(scripts): Create script to help with CJS -> ESM migration

  This is based on js2ts, but considerably simplified since we
  no longer need to deal with goog.module IDs.

* feat(scripts): Add support for module.exports = {...}

  Support (optionally renaming) assignments to module.exports, in
  addition to the existing support for simple exports property
  assignmenets.

* fix(scripts): Typo corrections + other improvments for PR #8197

* chore(scripts): Format
This commit is contained in:
Christopher Allen
2024-06-12 18:29:47 +01:00
committed by GitHub
parent dc91c3ab54
commit 5a9a31ad1d

162
scripts/migration/cjs2esm Executable file
View File

@@ -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}.`);
}