Skip to content

Commit

Permalink
feat: Convert dynamic imports
Browse files Browse the repository at this point in the history
  • Loading branch information
bennycode committed Feb 23, 2025
1 parent dba71ac commit 318df63
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 108 deletions.
9 changes: 8 additions & 1 deletion src/converter/convertFile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {SourceFile} from 'ts-morph';
import {addFileExtensions} from './replacer/addFileExtensions.js';
import {replaceModuleExports} from './replacer/replaceModuleExports.js';
import {replaceRequiresAndShebang} from './replacer/replaceRequire.js';
import {replaceRequiresAndShebang} from './replacer/replaceRequiresAndShebang.js';
import {replaceDynamicImports} from './replacer/replaceDynamicImports.js';

/**
* Returns the source file ONLY if it was modified.
Expand All @@ -15,6 +16,12 @@ export function convertFile(sourceFile: SourceFile) {
madeChanges = true;
}

// Update "await import" statements
const updatedDynamicImports = replaceDynamicImports(sourceFile);
if (updatedDynamicImports) {
madeChanges = true;
}

// Update "module.exports" statements to "export" statements
const replacedModuleExports = replaceModuleExports(sourceFile);
if (replacedModuleExports) {
Expand Down
73 changes: 1 addition & 72 deletions src/converter/replacer/addFileExtensions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import {SourceFile, SyntaxKind} from 'ts-morph';
import {ProjectUtil} from '../../util/ProjectUtil.js';
import {StringLiteral} from 'ts-morph';
import {ModuleInfo, parseInfo} from '../../parser/InfoParser.js';
import {toImport, toImportAttribute} from '../ImportConverter.js';
import path from 'node:path';
import {PathFinder} from '../../util/PathFinder.js';
import {getNormalizedPath, isNodeModuleRoot} from '../../util/PathUtil.js';
import {replaceModulePath} from '../../util/replaceModulePath.js';

export function addFileExtensions(sourceFile: SourceFile, type: 'import' | 'export') {
let madeChanges: boolean = false;
Expand All @@ -31,68 +25,3 @@ export function addFileExtensions(sourceFile: SourceFile, type: 'import' | 'expo

return madeChanges;
}

export function replaceModulePath({
hasAttributesClause,
stringLiteral,
sourceFile,
}: {
hasAttributesClause: boolean;
stringLiteral: StringLiteral;
sourceFile: SourceFile;
}) {
const paths = ProjectUtil.getPaths(sourceFile.getProject());
const tsConfigFilePath = ProjectUtil.getTsConfigFilePath(sourceFile);
const projectDirectory = ProjectUtil.getRootDirectory(tsConfigFilePath);
const info = parseInfo(sourceFile.getFilePath(), stringLiteral, paths);
const replacement = createReplacementPath({hasAttributesClause, info, paths, projectDirectory});
if (replacement) {
stringLiteral.replaceWithText(replacement);
return true;
}
return false;
}

function createReplacementPath({
hasAttributesClause,
info,
paths,
projectDirectory,
}: {
hasAttributesClause: boolean;
info: ModuleInfo;
paths: Record<string, string[]> | undefined;
projectDirectory: string;
}) {
if (hasAttributesClause) {
return null;
}

const comesFromPathAlias = !!info.pathAlias && !!paths;
const isNodeModulesPath = !info.isRelative && info.normalized.includes('/') && !comesFromPathAlias;
if (info.isRelative || comesFromPathAlias || isNodeModulesPath) {
if (['.json', '.css'].includes(info.extension)) {
return toImportAttribute(info);
}

// If an import does not have a file extension or isn't an extension recognized here and can't be found locally (perhaps
// file had . in name), try to find a matching file by traversing through all valid TypeScript source file extensions.
let baseFilePath = comesFromPathAlias
? getNormalizedPath(projectDirectory, info, paths)
: path.join(info.directory, info.normalized);
if (isNodeModulesPath) {
baseFilePath = path.join(projectDirectory, 'node_modules', info.normalized);
}

const foundPath = PathFinder.findPath(baseFilePath, info.extension);
if (foundPath) {
// TODO: Write test case for this condition, mock "path" and "fs" calls if necessary
if (foundPath.extension === '/index.js' && isNodeModuleRoot(baseFilePath)) {
// @fixes https://github.com/bennycode/ts2esm/issues/81#issuecomment-2437503011
return null;
}
return toImport({...info, extension: foundPath.extension});
}
}
return null;
}
29 changes: 29 additions & 0 deletions src/converter/replacer/replaceDynamicImports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {SourceFile, SyntaxKind} from 'ts-morph';
import {replaceModulePath} from '../../util/replaceModulePath.js';

export function replaceDynamicImports(sourceFile: SourceFile) {
let madeChanges: boolean = false;

sourceFile.getVariableStatements().forEach(statement => {
statement.getDeclarations().forEach(declaration => {
const initializer = declaration.getInitializerIfKind(SyntaxKind.AwaitExpression);
const callExpression = initializer?.getExpressionIfKind(SyntaxKind.CallExpression);
const importExpression = callExpression?.getExpressionIfKind(SyntaxKind.ImportKeyword);
if (importExpression) {
const literals = initializer?.getDescendantsOfKind(SyntaxKind.StringLiteral);
literals?.forEach(stringLiteral => {
const adjustedImport = replaceModulePath({
hasAttributesClause: false,
sourceFile,
stringLiteral,
});
if (adjustedImport) {
madeChanges = true;
}
});
}
});
});

return madeChanges;
}
Original file line number Diff line number Diff line change
@@ -1,31 +1,5 @@
import {SourceFile, SyntaxKind, VariableStatement} from 'ts-morph';
import {NodeUtil} from '../../util/NodeUtil.js';
import {replaceModulePath} from './addFileExtensions.js';

function replaceDynamicImport(sourceFile: SourceFile, statement: VariableStatement) {
let madeChanges: boolean = false;

statement.getDeclarations().forEach(declaration => {
const initializer = declaration.getInitializerIfKind(SyntaxKind.AwaitExpression);
const callExpression = initializer?.getExpressionIfKind(SyntaxKind.CallExpression);
const importExpression = callExpression?.getExpressionIfKind(SyntaxKind.ImportKeyword);
if (importExpression) {
const literals = initializer?.getDescendantsOfKind(SyntaxKind.StringLiteral);
literals?.forEach(stringLiteral => {
const adjustedImport = replaceModulePath({
hasAttributesClause: false,
sourceFile,
stringLiteral,
});
if (adjustedImport) {
madeChanges = true;
}
});
}
});

return madeChanges;
}

/**
* Replaces a CommonJS require statement with an ESM import declaration.
Expand Down Expand Up @@ -86,7 +60,7 @@ export function replaceRequiresAndShebang(sourceFile: SourceFile) {
const hasShebang = firstStatement && firstStatement?.getFullText().startsWith('#!');
let shebangText = '';
if (hasShebang) {
// The full text contains both comments and the following statment,
// The full text contains both comments and the following statement,
// so we are separating the statement into comments and the instruction that follow on the next line.
const {statement: lineAfterShebang, comment} = NodeUtil.extractComment(firstStatement);
shebangText = comment;
Expand All @@ -96,20 +70,15 @@ export function replaceRequiresAndShebang(sourceFile: SourceFile) {
sourceFile.insertStatements(index, lineAfterShebang);
}

// TODO: Traverse statements, make changes, save changes and ONLY after that, proceed with the next statements...
sourceFile.getVariableStatements().forEach(statement => {
try {
const updatedRequire = replaceRequire(sourceFile, statement);
if (updatedRequire) {
return (madeChanges = true);
}

const updatedDynamicImport = replaceDynamicImport(sourceFile, statement);
if (updatedDynamicImport) {
return (madeChanges = true);
if (updatedRequire) {
madeChanges = true;
}

return false;
return madeChanges;
} catch (error: unknown) {
console.error(` There was an issue with "${sourceFile.getFilePath()}":`, error);
return false;
Expand Down
72 changes: 72 additions & 0 deletions src/util/replaceModulePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {SourceFile, StringLiteral} from 'ts-morph';
import {ModuleInfo, parseInfo} from '../parser/InfoParser.js';
import {ProjectUtil} from './ProjectUtil.js';
import {toImport, toImportAttribute} from '../converter/ImportConverter.js';
import {getNormalizedPath, isNodeModuleRoot} from './PathUtil.js';
import path from 'node:path';
import {PathFinder} from './PathFinder.js';

export function replaceModulePath({
hasAttributesClause,
stringLiteral,
sourceFile,
}: {
hasAttributesClause: boolean;
stringLiteral: StringLiteral;
sourceFile: SourceFile;
}) {
const paths = ProjectUtil.getPaths(sourceFile.getProject());
const tsConfigFilePath = ProjectUtil.getTsConfigFilePath(sourceFile);
const projectDirectory = ProjectUtil.getRootDirectory(tsConfigFilePath);
const info = parseInfo(sourceFile.getFilePath(), stringLiteral, paths);
const replacement = createReplacementPath({hasAttributesClause, info, paths, projectDirectory});
if (replacement) {
stringLiteral.replaceWithText(replacement);
return true;
}
return false;
}

function createReplacementPath({
hasAttributesClause,
info,
paths,
projectDirectory,
}: {
hasAttributesClause: boolean;
info: ModuleInfo;
paths: Record<string, string[]> | undefined;
projectDirectory: string;
}) {
if (hasAttributesClause) {
return null;
}

const comesFromPathAlias = !!info.pathAlias && !!paths;
const isNodeModulesPath = !info.isRelative && info.normalized.includes('/') && !comesFromPathAlias;
if (info.isRelative || comesFromPathAlias || isNodeModulesPath) {
if (['.json', '.css'].includes(info.extension)) {
return toImportAttribute(info);
}

// If an import does not have a file extension or isn't an extension recognized here and can't be found locally (perhaps
// file had . in name), try to find a matching file by traversing through all valid TypeScript source file extensions.
let baseFilePath = comesFromPathAlias
? getNormalizedPath(projectDirectory, info, paths)
: path.join(info.directory, info.normalized);
if (isNodeModulesPath) {
baseFilePath = path.join(projectDirectory, 'node_modules', info.normalized);
}

const foundPath = PathFinder.findPath(baseFilePath, info.extension);
if (foundPath) {
// TODO: Write test case for this condition, mock "path" and "fs" calls if necessary
if (foundPath.extension === '/index.js' && isNodeModuleRoot(baseFilePath)) {
// @fixes https://github.com/bennycode/ts2esm/issues/81#issuecomment-2437503011
return null;
}
return toImport({...info, extension: foundPath.extension});
}
}
return null;
}

0 comments on commit 318df63

Please sign in to comment.