From ddd38a411a6531244d2ee134588e9414023d28d4 Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Fri, 10 Feb 2023 18:54:26 +0000 Subject: [PATCH] chore: Create script to help with js -> ts migration (#6837) --- scripts/migration/js2ts | 154 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100755 scripts/migration/js2ts diff --git a/scripts/migration/js2ts b/scripts/migration/js2ts new file mode 100755 index 000000000..324a75086 --- /dev/null +++ b/scripts/migration/js2ts @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const filenames = process.argv.slice(2); // Trim off node and script name. + +////////////////////////////////////////////////////////////////////// +// Load deps files via require (since they're executalbe .js files). +////////////////////////////////////////////////////////////////////// + +/** + * Dictionary mapping goog.module ID to absolute pathname of the file + * containing the goog.declareModuleId for that ID. + * @type {!Object} + */ +const modulePaths = {}; + +/** Absolute path of repository root. */ +const repoPath = path.resolve(__dirname, '..', '..'); + +/** + * Absolute path of directory containing base.js (the version used as + * input to tsc, not the one output by it). + * @type {string} + */ +const closurePath = path.resolve(repoPath, 'closure', 'goog'); + +globalThis.goog = {}; + +/** + * Stub version of addDependency that store mappings in modulePaths. + * @param {string} relPath The path to the js file. + * @param {!Array} provides An array of strings with + * the names of the objects this file provides. + * @param {!Array} _requires An array of strings with + * the names of the objects this file requires (unused). + * @param {boolean|!Object=} opt_loadFlags Parameters indicating + * how the file must be loaded. The boolean 'true' is equivalent + * to {'module': 'goog'} for backwards-compatibility. Valid properties + * and values include {'module': 'goog'} and {'lang': 'es6'}. + */ +goog.addDependency = function(relPath, provides, _requires, opt_loadFlags) { + // Ignore any non-ESM files, as they can't be imported. + if (opt_loadFlags?.module !== 'es6') return; + + // There should be only one "provide" from an ESM, but... + for (const moduleId of provides) { + // Store absolute path to source file (i.e., treating relPath + // relative to closure/goog/, not build/src/closure/goog/). + modulePaths[moduleId] = path.resolve(closurePath, relPath); + } +}; + +// Load deps files relative to this script's location. +require(path.resolve(__dirname, '../../build/deps.js')); +require(path.resolve(__dirname, '../../build/deps.mocha.js')); + +////////////////////////////////////////////////////////////////////// +// Process files mentioned on the command line. +////////////////////////////////////////////////////////////////////// + +/** RegExp matching goog.require statements. */ +const requireRE = + /(?:const\s+(?:([$\w]+)|(\{[^}]*\}))\s+=\s+)?goog.require(Type)?\('([^']+)'\);/mg; + +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} to TypeScript...`); + + // Remove "use strict". + contents = contents.replace(/^\s*["']use strict["']\s*; *\n/m, ''); + + // Migrate from goog.module to goog.declareModuleId. + const closurePathRelative = + path.relative(path.dirname(path.resolve(filename)), closurePath); + contents = contents.replace( + /^goog.module\('([$\w.]+)'\);$/m, + `import * as goog from '${closurePathRelative}/goog.js';\n` + + `goog.declareModuleId('$1');`); + + // Migrate from goog.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. + type, // If truthy, it is a requireType not require. + moduleId, // goog.module ID that was goog.require()d. + ) { + const importPath = modulePaths[moduleId]; + type = type ? ' type' : ''; + if (!importPath) { + console.warn(`Unable to migrate goog.require('${ + moduleId}') as no ES module path known.`); + return orig; + } + const relativePath = + path.relative(path.dirname(path.resolve(filename)), importPath); + if (name) { + return `import${type} * as ${name} from '${relativePath}';`; + } else if (names) { + return `import${type} ${names} from '${relativePath}';`; + } else { // Side-effect only require. + return `import${type} '${relativePath}';`; + } + }); + + // Find and update or remove old-style export assignemnts. + /** @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 transalted 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*)((?:const|let|var|function|class)\\s+${declName})\\b`, + 'gm'); + if (contents.match(declRE)) { + easyExports.push({exportName, declRE}); + return ''; // Delete existing export assignment. + } else { + return `export ${exportName};\n`; // Safe fallback. + } + }); + // 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(/.js$/, '.ts'); + fs.writeFileSync(newFilename, contents); + console.log(`Wrote ${newFilename}.`); +}