Refactor aliased @ imports to relative paths

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
  )
);

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.


A simple way to critically reduce the time spent on the task is to use regexp pattern matching to only target files located at a specific depth level. Assuming you have a magic path pointing to your components folder and a project structure like this:

...
├── package.json
└── src
    └── components

You can refactor it by a simple find and replace:

find: from "components
replace: from "../components
files to include: ./src/*/**.ts

Then you just go recursively:

find: from "components
replace: from "../../components
files to include: ./src/*/*/**.ts

I wrote a small blog post about this: https://dev.to/fes300/refactoring-absolute-paths-to-relative-ones-in-vscode-3iaj