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)
// 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:
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