@ts-bridge/cli
Version:
Bridge the gap between ES modules and CommonJS modules with an easy-to-use alternative to `tsc`.
715 lines (714 loc) • 28.3 kB
JavaScript
import typescript from 'typescript';
import { getImportAttribute, getImportMetaUrl, getNamedImportNodes, getNonTypeExports, getNonTypeImports, getUniqueIdentifier, isGlobal, } from './generator.js';
import { getImportDefaultHelper } from './helpers.js';
import { getModulePath, getModuleType, isCommonJs } from './module-resolver.js';
import { getDirnameGlobalFunction, getDirnameHelperFunction, getFileUrlToPathHelperFunction, getImportMetaUrlFunction, getRequireHelperFunction, } from './shims.js';
const { factory, isBindingElement, isCallExpression, isExportDeclaration, isFunctionDeclaration, isIdentifier, isImportDeclaration, isMetaProperty, isPropertyAccessExpression, isStringLiteral, isVariableDeclaration, NodeFlags, visitEachChild, visitNode, SyntaxKind, } = typescript;
/**
* Get a transformer that updates the import extensions to append the given
* extension.
*
* For example, the following import declaration:
* ```js
* import { foo } from './foo.js';
* ```
*
* will be transformed to (assuming the extension is `.mjs`):
* ```js
* import { foo } from './foo.mjs';
* ```
*
* @param extension - The extension to append to import paths.
* @param options - The transformer options.
* @param options.system - The compiler system to use.
* @param options.verbose - Whether to enable verbose logging.
* @returns The transformer function.
*/
export function getImportExtensionTransformer(extension, { system, verbose }) {
return (context) => {
// This returns a custom transformer instead of a transformer factory, as
// this transformer is used for declaration files, which requires support
// for bundle transformations (even though we don't use it here).
return {
transformSourceFile(sourceFile) {
const visitor = (node) => {
if (node.parent &&
isStringLiteral(node) &&
isImportDeclaration(node.parent)) {
const importPath = getModulePath({
packageSpecifier: node.text,
parentUrl: sourceFile.fileName,
extension,
system,
verbose,
});
return factory.createStringLiteral(importPath);
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
},
/* istanbul ignore next 3 */
transformBundle(bundle) {
return bundle;
},
};
};
}
/**
* Get a transformer that updates the dynamic import extensions to append the
* given extension.
*
* For example, the following import declaration:
* ```js
* import('./foo.js');
* ```
*
* will be transformed to (assuming the extension is `.mjs`):
* ```js
* import('./foo.mjs');
* ```
*
* @param extension - The extension to append to import paths.
* @param options - The transformer options.
* @param options.system - The compiler system to use.
* @param options.verbose - Whether to enable verbose logging.
* @returns The transformer function.
*/
export function getDynamicImportExtensionTransformer(extension, { system, verbose }) {
return (context) => {
// This returns a custom transformer instead of a transformer factory, as
// this transformer is used for declaration files, which requires support
// for bundle transformations (even though we don't use it here).
return {
transformSourceFile(sourceFile) {
const visitor = (node) => {
if (node.parent &&
isStringLiteral(node) &&
isCallExpression(node.parent) &&
node.parent.expression.kind === SyntaxKind.ImportKeyword) {
const importPath = getModulePath({
packageSpecifier: node.text,
parentUrl: sourceFile.fileName,
extension,
system,
verbose,
});
return factory.createStringLiteral(importPath);
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
},
/* istanbul ignore next 3 */
transformBundle(bundle) {
return bundle;
},
};
};
}
/**
* Get a transformer that updates the `require` calls to use the given
* extension.
*
* For example, the following `require` call:
* ```js
* const foo = require('./foo.js');
* ```
*
* will be transformed to (assuming the extension is `.mjs`):
* ```js
* const foo = require('./foo.mjs');
* ```
*
* @param extension - The new extension for the `require` calls.
* @param options - The transformer options.
* @param options.typeChecker - The type checker to use.
* @param options.system - The compiler system to use.
* @param options.verbose - Whether to enable verbose logging.
* @returns The transformer function.
*/
export function getRequireExtensionTransformer(extension, { typeChecker, system, verbose }) {
return (context) => {
return (sourceFile) => {
const visitor = (node) => {
if (node.parent &&
isStringLiteral(node) &&
isCallExpression(node.parent) &&
isIdentifier(node.parent.expression) &&
node.parent.expression.text === 'require' &&
isGlobal(typeChecker, node, 'require')) {
const importPath = getModulePath({
packageSpecifier: node.text,
parentUrl: sourceFile.fileName,
extension,
system,
verbose,
});
return factory.createStringLiteral(importPath);
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
};
};
}
/**
* Get a transformer that updates the export extensions to append the given
* extension.
*
* For example, the following export declaration:
* ```js
* export * from './foo.js';
* ```
*
* will be transformed to (assuming the extension is `.mjs`):
* ```js
* export * from './foo.mjs';
* ```
*
* @param extension - The extension to append to export paths.
* @param options - The transformer options.
* @param options.system - The compiler system to use.
* @param options.verbose - Whether to enable verbose logging.
* @returns The transformer function.
*/
export function getExportExtensionTransformer(extension, { system, verbose }) {
return (context) => {
// This returns a custom transformer instead of a transformer factory, as
// this transformer is used for declaration files, which requires support
// for bundle transformations (even though we don't use it here).
return {
transformSourceFile(sourceFile) {
const visitor = (node) => {
if (node.parent &&
isStringLiteral(node) &&
isExportDeclaration(node.parent)) {
const importPath = getModulePath({
packageSpecifier: node.text,
parentUrl: sourceFile.fileName,
extension,
system,
verbose,
});
return factory.createStringLiteral(importPath);
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
},
/* istanbul ignore next 3 */
transformBundle(bundle) {
return bundle;
},
};
};
}
/**
* Get a transformer that updates `__filename`, `__dirname` to an ES-compatible
* version using `import.meta.url`.
*
* For example, the following statement:
* ```ts
* const foo = __filename;
* ```
*
* will be transformed to:
* ```ts
* function $__filename(url) {
* // ...;
* }
*
* const foo = $__filename(import.meta.url);
* ```
*
* This should only be used for the ES module target.
*
* @param options - The transformer options.
* @param options.typeChecker - The type checker to use.
* @returns The transformer function.
*/
export function getGlobalsTransformer({ typeChecker }) {
return (context) => {
return (sourceFile) => {
let insertFilenameShim = false;
let insertDirnameShim = false;
const dirnameHelperFunctionName = getUniqueIdentifier(typeChecker, sourceFile, 'getDirname');
const fileUrlToPathFunctionName = getUniqueIdentifier(typeChecker, sourceFile, '__filename');
const dirnameFunctionName = getUniqueIdentifier(typeChecker, sourceFile, '__dirname');
const visitor = (node) => {
if (isIdentifier(node) &&
node.text === '__filename' &&
!isVariableDeclaration(node.parent) &&
!isBindingElement(node.parent) &&
!isFunctionDeclaration(node.parent) &&
isGlobal(typeChecker, node, '__filename')) {
insertFilenameShim = true;
return factory.createCallExpression(factory.createIdentifier(fileUrlToPathFunctionName), undefined, [getImportMetaUrl()]);
}
if (isIdentifier(node) &&
node.text === '__dirname' &&
!isVariableDeclaration(node.parent) &&
!isBindingElement(node.parent) &&
!isFunctionDeclaration(node.parent) &&
isGlobal(typeChecker, node.parent, '__dirname')) {
insertDirnameShim = true;
return factory.createCallExpression(factory.createIdentifier(dirnameFunctionName), undefined, [getImportMetaUrl()]);
}
return visitEachChild(node, visitor, context);
};
const modifiedSourceFile = visitNode(sourceFile, visitor);
const statements = [];
if (insertDirnameShim) {
statements.push(getDirnameHelperFunction(dirnameHelperFunctionName));
statements.push(getDirnameGlobalFunction(dirnameFunctionName, fileUrlToPathFunctionName, dirnameHelperFunctionName));
}
if (insertFilenameShim || insertDirnameShim) {
statements.unshift(getFileUrlToPathHelperFunction(fileUrlToPathFunctionName));
return factory.updateSourceFile(modifiedSourceFile, [
...statements,
...modifiedSourceFile.statements,
]);
}
return modifiedSourceFile;
};
};
}
/**
* Get a transformer that updates `require` to an ES-compatible version using
* `createRequire` and `import.meta.url`.
*
* For example, the following statement:
* ```ts
* const foo = require('bar');
* ```
*
* will be transformed to:
* ```ts
* function require(path, url) {
* // ...;
* }
*
* const foo = require('bar', import.meta.url);
* ```
*
* This should only be used for the ES module target.
*
* @param options - The transformer options.
* @param options.typeChecker - The type checker to use.
* @returns The transformer function.
*/
export const getRequireTransformer = ({ typeChecker }) => {
return (context) => {
return (sourceFile) => {
let insertShim = false;
const requireFunctionName = getUniqueIdentifier(typeChecker, sourceFile, 'require');
const createRequireFunctionName = getUniqueIdentifier(typeChecker, sourceFile, 'createRequire');
const visitor = (node) => {
if (isCallExpression(node) &&
isIdentifier(node.expression) &&
node.expression.text === 'require' &&
isGlobal(typeChecker, node, 'require') &&
node.arguments[0]) {
insertShim = true;
return factory.createCallExpression(factory.createIdentifier(requireFunctionName), undefined, [node.arguments[0]]);
}
if (isCallExpression(node) &&
isPropertyAccessExpression(node.expression) &&
isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'require' &&
node.expression.name.text === 'resolve' &&
isGlobal(typeChecker, node, 'require') &&
node.arguments[0]) {
insertShim = true;
return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier(requireFunctionName), 'resolve'), undefined, [node.arguments[0]]);
}
return visitEachChild(node, visitor, context);
};
const modifiedSourceFile = visitNode(sourceFile, visitor);
if (insertShim) {
return factory.updateSourceFile(modifiedSourceFile, [
...getRequireHelperFunction(requireFunctionName, createRequireFunctionName),
...modifiedSourceFile.statements,
]);
}
return modifiedSourceFile;
};
};
};
/**
* Get a transformer that updates `import.meta.url` to a CommonJS-compatible
* version using `__filename`.
*
* For example, the following statement:
* ```ts
* const foo = import.meta.url;
* ```
*
* will be transformed to:
* ```ts
* function getImportMetaUrl(filename) {
* // ...;
* }
*
* const foo = getImportMetaUrl(__filename);
* ```
*
* This should only be used for the CommonJS target.
*
* @param options - The transformer options.
* @param options.typeChecker - The type checker to use.
* @returns The transformer function.
*/
export function getImportMetaTransformer({ typeChecker }) {
return (context) => {
return (sourceFile) => {
let insertShim = false;
const functionName = getUniqueIdentifier(typeChecker, sourceFile, 'getImportMetaUrl');
const visitor = (node) => {
if (isPropertyAccessExpression(node) &&
isMetaProperty(node.expression) &&
node.expression.keywordToken === SyntaxKind.ImportKeyword &&
node.name.text === 'url') {
insertShim = true;
return factory.createCallExpression(factory.createIdentifier(functionName), undefined, [factory.createIdentifier('__filename')]);
}
return visitEachChild(node, visitor, context);
};
const modifiedSourceFile = visitNode(sourceFile, visitor);
if (insertShim) {
return factory.updateSourceFile(modifiedSourceFile, [
getImportMetaUrlFunction(functionName),
...modifiedSourceFile.statements,
]);
}
return modifiedSourceFile;
};
};
}
/**
* Get a transformer that updates the default imports to use the `importDefault`
* helper function for CommonJS modules.
*
* For example, the following default import:
*
* ```ts
* import foo from 'module';
* ```
*
* will be transformed to:
*
* ```ts
* function $importDefault(module) {
* // ...;
* }
*
* import $foo from 'module';
* const foo = $importDefault($foo);
* ```
*
* @param options - The transformer options.
* @param options.typeChecker - The type checker to use.
* @param options.system - The compiler system to use.
* @returns The transformer function.
*/
export function getDefaultImportTransformer({ typeChecker, system, }) {
return (context) => {
return (sourceFile) => {
let insertShim = false;
const importDefaultFunctionName = getUniqueIdentifier(typeChecker, sourceFile, 'importDefault');
const visitor = (node) => {
if (isImportDeclaration(node) && node.importClause?.name) {
// If the module specifier is not a string literal, return the node as is.
if (!isStringLiteral(node.moduleSpecifier)) {
return node;
}
// If the module specifier is not a CommonJS module, return the node as is.
if (!isCommonJs(node.moduleSpecifier.text, system, sourceFile.fileName)) {
return node;
}
insertShim = true;
const name = getUniqueIdentifier(typeChecker, sourceFile, node.importClause.name.text);
const importDeclaration = factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(node.importClause, node.importClause.isTypeOnly, factory.createIdentifier(name), node.importClause.namedBindings), node.moduleSpecifier, node.attributes);
const variableDeclaration = factory.createVariableStatement(undefined, factory.createVariableDeclarationList([
factory.createVariableDeclaration(factory.createIdentifier(node.importClause.name.text), undefined, undefined, factory.createCallExpression(factory.createIdentifier(importDefaultFunctionName), undefined, [factory.createIdentifier(name)])),
],
// eslint-disable-next-line no-bitwise
NodeFlags.Const));
return [importDeclaration, variableDeclaration];
}
return visitEachChild(node, visitor, context);
};
const modifiedSourceFile = visitNode(sourceFile, visitor);
if (insertShim) {
return factory.updateSourceFile(modifiedSourceFile, [
getImportDefaultHelper(importDefaultFunctionName),
...modifiedSourceFile.statements,
]);
}
return modifiedSourceFile;
};
};
}
/**
* Get a transformer that updates the named imports. This updates the imports to
* use a default import, and destructures the imports from the default import.
*
* For example, the following import (assuming the module is a CommonJS module):
* ```ts
* import { foo, bar } from 'module';
* ```
*
* will be transformed to:
* ```ts
* import module from 'module';
* const { foo, bar } = module;
* ```
*
* @param options - The transformer options.
* @param options.typeChecker - The type checker to use.
* @param options.system - The compiler system to use.
* @returns The transformer function.
*/
export function getNamedImportTransformer({ typeChecker, system, }) {
return (context) => {
return (sourceFile) => {
const visitor = (node) => {
if (isImportDeclaration(node)) {
if (node.importClause?.isTypeOnly) {
// If the import is type-only, we need to return `undefined` to
// avoid TypeScript 4.x from including the import in the output.
return undefined;
}
return getNamedImportNodes(typeChecker, sourceFile, node, system);
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
};
};
}
/**
* Get a transformer that removes type-only imports and exports. This is the
* default behaviour for TypeScript when invoked from the command line, but does
* not seem to be the default behaviour when using the TypeScript API.
*
* For example, the following type-only imports and exports:
* ```ts
* import type { Foo } from 'module';
* export type { Foo };
* ```
*
* will be removed.
*
* @param context - The transformer options.
* @param context.typeChecker - The type checker to use.
* @returns The transformer function.
*/
export function getTypeImportExportTransformer({ typeChecker, }) {
return (context) => {
return (sourceFile) => {
const visitor = (node) => {
if (isImportDeclaration(node)) {
if (node.importClause?.isTypeOnly) {
return undefined;
}
return getNonTypeImports(typeChecker, node);
}
if (isExportDeclaration(node)) {
if (node.isTypeOnly) {
return undefined;
}
return getNonTypeExports(typeChecker, node);
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
};
};
}
/**
* Get a transformer that adds an import attribute to the given module type with
* the given type attribute. This is mainly useful for JSON imports, which
* require `with { type: 'json' }` to be added to the import statement.
*
* @param options - The import attribute options.
* @param options.moduleType - The type of the module, i.e., CommonJS or ES
* module, to apply the transformation to.
* @param options.type - The import assertion type to apply.
* @param context - The transformer options.
* @param context.system - The compiler system to use.
* @returns The transformer function.
*/
export function getImportAttributeTransformer(options, { system }) {
return (context) => {
return (sourceFile) => {
const visitor = (node) => {
if (node &&
isImportDeclaration(node) &&
isStringLiteral(node.moduleSpecifier)) {
const type = getModuleType(node.moduleSpecifier.text, system, sourceFile.fileName);
if (type === options.moduleType) {
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, node.moduleSpecifier, getImportAttribute('type', options.type));
}
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
};
};
}
/**
* Get a transformer that removes any import attributes from the import
* declarations.
*
* This is useful in CommonJS environments where import attributes are not
* supported.
*
* @param _context - The transformer options. This is not used.
* @returns The transformer function.
*/
export function getRemoveImportAttributeTransformer(_context) {
return (context) => {
return (sourceFile) => {
const visitor = (node) => {
if (node &&
isImportDeclaration(node) &&
(node.attributes || node.assertClause)) {
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, node.moduleSpecifier, undefined);
}
return visitEachChild(node, visitor, context);
};
return visitNode(sourceFile, visitor);
};
};
}
/**
* Get a custom transformer that sets the target module kind for the source
* file.
*
* @param impliedNodeFormat - The target module kind, i.e., ES module or
* CommonJS module.
* @returns The custom transformer function.
*/
export function getTargetTransformer(impliedNodeFormat) {
return () => {
return (sourceFile) => {
return {
...sourceFile,
impliedNodeFormat,
};
};
};
}
/**
* Update the source map path to match the new file extension of the source
* file.
*
* Source maps contain a `file` property that points to the original source
* file. When the source file extension is changed, the source map file path
* should be updated to match the new extension.
*
* @param sourceMap - The source map JSON string.
* @param extension - The new file extension.
* @returns The updated source map JSON string.
*/
export function transformSourceMap(sourceMap, extension) {
const sourceMapObject = JSON.parse(sourceMap);
const updatedSourceMapObject = {
...sourceMapObject,
file: sourceMapObject.file.replace(/\.(?:js|d\.ts)$/u, extension),
};
return JSON.stringify(updatedSourceMapObject);
}
/**
* Transform the source map URL to match the new file extension of the source
* file.
*
* @param content - The content of the source file.
* @param extension - The new file extension.
* @param declarationExtension - The new file extension for declaration files.
* @returns The transformed content.
*/
function transformSourceMapUrl(content, extension, declarationExtension) {
// This is a bit hacky, but TypeScript doesn't provide a way to transform
// the source map comment in the source file.
return content
.replace(/^\/\/# sourceMappingURL=(.*)\.js\.map$/mu, `//# sourceMappingURL=$1${extension}.map`)
.replace(/^\/\/# sourceMappingURL=(.*)\.d\.ts\.map$/mu, `//# sourceMappingURL=$1${declarationExtension}.map`);
}
/**
* The regular expression to match dynamic imports. The aim of `ts-bridge` is to
* avoid regular expressions as much as possible, but this is a special case
* where we need to match dynamic imports created by the TypeScript compiler,
* after AST transformation.
*
* This regular expression matches dynamic imports, but only if they are not
* part of a larger identifier. This is to avoid matching `import.meta.url`,
* `foo.import`, etc.
*
* The following imports will be matched:
*
* ```ts
* import('./foo.js')
* import('./foo/bar.js').SomeClass
* ```
*
* The following imports will not be matched:
*
* ```ts
* import.meta.url
* foo.import('./foo.js')
* _import('./foo.js')
* ```
*/
const DYNAMIC_IMPORT_REGEX = /(?<![\w.])import\(['"](\..+?)['"]\)/gu;
/**
* Transform the dynamic imports in the declaration file to use the new file
* extension.
*
* @param content - The content of the declaration file.
* @param extension - The new file extension.
* @param parentUrl - The URL of the parent module.
* @param system - The TypeScript system.
* @param verbose - Whether to show verbose output.
* @returns The transformed content.
*/
export function transformDeclarationImports(content, extension, parentUrl, system, verbose) {
return content.replace(DYNAMIC_IMPORT_REGEX, (match, path) => {
const importPath = getModulePath({
packageSpecifier: path,
parentUrl,
extension,
system,
verbose,
});
return match.replace(path, importPath);
});
}
/**
* Transform the file content to update the source map path or the source file
* extension.
*
* @param fileName - The name of the file.
* @param sourceFileName - The name of the source file.
* @param content - The content of the source file.
* @param extension - The new file extension.
* @param declarationExtension - The new file extension for declaration files.
* @param system - The TypeScript system.
* @param verbose - Whether to show verbose output.
* @returns The transformed content.
*/
export function transformFile(fileName, sourceFileName, content, extension, declarationExtension, system, verbose) {
if (fileName.endsWith('.d.ts.map')) {
return transformSourceMap(content, declarationExtension);
}
if (fileName.endsWith('.map')) {
return transformSourceMap(content, extension);
}
const updatedContent = transformSourceMapUrl(content, extension, declarationExtension);
if (fileName.endsWith('.d.ts')) {
return transformDeclarationImports(updatedContent, extension, sourceFileName, system, verbose);
}
return updatedContent;
}