@ts-bridge/cli
Version:
Bridge the gap between ES modules and CommonJS modules with an easy-to-use alternative to `tsc`.
300 lines (299 loc) • 12.8 kB
JavaScript
import typescript from 'typescript';
import { getCommonJsExports, isCommonJs } from './module-resolver.js';
import { getDefinedArray, getIdentifierName } from './utils.js';
const { factory, isNamedExports, isNamedImports, isNamespaceImport, isStringLiteral, NodeFlags, SymbolFlags, SyntaxKind, } = typescript;
/**
* Check if a symbol name is unique in the scope of the given node.
*
* @param typeChecker - The type checker to use.
* @param node - The node to check.
* @param symbolName - The name of the symbol to check.
* @returns `true` if the symbol name is unique, or `false` otherwise.
*/
export function isUnique(typeChecker, node, symbolName) {
const symbols = typeChecker.getSymbolsInScope(node, SymbolFlags.All);
return (symbols.find((symbol) => symbol.escapedName === symbolName ||
symbol.escapedName === `_${symbolName}`) === undefined);
}
/**
* Check if a symbol is global.
*
* @param typeChecker - The type checker to use.
* @param node - The node to check.
* @param symbolName - The name of the symbol to check.
* @returns `true` if the symbol is global, or `false` otherwise.
*/
export function isGlobal(typeChecker, node, symbolName) {
const symbols = typeChecker.getSymbolsInScope(node, SymbolFlags.All);
const foundSymbol = symbols.find((symbol) => symbol.escapedName === symbolName ||
symbol.escapedName === `_${symbolName}`);
const declarations = getDefinedArray(foundSymbol?.getDeclarations());
for (const declaration of declarations) {
if (declaration.getSourceFile().isDeclarationFile) {
return true;
}
}
return false;
}
/**
* Get a unique identifier given a source file and (expected) name. This will
* try to get a name as close to the expected name as possible, and prepend an
* underscore if necessary.
*
* @param typeChecker - The type checker to use.
* @param sourceFile - The source file to get the unique identifier for.
* @param name - The (expected) name to use.
* @returns The unique identifier.
*/
export function getUniqueIdentifier(typeChecker, sourceFile, name) {
const escapedName = `$${name}`;
if (isUnique(typeChecker, sourceFile, escapedName)) {
return escapedName;
}
return getUniqueIdentifier(typeChecker, sourceFile, `_${name}`);
}
/**
* Get the detected and undetected imports for the given package specifier.
* This function uses `cjs-module-lexer` to parse the CommonJS module and
* extract the exports, and then compares the named imports to the exports to
* determine which imports are detected and which are not.
*
* @param packageSpecifier - The specifier for the package.
* @param system - The TypeScript system.
* @param parentUrl - The URL of the parent module.
* @param imports - The named imports from the import declaration.
* @returns The "exported" imports and other imports.
*/
export function getImports(packageSpecifier, system, parentUrl, imports) {
const exports = getCommonJsExports(packageSpecifier, system, parentUrl);
return imports.reduce((accumulator, element) => {
if (element.isTypeOnly) {
return accumulator;
}
const name = element.name.text;
const propertyName = element.propertyName?.text;
const exportName = propertyName ?? name;
if (exports.has(exportName)) {
return {
...accumulator,
detected: [...accumulator.detected, { name, propertyName }],
};
}
return {
...accumulator,
undetected: [...accumulator.undetected, { name, propertyName }],
};
}, { detected: [], undetected: [] });
}
/**
* Get the named import node(s) for the given import declaration. This function
* transforms named imports for CommonJS modules to a default import and a
* variable declaration, so that the named imports can be used in ES modules.
*
* For example, the following import from a CommonJS module:
* ```js
* import { namedImport1, namedImport2 } from 'some-module';
* ```
*
* will be transformed to:
* ```js
* import somemodule from 'some-module';
* const { namedImport1, namedImport2 } = somemodule;
* ```
*
* @param typeChecker - The type checker to use.
* @param sourceFile - The source file to use.
* @param node - The import declaration node.
* @param system - The compiler system to use.
* @returns The new node(s) for the named import.
*/
export function getNamedImportNodes(typeChecker, sourceFile, node, system) {
// If the import declaration does not have named bindings, return the node
// as is.
if (!node.importClause?.namedBindings) {
return node;
}
const { namedBindings } = node.importClause;
// If the named bindings are a namespace import, return the node as is.
if (isNamespaceImport(namedBindings)) {
return node;
}
// 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;
}
try {
const importNames = getImports(node.moduleSpecifier.text, system, sourceFile.fileName, namedBindings.elements);
// If there are no named imports, return the node as is.
if (importNames.detected.length === 0 &&
importNames.undetected.length === 0) {
return node;
}
// If there are no undetected imports, return the node as is.
if (importNames.undetected.length === 0) {
return node;
}
const moduleSpecifier = getIdentifierName(node.moduleSpecifier.text);
const importIdentifier =
// If the import declaration has a name (default import), use that name, to
// avoid breaking the default import transformer.
node.importClause.name?.text ??
getUniqueIdentifier(typeChecker, sourceFile, moduleSpecifier);
const statements = [];
if (importNames.detected.length > 0) {
// Create a new named import node for the detected imports.
const namedImport = factory.createImportDeclaration(node.modifiers, factory.createImportClause(false, undefined, factory.createNamedImports(importNames.detected.map(({ propertyName, name }) => factory.createImportSpecifier(false, propertyName
? factory.createIdentifier(propertyName)
: undefined, factory.createIdentifier(name))))), node.moduleSpecifier);
statements.push(namedImport);
}
// Create a new default import node.
const defaultImport = factory.createImportDeclaration(node.modifiers, factory.createImportClause(false, factory.createIdentifier(importIdentifier), undefined), node.moduleSpecifier);
// Create a variable declaration for the undetected import names.
const variableStatement = factory.createVariableStatement(undefined, factory.createVariableDeclarationList([
factory.createVariableDeclaration(factory.createObjectBindingPattern([
...importNames.undetected.map(({ propertyName, name }) => factory.createBindingElement(undefined, propertyName, name)),
]), undefined, undefined, factory.createIdentifier(importIdentifier)),
],
// eslint-disable-next-line no-bitwise
NodeFlags.Const));
statements.push(defaultImport, variableStatement);
return statements;
}
catch {
// If there is an error, return the node as is. This may occur if the module
// cannot be parsed by `cjs-module-lexer`, e.g., in the case of React
// Native, which resolves to a Flow file.
return node;
}
}
/* eslint-disable no-bitwise */
/**
* Check if the symbol is a type-only symbol.
*
* @param symbol - The symbol to check.
* @returns `true` if the symbol is a type-only symbol, or `false` otherwise.
*/
function isSymbolTypeOnly(symbol) {
return (Boolean(symbol.flags & SymbolFlags.Type) &&
!(symbol.flags & SymbolFlags.Value));
}
/* eslint-enable no-bitwise */
/**
* Check if the specifier is emittable, i.e., if it should be included in the
* emitted output.
*
* @param typeChecker - The type checker to use.
* @param specifier - The import or export specifier to check.
* @returns `true` if the specifier is emittable, or `false` otherwise.
*/
function isEmittable(typeChecker, specifier) {
if (specifier.isTypeOnly) {
return false;
}
const symbol = typeChecker.getSymbolAtLocation(specifier.name);
if (symbol) {
const originalSymbol = typeChecker.getAliasedSymbol(symbol);
return !isSymbolTypeOnly(originalSymbol);
}
return true;
}
/**
* Get the import declaration without type-only imports.
*
* @param typeChecker - The type checker to use.
* @param node - The import declaration node.
* @returns The import declaration without type-only imports. If there are no
* type-only imports, the node is returned as is.
*/
export function getNonTypeImports(typeChecker, node) {
if (!node.importClause?.namedBindings ||
!isNamedImports(node.importClause.namedBindings)) {
return node;
}
const elements = node.importClause.namedBindings.elements.filter((element) => isEmittable(typeChecker, element));
if (!node.importClause.name && elements.length === 0) {
return undefined;
}
return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(node.importClause, false, node.importClause.name, elements.length === 0
? undefined
: factory.updateNamedImports(node.importClause.namedBindings, elements)), node.moduleSpecifier, node.attributes);
}
/**
* Get the export declaration without type-only exports.
*
* @param typeChecker - The type checker to use.
* @param node - The export declaration node.
* @returns The export declaration without type-only exports. If there are no
* type-only exports, the node is returned as is.
*/
export function getNonTypeExports(typeChecker, node) {
if (!node.exportClause || !isNamedExports(node.exportClause)) {
return node;
}
const elements = node.exportClause.elements.filter((element) => isEmittable(typeChecker, element));
if (elements.length === 0) {
return undefined;
}
return factory.updateExportDeclaration(node, node.modifiers, false, factory.updateNamedExports(node.exportClause, elements), node.moduleSpecifier, node.attributes);
}
/**
* Create a namespace import, e.g.:
*
* ```
* import * as name from 'module';
* ```
*
* @param name - The name of the namespace import.
* @param module - The module to import.
* @returns The namespace import.
*/
export function getNamespaceImport(name, module) {
return factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamespaceImport(factory.createIdentifier(name))), factory.createStringLiteral(module), undefined);
}
/**
* Get `import.meta.url`. This is extracted into a function to reduce code
* duplication.
*
* @returns The `import.meta.url` expression.
*/
export function getImportMetaUrl() {
return factory.createPropertyAccessExpression(factory.createMetaProperty(SyntaxKind.ImportKeyword, factory.createIdentifier('meta')), factory.createIdentifier('url'));
}
/**
* Check if the TypeScript version supports import attributes.
*
* @returns `true` if the TypeScript version supports import attributes, or
* `false` otherwise.
*/
export function hasImportAttributes() {
return 'createImportAttributes' in factory;
}
/**
* Get the import attributes for the given name and value.
*
* This function supports older versions of TypeScript by using import
* assertions rather than import attributes, if necessary. Import assertions are
* deprecated though, so it is recommended to use a recent version of TypeScript
* that supports import attributes.
*
* @param name - The name of the attribute.
* @param value - The value of the attribute.
* @param useAttributes - Whether to use import attributes. By default, this is
* determined by the {@link hasImportAttributes} function.
* @returns The import attributes.
*/
export function getImportAttribute(name, value, useAttributes = hasImportAttributes()) {
if (useAttributes) {
return factory.createImportAttributes(factory.createNodeArray([
factory.createImportAttribute(factory.createIdentifier(name), factory.createStringLiteral(value)),
]));
}
return factory.createAssertClause(factory.createNodeArray([
factory.createAssertEntry(factory.createIdentifier(name), factory.createStringLiteral(value)),
]));
}