Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Refactor aliased @ imports to relative paths

Tags:

In modular environments that use Webpack, TypeScript or other tools that transform ES module imports, path aliases are used, a common convention is @ for src.

It's a frequent problem for me to transform a project with aliased absolute paths:

src/foo/bar/index.js

import baz from '@/baz'; 

to relative paths:

src/foo/bar/index.js

import baz from '../../baz'; 

For instance, a project that uses aliases needs to be merged with another project that doesn't use aliases, configuring the latter to use aliases isn't an option due to style guide or other causes.

This cannot be solved with simple search and replace, and fixing import paths manually is tedious and prone to errors. I expect original JavaScript/TypeScript codebase to remain intact in other respects, so transforming it with a transpiler may be not an option.

I would like to achieve this kind of refactoring with IDE of my choice (Jetbrains IDEA/Webstorm/Phpstorm) but would accept a solution with any other IDE (VS Code) or plain Node.js.

How can this be achieved?

like image 625
Estus Flask Avatar asked Jul 24 '19 17:07

Estus Flask


2 Answers

Three possible solutions that rewire aliased imports to relative paths:

1. babel-plugin-module-resolver

Use babel-plugin-module-resolver, while leaving out other babel plugins/presets.

.babelrc:
"plugins": [   [     "module-resolver",     {       "alias": {         "^@/(.+)": "./src/\\1"       }     }   ] ] 

Build step: babel src --out-dir dist (output in dist, won't modify in-place)

Processed example file:
// input                                // output import { helloWorld } from "@/sub/b"    // import { helloWorld } from "./sub/b"; import "@/sub/b"                        // import "./sub/b"; export { helloWorld } from "@/sub/b"    // export { helloWorld } from "./sub/b"; export * from "@/sub/b"                 // export * from "./sub/b"; 

For TS, you will also need @babel/preset-typescript and activate .ts extensions by babel src --out-dir dist --extensions ".ts".

2. Codemod jscodeshift with Regex

All relevant import/export variants from MDN docs should be supported. The algorithm is implemented like this:

1. Input: path aliases mapping in the form alias -> resolved path akin to TypeScript tsconfig.json paths or Webpack's resolve.alias:

const pathMapping = {   "@": "./custom/app/path",   ... }; 

2. Iterate over all source files, e.g. traverse src:

jscodeshift -t scripts/jscodeshift.js src # use -d -p options for dry-run + stdout # or for TS jscodeshift --extensions=ts --parser=ts -t scripts/jscodeshift.js src 

3. For each source file, find all import and export declarations

function transform(file, api) {   const j = api.jscodeshift;   const root = j(file.source);    root.find(j.ImportDeclaration).forEach(replaceNodepathAliases);   root.find(j.ExportAllDeclaration).forEach(replaceNodepathAliases);   root     .find(j.ExportNamedDeclaration, node => node.source !== null)     .forEach(replaceNodepathAliases);   return root.toSource();  ... }; 

jscodeshift.js:

/**  * Corresponds to tsconfig.json paths or webpack aliases  * E.g. "@/app/store/AppStore" -> "./src/app/store/AppStore"  */ const pathMapping = {   "@": "./src",   foo: "bar", };  const replacePathAlias = require("./replace-path-alias");  module.exports = function transform(file, api) {   const j = api.jscodeshift;   const root = j(file.source);    root.find(j.ImportDeclaration).forEach(replaceNodepathAliases);   root.find(j.ExportAllDeclaration).forEach(replaceNodepathAliases);    /**    * Filter out normal module exports, like export function foo(){ ...}    * Include export {a} from "mymodule" etc.    */   root .find(j.ExportNamedDeclaration, (node) => node.source !== null) .forEach(replaceNodepathAliases);    return root.toSource();    function replaceNodepathAliases(impExpDeclNodePath) { impExpDeclNodePath.value.source.value = replacePathAlias(   file.path,   impExpDeclNodePath.value.source.value,   pathMapping );   } };

Further illustration:

import { AppStore } from "@/app/store/appStore-types" 

creates following AST, whose source.value of ImportDeclaration node can be modified:

AST explorer

4. For each path declaration, test for a Regex pattern that includes one of the path aliases.

5. Get the resolved path of the alias and convert as path relative to the current file's location (credit to @Reijo)

replace-path-alias.js (4. + 5.):

const path = require("path");  function replacePathAlias(currentFilePath, importPath, pathMap) {   // if windows env, convert backslashes to "/" first   currentFilePath = path.posix.join(...currentFilePath.split(path.sep));    const regex = createRegex(pathMap);   return importPath.replace(regex, replacer);    function replacer(_, alias, rest) { const mappedImportPath = pathMap[alias] + rest;  // use path.posix to also create foward slashes on windows environment let mappedImportPathRelative = path.posix.relative(   path.dirname(currentFilePath),   mappedImportPath ); // append "./" to make it a relative import path if (!mappedImportPathRelative.startsWith("../")) {   mappedImportPathRelative = `./${mappedImportPathRelative}`; }  logReplace(currentFilePath, mappedImportPathRelative);  return mappedImportPathRelative;   } }  function createRegex(pathMap) {   const mapKeysStr = Object.keys(pathMap).reduce((acc, cur) => `${acc}|${cur}`);   const regexStr = `^(${mapKeysStr})(.*)$`;   return new RegExp(regexStr, "g"); }  const log = true; function logReplace(currentFilePath, mappedImportPathRelative) {   if (log) console.log(   "current processed file:",   currentFilePath,   "; Mapped import path relative to current file:",   mappedImportPathRelative ); }  module.exports = replacePathAlias;

3. Regex-only search and replace

Iterate throught all sources and apply a regex (not tested thoroughly):

^(import.*from\\s+["|'])(${aliasesKeys})(.*)(["|'])$ 

, where ${aliasesKeys} contains path alias "@". The new import path can be processed by modifying the 2nd and 3rd capture group (path mapping + resolving to a relative path).

This variant cannot deal with AST, hence might considered to be not as stable as jscodeshift.

Currently, the Regex only supports imports. Side effect imports in the form import "module-name" are excluded, with the benefit of going safer with search/replace.

Sample:

const path = require("path");  // here sample file content of one file as hardcoded string for simplicity. // For your project, read all files (e.g. "fs.readFile" in node.js) // and foreach file replace content by the return string of replaceImportPathAliases function. const fileContentSample = ` import { AppStore } from "@/app/store/appStore-types" import { WidgetService } from "@/app/WidgetService" import { AppStoreImpl } from "@/app/store/AppStoreImpl" import { rootReducer } from "@/app/store/root-reducer" export { appStoreFactory } `;  // corresponds to tsconfig.json paths or webpack aliases // e.g. "@/app/store/AppStoreImpl" -> "./custom/app/path/app/store/AppStoreImpl" const pathMappingSample = {   "@": "./src",   foo: "bar" };  const currentFilePathSample = "./src/sub/a.js";  function replaceImportPathAliases(currentFilePath, fileContent, pathMap) {   const regex = createRegex(pathMap);   return fileContent.replace(regex, replacer);    function replacer(_, g1, aliasGrp, restPathGrp, g4) {     const mappedImportPath = pathMap[aliasGrp] + restPathGrp;      let mappedImportPathRelative = path.posix.relative(       path.dirname(currentFilePath),       mappedImportPath     );     // append "./" to make it a relative import path     if (!mappedImportPathRelative.startsWith("../")) {       mappedImportPathRelative = `./${mappedImportPathRelative}`;     }     return g1 + mappedImportPathRelative + g4;   } }  function createRegex(pathMap) {   const mapKeysStr = Object.keys(pathMap).reduce((acc, cur) => `${acc}|${cur}`);   const regexStr = `^(import.*from\\s+["|'])(${mapKeysStr})(.*)(["|'])$`;   return new RegExp(regexStr, "gm"); }  console.log(   replaceImportPathAliases(     currentFilePathSample,     fileContentSample,     pathMappingSample   ) );
like image 183
ford04 Avatar answered Dec 15 '22 14:12

ford04


I created a script to do this.

It basically traverses the project tree, searches for all files, finds imports that look like "@/my/import" with a regex /"@(\/\w+[\w\/.]+)"/gi and than uses the path module of nodejs to create the relative path.

I hope you don't have any edge cases that I didn't cover in this simple script, so better backup your files. I have only tested it in a simple scenario.

Here is the code:

const path = require("path"); const args = process.argv;  const rootName = args[2]; const rootPath = path.resolve(process.cwd(), rootName); const alias = "@";  if (!rootPath || !alias) return;  const { promisify } = require("util"); const fs = require("fs");  const readFileAsync = promisify(fs.readFile); const readDirAsync = promisify(fs.readdir); const writeFileAsync = promisify(fs.writeFile); const statsAsync = promisify(fs.stat);  function testForAliasImport(file) {   if (!file.content) return file;    const regex = /"@(\/\w+[\w\/.]+)"/gi;    let match,     search = file.content;    while ((match = regex.exec(search))) {     const matchString = match[0];     console.log(`found alias import ${matchString} in ${file.filepath}`);     file.content = file.content.replace(       matchString,       aliasToRelative(file, matchString)     );     search = search.substring(match.index + matchString.length);   }    return file; }  function aliasToRelative(file, importString) {   let importPath = importString     .replace(alias, "")     .split('"')     .join("");   const hasExtension = !!path.parse(importString).ext;    if (!hasExtension) {     importPath += ".ext";   }    const filepath = file.filepath     .replace(rootPath, "")     .split("\\")     .join("/");    let relativeImport = path.posix.relative(path.dirname(filepath), importPath);    if (!hasExtension) {     relativeImport = relativeImport.replace(".ext", "");   }    if (!relativeImport.startsWith("../")) {     relativeImport = "./" + relativeImport;   }    relativeImport = `"${relativeImport}"`;    console.log(`replaced alias import ${importString} with ${relativeImport}`);   return relativeImport; }  async function writeFile(file) {   if (!file || !file.content || !file.filepath) return file;   try {     console.log(`writing new contents to file ${file.filepath}...`);     await writeFileAsync(file.filepath, file.content);   } catch (e) {     console.error(e);   } }  async function prepareFile(filepath) {   const stat = await statsAsync(filepath);   return { stat, filepath }; }  async function processFile(file) {   if (file.stat.isFile()) {     console.log(`reading file ${file.filepath}...`);     file.content = await readFileAsync(file.filepath);     file.content = file.content.toString();   } else if (file.stat.isDirectory()) {     console.log(`traversing dir ${file.filepath}...`);     await traverseDir(file.filepath);   }   return file; }  async function traverseDir(dirPath) {   try {     const filenames = await readDirAsync(dirPath);     const filepaths = filenames.map(name => path.join(dirPath, name));     const fileStats = await Promise.all(filepaths.map(prepareFile));     const files = await Promise.all(fileStats.map(processFile));     await Promise.all(files.map(testForAliasImport).map(writeFile));   } catch (e) {     console.error(e);   } }   traverseDir(rootPath)   .then(() => console.log("done"))   .catch(console.error); 

Be sure to provide a directory name as an argument. Like src for instance.

For the IDE part, I know that Jetbrains Webstorm let you define npm tasks.
Create a scripts directory to hold the script.
Define a script in the package.json

"scripts": {     ...     "replaceimports": "node scripts/script.js \"src\"" } 

Register the npm task for usage in the npm tool window.

like image 39
Armin Bu Avatar answered Dec 15 '22 14:12

Armin Bu