Skip to content

Commit

Permalink
feat: add support for adding and customizing Import Assertions
Browse files Browse the repository at this point in the history
  • Loading branch information
wessberg committed May 29, 2022
1 parent efdbd3b commit 6bf8056
Show file tree
Hide file tree
Showing 14 changed files with 214 additions and 34 deletions.
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,18 @@ $ npx -p typescript -p cjstoesm cjstoesm

`cjstoesm` requires Node.js v14.19.0 or newer to function correctly.

## File extension handling

The default behavior is to add file extensions to module specifiers to align with the implementation in [node.js](https://nodejs.org/dist/latest/docs/api/esm.html#esm_mandatory_file_extensions) and across browsers.

You can customize this with the `--preserve-module-specifiers` command line option, or with the `preserveModuleSpecifiers` API option. See the [API Options](#api-options) for documentation for the possible values you can pass to it.

## Import Assertion handling

The default behavior is to add Import Assertions to Import Declarations when necessary and relevant, such as for when referencing JSON files. This aligns with the implementation in [node.js](https://nodejs.org/dist/latest/docs/api/esm.html#import-assertions) and across browsers.

You can customize this with the `--import-assertions` command line option, or with the `importAssertions` API option. See the [API Options](#api-options) for documentation for the possible values you can pass to it.

<!-- SHADOW_SECTION_USAGE_START -->

## Usage
Expand Down Expand Up @@ -273,13 +285,11 @@ Options:
-s, --silent [arg] Whether to not print anything
-c, --cwd [arg] Optionally which directory to use as the current working directory
-p, --preserve-module-specifiers [arg] Determines whether or not module specifiers are preserved. Possible values are: "external", "internal", "always", and "never" (default: "external")
-a, --import-assertions [arg] Determines whether or not Import Assertions are included where they are relevant. Possible values are: true and false (default: true)
-m, --dry [arg] If true, no files will be written to disk
-h, --help display help for command
```

The default behavior is to add file extensions to module specifiers to align with the implementation in [node.js](https://nodejs.org/dist/latest-v12.x/docs/api/esm.html#esm_mandatory_file_extensions) and across browsers.
You can customize this with the `--preserve-module-specifiers` command line option. See the [API Options](#api-options) for documentation for the possible values you can pass for it.

### API Usage

You can also use this library programmatically:
Expand Down Expand Up @@ -348,6 +358,15 @@ interface TransformOptions {
* It can also take a function that is invoked with a module specifier and returns a boolean determining whether or not it should be preserved
*/
preserveModuleSpecifiers: "always" | "never" | "external" | "internal" | ((specifier: string) => boolean);

/**
* Determines whether or not to include import assertions when converting require() calls referencing JSON files to ESM.
* - true (default): Import assertions will always be added when relevant.
* - false: Import assertions will never be added.
* It can also take a function that is invoked with a module specifier and returns a boolean determining whether or not an import assertion should be added
*/
importAssertions: boolean | ((specifier: string) => boolean);

/**
* If given, a specific TypeScript version to use
*/
Expand Down Expand Up @@ -526,6 +545,7 @@ You can provide options to the `cjsToEsm` Custom Transformer to configure its be
| `debug` _(optional)_ | If `true`, errors will be thrown if unexpected or unhandled cases are encountered. Additionally, debugging information will be printed during transpilation. |
| `fileSystem` _(optional)_ | If given, the file system to use. Useful if you are using `cjstoesm` inside a virtual file system. |
| `preserveModuleSpecifiers` _(optional)_ | Determines whether or not module specifiers are preserved. Possible values are: "external", "internal", "always", and "never". See [API options](#api-options) for more details |
| `importAssertions` _(optional)_ | Determines whether or not Import Assertions are included where relevant. Possible values are: true and false. See [API options](#api-options) for more details |
| `typescript` _(optional)_ | If given, the TypeScript version to use internally for all operations. |
| `cwd` _(optional)_ | The directory to use as the current working directory. |

Expand Down
6 changes: 6 additions & 0 deletions src/cli/command/transform/inject-transform-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export function injectTransformCommand(options: InjectCommandOptions): void {
defaultValue: "external",
description: `Determines whether or not module specifiers are preserved. Possible values are: "external", "internal", "always", and "never"`
},
"import-assertions": {
shortHand: "a",
type: "boolean",
defaultValue: true,
description: `Determines whether or not Import Assertions are included where they are relevant. Possible values are: true and false`
},
dry: {
shortHand: "m",
type: "boolean",
Expand Down
4 changes: 2 additions & 2 deletions src/cli/task/transform/transform-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import {TEMPORARY_SUBFOLDER_NAME} from "../../../shared/constant.js";
* Executes the 'generate' task
*/
export async function transformTask(options: TransformTaskOptions): Promise<TransformResult> {
let {logger, input, cwd, outDir, fileSystem, write, typescript, debug, preserveModuleSpecifiers, hooks} = options;
let {logger, input, cwd, outDir, fileSystem, write, typescript, debug, preserveModuleSpecifiers, importAssertions, hooks} = options;

logger.debug(
"Options:",
inspect(
{input, outDir, cwd, write, debug, preserveModuleSpecifiers},
{input, outDir, cwd, write, debug, preserveModuleSpecifiers, importAssertions},
{
colors: true,
depth: Infinity,
Expand Down
2 changes: 2 additions & 0 deletions src/shared/task/create-task-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function createTaskOptions({
debug = false,
cwd = process.cwd(),
preserveModuleSpecifiers = "external",
importAssertions = true,
logger = new Logger(debug !== false ? LogLevelKind.DEBUG : LogLevelKind.NONE)
}: Partial<TaskOptions> = {}): TaskOptions {
return {
Expand All @@ -18,6 +19,7 @@ export function createTaskOptions({
debug,
cwd,
preserveModuleSpecifiers,
importAssertions,
logger
};
}
8 changes: 8 additions & 0 deletions src/shared/task/task-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export interface TaskOptions {
*/
preserveModuleSpecifiers: "always" | "never" | "external" | "internal" | ((specifier: string) => boolean);

/**
* Determines whether or not to include import assertions when converting require() calls referencing JSON files to ESM.
* - true (default): Import assertions will always be added when relevant.
* - false: Import assertions will never be added.
* It can also take a function that is invoked with a module specifier and returns a boolean determining whether or not an import assertion should be added
*/
importAssertions: boolean | ((specifier: string) => boolean);

/**
* If given, a specific TypeScript version to use
*/
Expand Down
1 change: 1 addition & 0 deletions src/transformer/module-exports/module-exports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface ModuleExports {
namedExports: Set<string>;
hasDefaultExport: boolean;
assert?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {IsRequireCallResult} from "./is-require-call.js";
import {ModuleExports} from "../module-exports/module-exports.js";
import {BUILT_IN_MODULE_MAP, isBuiltInModule} from "../built-in/built-in-module-map.js";
import {BeforeVisitorContext} from "../visitor/before-visitor-context.js";
import {isJsonModule} from "./path-util.js";

/**
* Tries to get or potentially parse module exports based on the given data in the given context
Expand Down Expand Up @@ -30,18 +31,27 @@ export function getModuleExportsFromRequireDataInContext(data: IsRequireCallResu

// Otherwise, if we could resolve a module, try to get the exports for it
else if (resolvedModuleSpecifier != null) {
// Try to get the ModuleExports for the resolved module, if we know them already
moduleExports = context.getModuleExportsForPath(resolvedModuleSpecifier);

// If that wasn't possible, generate a new SourceFile and parse it
if (moduleExports == null && resolvedModuleSpecifierText != null) {
moduleExports = context.transformSourceFile(
typescript.createSourceFile(resolvedModuleSpecifier, resolvedModuleSpecifierText, typescript.ScriptTarget.ESNext, true, typescript.ScriptKind.TS),
{
...context,
onlyExports: true
}
).exports;
// Treat JSON modules as ones with a single default export
if (isJsonModule(resolvedModuleSpecifier)) {
moduleExports = {
assert: "json",
hasDefaultExport: true,
namedExports: new Set()
};
} else {
// Try to get the ModuleExports for the resolved module, if we know them already
moduleExports = context.getModuleExportsForPath(resolvedModuleSpecifier);

// If that wasn't possible, generate a new SourceFile and parse it
if (moduleExports == null && resolvedModuleSpecifierText != null) {
moduleExports = context.transformSourceFile(
typescript.createSourceFile(resolvedModuleSpecifier, resolvedModuleSpecifierText, typescript.ScriptTarget.ESNext, true, typescript.ScriptKind.TS),
{
...context,
onlyExports: true
}
).exports;
}
}
}
return moduleExports;
Expand Down
20 changes: 20 additions & 0 deletions src/transformer/util/maybe-generate-assert-clause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {TS} from "../../type/ts.js";
import {VisitorContext} from "../visitor-context.js";

export function maybeGenerateAssertClause(context: VisitorContext, moduleSpecifier: string, assert?: string): TS.AssertClause | undefined {
if (assert == null) return undefined;

const {factory, importAssertions} = context;

if (importAssertions === false || (typeof importAssertions === "function" && !importAssertions(moduleSpecifier))) {
return undefined;
}

if (!("createAssertClause" in context.typescript.factory)) {
context.logger.warn(
`The current version of TypeScript (v${context.typescript.version}) does not support Import Assertions. No Import Assertion will be added for the module with specifier '${moduleSpecifier}' in the transformed code. To remove this warning, either disable import assertions or update to TypeScript v4.5 or newer.`
);
}

return factory.createAssertClause(factory.createNodeArray([factory.createAssertEntry(factory.createIdentifier("type"), factory.createStringLiteral(assert))]));
}
4 changes: 4 additions & 0 deletions src/transformer/util/path-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ export function setExtension(file: string, extension: string): string {
export function isExternalLibrary(p: string): boolean {
return !p.startsWith(".") && !p.startsWith("/");
}

export function isJsonModule (p: string): boolean {
return p.endsWith(`.json`);
}
55 changes: 43 additions & 12 deletions src/transformer/visitor/visit/visit-call-expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ import {getModuleExportsFromRequireDataInContext} from "../../util/get-module-ex
import {TS} from "../../../type/ts.js";
import {shouldDebug} from "../../util/should-debug.js";
import {walkThroughFillerNodes} from "../../util/walk-through-filler-nodes.js";
import {maybeGenerateAssertClause} from "../../util/maybe-generate-assert-clause.js";

/**
* Visits the given CallExpression
*
* @param options
* @returns
*/
export function visitCallExpression({node, childContinuation, sourceFile, context}: BeforeVisitorOptions<TS.CallExpression>): TS.VisitResult<TS.Node> {
if (context.onlyExports) {
Expand Down Expand Up @@ -57,7 +55,16 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex
if (expressionStatementParent != null) {
// Only add the import if there isn't already an import within the SourceFile of the entire module without any bindings
if (!context.isModuleSpecifierImportedWithoutLocals(moduleSpecifier)) {
context.addImport(factory.createImportDeclaration(undefined, undefined, undefined, factory.createStringLiteral(transformedModuleSpecifier)), moduleSpecifier);
context.addImport(
factory.createImportDeclaration(
undefined,
undefined,
undefined,
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
}

// Drop this CallExpression
Expand All @@ -76,7 +83,16 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex

const importClause = factory.createImportClause(false, identifier, undefined);

context.addImport(factory.createImportDeclaration(undefined, undefined, importClause, factory.createStringLiteral(transformedModuleSpecifier)), moduleSpecifier);
context.addImport(
factory.createImportDeclaration(
undefined,
undefined,
importClause,
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);

// Replace the CallExpression by the identifier
return identifier;
Expand Down Expand Up @@ -139,7 +155,8 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex
factory.createImportClause(false, identifier, undefined)
: // Otherwise, import the entire namespace
factory.createImportClause(false, undefined, factory.createNamespaceImport(identifier)),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand Down Expand Up @@ -187,7 +204,16 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex

const importClause = factory.createImportClause(false, undefined, namedImports);

context.addImport(factory.createImportDeclaration(undefined, undefined, importClause, factory.createStringLiteral(transformedModuleSpecifier)), moduleSpecifier);
context.addImport(
factory.createImportDeclaration(
undefined,
undefined,
importClause,
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
}

// If the 'require(...)[<something>]' or 'require(...).<something>' expression is part of an ExpressionStatement
Expand Down Expand Up @@ -246,7 +272,8 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex
factory.createImportClause(false, identifier, undefined)
: // Otherwise, import the entire namespace
factory.createImportClause(false, undefined, factory.createNamespaceImport(identifier)),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand Down Expand Up @@ -339,7 +366,8 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex
factory.createImportClause(false, identifier, undefined)
: // Otherwise, import the entire namespace
factory.createImportClause(false, undefined, factory.createNamespaceImport(identifier)),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand All @@ -356,7 +384,8 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex
undefined,
undefined,
factory.createImportClause(false, undefined, factory.createNamedImports(importSpecifiers)),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand Down Expand Up @@ -411,7 +440,8 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex
factory.createImportClause(false, identifier, undefined)
: // Otherwise, import the entire namespace
factory.createImportClause(false, undefined, factory.createNamespaceImport(identifier)),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand Down Expand Up @@ -453,7 +483,8 @@ export function visitCallExpression({node, childContinuation, sourceFile, contex
factory.createImportClause(false, identifier, undefined)
: // Otherwise, import the entire namespace
factory.createImportClause(false, undefined, factory.createNamespaceImport(identifier)),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand Down
18 changes: 14 additions & 4 deletions src/transformer/visitor/visit/visit-variable-declaration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {TS} from "../../../type/ts.js";
import {willReassignIdentifier} from "../../util/will-be-reassigned.js";
import {hasExportModifier} from "../../util/has-export-modifier.js";
import {findNodeUp} from "../../util/find-node-up.js";
import {maybeGenerateAssertClause} from "../../util/maybe-generate-assert-clause.js";

/**
* Visits the given VariableDeclaration
Expand Down Expand Up @@ -89,7 +90,8 @@ export function visitVariableDeclaration({node, childContinuation, sourceFile, c
undefined,
undefined,
factory.createImportClause(false, undefined, factory.createNamespaceImport(factory.createIdentifier(node.name.text))),
moduleSpecifierExpression
moduleSpecifierExpression,
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand All @@ -115,7 +117,8 @@ export function visitVariableDeclaration({node, childContinuation, sourceFile, c
factory.createImportClause(false, factory.createIdentifier(newName), undefined)
: // Otherwise, import the entire namespace
factory.createImportClause(false, undefined, factory.createNamespaceImport(factory.createIdentifier(newName))),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand Down Expand Up @@ -179,7 +182,13 @@ export function visitVariableDeclaration({node, childContinuation, sourceFile, c
const namedImports = factory.createNamedImports([factory.createImportSpecifier(false, factory.createIdentifier(propertyName.text), factory.createIdentifier(newName))]);

context.addImport(
factory.createImportDeclaration(undefined, undefined, factory.createImportClause(false, undefined, namedImports), factory.createStringLiteral(transformedModuleSpecifier)),
factory.createImportDeclaration(
undefined,
undefined,
factory.createImportClause(false, undefined, namedImports),
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);

Expand All @@ -201,7 +210,8 @@ export function visitVariableDeclaration({node, childContinuation, sourceFile, c
undefined,
undefined,
factory.createImportClause(false, undefined, factory.createNamedImports(otherImportSpecifiers)),
factory.createStringLiteral(transformedModuleSpecifier)
factory.createStringLiteral(transformedModuleSpecifier),
maybeGenerateAssertClause(context, transformedModuleSpecifier, moduleExports?.assert)
),
moduleSpecifier
);
Expand Down
Loading

0 comments on commit 6bf8056

Please sign in to comment.