babel-plugin-transform-rewrite-imports
TypeScript icon, indicating that this package has built-in type declarations

1.2.0 • Public • Published

Black Lives Matter! Last commit timestamp Codecov Source license Monthly Downloads NPM version Uses Semantic Release!

babel-plugin-transform-rewrite-imports

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-transform-rewrite-imports 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.


Install

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

Usage

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 for appendExtension. This means bare specifiers (e.g. built-in packages and packages imported from node_modules) and absolute specifiers will never be affected by appendExtension.

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.

Advanced Usage

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: string[];
}) => 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.

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, in the case of appendExtension, 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`;
            }
          }
        }
      }
    ]
  ]
};

Further note that callback functions are stringified and injected into the resulting AST when transforming certain dynamic imports and require statements, so, to be safe, each function's contents must make no reference to variables outside of said function's 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 only using dynamic imports/require statements with string literal arguments. Be careful.

Examples

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.

Real-World Examples

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.

Appendix

Further documentation can be found under docs/.

Published Package Details

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.

License

See LICENSE.

Contributing and Support

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.

Contributors

All Contributors

Thanks goes to these wonderful people (emoji key):

Bernard
Bernard

🚇 💻 📖 🚧 ⚠️ 👀
Add your contributions

This project follows the all-contributors specification. Contributions of any kind welcome!

Package Sidebar

Install

npm i babel-plugin-transform-rewrite-imports

Weekly Downloads

1,605

Version

1.2.0

License

MIT

Unpacked Size

52.1 kB

Total Files

5

Last publish

Collaborators

  • xunnamius