A babel plugin that reliably adds extensions to import (and export) specifiers
that do not already have one, selectively replaces extensions of specifiers that
do, and can even rewrite specifiers entirely if desired. This plugin comes in
handy in situations like transpiling TypeScript source to ESM while maintaining
the ergonomic advantage of TypeScript/NodeJS extensionless imports. It can also
be run on TypeScript declaration (i.e. .d.ts
) files directly to fix import
paths as a post-compilation step.
This plugin started off as a fork of
babel-plugin-add-import-extension that functions more
consistently, including support for require
and dynamic import()
,
replacing multiple extensions via mapping, and reliably ignoring extensions
that should not be replaced.
This plugin is similar in intent to babel-plugin-replace-import-extension
and babel-plugin-transform-rename-import but with the ability to rewrite
both require
and dynamic import()
statements, memoize rewrite function AST
and options as globals for substantial comparative output size reduction, and
append extensions to import specifiers that do not already have one.
npm install --save-dev babel-plugin-transform-rewrite-imports
And integrate the following snippet into your babel configuration:
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
// See below for configuration instructions and examples
}
]
]
};
Finally, run Babel through your toolchain (Webpack, Jest, etc) or manually:
npx babel src --out-dir dist
By default this plugin does not affect babel's output. You must explicitly configure this extension before any specifier will be rewritten.
Available options are:
appendExtension?: string;
recognizedExtensions?: string[];
replaceExtensions?: Record<string, string>;
silent?: boolean;
verbose?: boolean;
To append an extension to all relative import specifiers that do not already
have a recognized extension, use appendExtension
:
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
appendExtension: '.mjs'
}
]
]
};
Only relative import specifiers (that start with
./
or../
) will be considered forappendExtension
. This means bare specifiers (e.g. built-in packages and packages imported fromnode_modules
) and absolute specifiers will never be affected byappendExtension
.
What is and is not considered a "recognized extension" is determined by
recognizedExtensions
:
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
appendExtension: '.mjs',
recognizedExtensions: ['.js']
}
]
]
};
That is: import specifiers that end with an extension included in
recognizedExtensions
will never have appendExtension
appended to them. All
other imports, including those with a .
in the file name (e.g.
component.module.style.ts
), may be rewritten.
recognizedExtensions
is set to ['.js', '.jsx', '.mjs', '.cjs', '.json']
by
default.
If the value of appendExtension
is not included in recognizedExtensions
,
then imports that already end in appendExtension
will have appendExtension
appended to them (e.g. index.ts
is rewritten as index.ts.ts
when
appendExtension: '.ts'
and recognizedExtensions
is its default value). If
this behavior is undesired, ensure appendExtension
is included in
recognizedExtensions
.
Note that specifying a custom value for
recognizedExtensions
overwrites the default value entirely. To extend rather than overwrite, you can import the default value from the package itself:const { defaultRecognizedExtensions } = require('babel-plugin-transform-rewrite-imports'); module.exports = { plugins: [ [ 'babel-plugin-transform-rewrite-imports', { appendExtension: '.mjs', recognizedExtensions: [...defaultRecognizedExtensions, '.ts'] } ] ] };
You can also replace one or more existing extensions in specifiers using a replacement map:
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
replaceExtensions: {
// Replacements are evaluated **in order**, stopping on the first match.
// That means if the following two keys were listed in reverse order,
// .node.js would become .node.mjs instead of .cjs
'.node.js': '.cjs',
'.js': '.mjs'
}
}
]
]
};
These configurations can be combined to rewrite many imports at once. For instance, if you wanted to replace certain extensions and append only when no recognized or listed extension is specified:
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
appendExtension: '.mjs',
replaceExtensions: {
'.node.js': '.cjs',
// Since .js is in recognizedExtensions by default, file.js would normally
// be ignored. However, since .js is mapped to .mjs in the
// replaceExtensions map, file.js becomes file.mjs
'.js': '.mjs'
}
}
]
]
};
appendExtension
and replaceExtensions
accept any suffix, not just those that
begin with .
; additionally, replaceExtensions
accepts regular expressions.
This allows you to partially or entirely rewrite a specifier rather than just
its extension:
const {
defaultRecognizedExtensions
} = require('babel-plugin-transform-rewrite-imports');
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
appendExtension: '.mjs',
// Add .css to recognizedExtensions so .mjs isn't automatically appended
recognizedExtensions: [...defaultRecognizedExtensions, '.css'],
replaceExtensions: {
'.node.js': '.cjs',
'.js': '.mjs',
// The following key replaces the entire specifier when matched
'^package$': `${__dirname}/package.json`,
// If .css wasn't in recognizedExtensions, my-utils/src/file.less would
// become my-utils/src/file.css.mjs instead of my-utils/src/file.css
'(.+?)\\.less$': '$1.css'
}
}
]
]
};
If a key of
replaceExtensions
begins with^
or ends with$
, it is considered a regular expression instead of an extension. Regular expression replacements support substitutions of capturing groups as well (e.g.$1
,$2
, etc).
replaceExtensions
is evaluated and replacements made before appendExtension
is appended to specifiers with unrecognized or missing extensions. This means an
extensionless import specifier could be rewritten by replaceExtensions
to have
a recognized extension, which would then be ignored instead of having
appendExtension
appended to it.
replaceExtensions
and appendExtension
both accept function callbacks as
values everywhere strings are accepted. This can be used to provide advanced
replacement logic.
These callback functions have the following signatures:
type AppendExtensionCallback = (context: {
specifier: string;
capturingGroups: never[];
}) => string | undefined;
type ReplaceExtensionsCallback = (context: {
specifier: string;
capturingGroups: string[];
}) => string;
Where specifier
is the import/export specifier being rewritten and
capturingGroups
is a simple string array of capturing groups returned by
String.prototype.match()
. capturingGroups
will always be an empty array
except when it appears within a function value of a replaceExtensions
entry
that has a regular expression key.
When provided as the value of appendExtension
, a string containing an
extension should be returned (including leading dot). When provided as the value
of a replaceExtensions
entry, a string containing the full specifier should be
returned. When returning a full specifier, capturing group substitutions (e.g.
$1, $2, etc) within the returned string will be honored.
Further, in the case of appendExtension
, note that specifier
, if its
basename is .
or ..
or if it ends in a directory separator (e.g. /
), will
have "/index" appended to the end before the callback is invoked. However, if
the callback returns undefined
(and the specifier was not matched in
replaceExtensions
), the specifier will not be modified in any way.
By way of example (see the output of this example here):
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
// If the specifier ends with "/no-ext", do not append any extension
appendExtension: ({ specifier }) => {
return specifier.endsWith('/no-ext') ||
specifier.endsWith('..') ||
specifier == './another-thing'
? undefined
: '.mjs';
},
replaceExtensions: {
// Rewrite imports of packages in a monorepo to use their actual names
// v capturing group #1: capturingGroups[1]
'^packages/([^/]+)(/.+)?': ({ specifier, capturingGroups }) => {
// ^ capturing group #2: capturingGroups[2]
if (
specifier == 'packages/root' ||
specifier.startsWith('packages/root/')
) {
return `./monorepo-js${capturingGroups[2] ?? '/'}`;
} else if (
!capturingGroups[2] ||
capturingGroups[2].startsWith('/src/index')
) {
return `@monorepo/$1`;
} else if (capturingGroups[2].startsWith('/package.json')) {
return `@monorepo/$1$2`;
} else {
return `@monorepo/$1/dist$2`;
}
}
}
}
]
]
};
The options passed to this plugin are transpiled and injected into the resulting AST when transforming dynamic imports and require statements that do not have a string literal as the first argument. Therefore, to be safe, callback functions must not reference variables outside of their immediate scope.
Good:
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
replaceExtensions: {
'^packages/([^/]+)(/.+)?': ({ specifier, capturingGroups }) => {
const myPkg = require('my-pkg');
myPkg.doStuff(specifier, capturingGroups);
}
}
}
]
]
};
Bad:
const myPkg = require('my-pkg');
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
replaceExtensions: {
'^packages/([^/]+)(/.+)?': ({ specifier, capturingGroups }) => {
myPkg.doStuff(specifier, capturingGroups);
}
}
}
]
]
};
Technically, you can get away with violating this rule if you're sure you'll only ever use dynamic imports/require statements with string literal arguments.
With the following snippet integrated into your babel configuration:
const {
defaultRecognizedExtensions
} = require('babel-plugin-transform-rewrite-imports');
module.exports = {
plugins: [
[
'babel-plugin-transform-rewrite-imports',
{
appendExtension: '.mjs',
recognizedExtensions: [...defaultRecognizedExtensions, '.css'],
replaceExtensions: {
'.ts': '.mjs',
'^package$': `${__dirname}/package.json`,
'(.+?)\\.less$': '$1.css'
}
}
]
]
};
The following source:
/* file: src/index.ts */
import { name as pkgName } from 'package';
import { primary } from '.';
import { secondary } from '..';
import { tertiary } from '../..';
import dirImport from './some-dir/';
import jsConfig from './jsconfig.json';
import projectConfig from './project.config.cjs';
import { add, double } from './src/numbers';
import { curry } from './src/typed/curry.ts';
import styles from './src/less/styles.less';
// Note that, unless otherwise configured, babel deletes type-only imports.
// Since they're only relevant for TypeScript, they are ignored by this plugin.
import type * as AllTypes from './lib/something.mjs';
export { triple, quadruple } from './lib/num-utils';
// Note that, unless otherwise configured, babel deletes type-only exports.
// Since they're only relevant for TypeScript, they are ignored by this plugin.
export type { NamedType } from './lib/something';
const thing = await import('./thing');
const anotherThing = require('./another-thing');
const thing2 = await import(someFn(`./${someVariable}`) + '.json');
const anotherThing2 = require(someOtherVariable);
Is, depending on your other plugins/settings, transformed into something like:
/* file: dist/index.js */
const _rewrite = (importPath, options) => {
...
},
_rewrite_options = {
appendExtension: '.mjs',
recognizedExtensions: ['.js', '.jsx', '.mjs', '.cjs', '.json', '.css'],
replaceExtensions: {
'.ts': '.mjs',
'^package$': '/absolute/path/to/project/package.json',
'(.+?)\\.less$': '$1.css'
}
};
import { name as pkgName } from '/absolute/path/to/project/package.json';
import { primary } from './index.mjs';
import { secondary } from '../index.mjs';
import { tertiary } from '../../index.mjs';
import dirImport from './some-dir/index.mjs';
import jsConfig from './jsconfig.json';
import projectConfig from './project.config.cjs';
import { add, double } from './src/numbers.mjs';
import { curry } from './src/typed/curry.mjs';
import styles from './src/less/styles.css';
export { triple, quadruple } from './lib/num-utils.mjs';
const thing = await import('./thing.mjs');
const anotherThing = require('./another-thing.mjs');
// Require calls and dynamic imports with a non-string-literal first argument
// are transformed into function calls that dynamically return the rewritten
// string:
const thing2 = await import(
_rewrite(someFn(`./${someVariable}`) + '.json', _rewrite_options)
);
const anotherThing2 = require(_rewrite(someOtherVariable, _rewrite_options));
See the full output of this example here.
For some real-world examples of this babel plugin in action, check out the unified-utils and babel-plugin-transform-rewrite-imports repositories or take a peek at the test cases.
Further documentation can be found under docs/
.
This is a CJS2 package with statically-analyzable exports built by Babel for Node14 and above.
Expand details
That means both CJS2 (via require(...)
) and ESM (via import { ... } from ...
or await import(...)
) source will load this package from the same entry points
when using Node. This has several benefits, the foremost being: less code
shipped/smaller package size, avoiding dual package
hazard entirely, distributables are not
packed/bundled/uglified, and a less complex build process.
Each entry point (i.e. ENTRY
) in package.json
's
exports[ENTRY]
object includes one or more export
conditions. These entries may or may not include: an
exports[ENTRY].types
condition pointing to a type
declarations file for TypeScript and IDEs, an
exports[ENTRY].module
condition pointing to
(usually ESM) source for Webpack/Rollup, an exports[ENTRY].node
condition
pointing to (usually CJS2) source for Node.js require
and import
, an
exports[ENTRY].default
condition pointing to source for browsers and other
environments, and other conditions not enumerated
here. Check the package.json file to see which export
conditions are supported.
Though package.json
includes
{ "type": "commonjs" }
, note that any ESM-only entry points will
be ES module (.mjs
) files. Finally, package.json
also
includes the sideEffects
key, which is false
for
optimal tree shaking.
See LICENSE.
New issues and pull requests are always welcome and greatly appreciated! 🤩 Just as well, you can star 🌟 this project to let me know you found it useful! ✊🏿 Thank you!
See CONTRIBUTING.md and SUPPORT.md for more information.
Thanks goes to these wonderful people (emoji key):
Bernard 🚇 💻 📖 🚧 |
||||||
Add your contributions |
This project follows the all-contributors specification. Contributions of any kind welcome!