Vite plugin to update your ESM and CJS specifiers.
Maybe you're running vite in library mode, or using a plugin like vite-plugin-no-bundle
, and you want to be able to change the default specifier and file extensions generated by vite. This plugin allows you to do that using whatever type
you want in your package.json.
Given an ESM-first ("type": "module"
) project with this structure:
.
├── src/
│ ├── index.ts
│ └── file.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
You can build a library in both ESM and CJS build.lib.formats
, but use .mjs
extensions for the ESM build, by defining the following vite.config.ts:
import { defineConfig } from 'vite'
import specifier from 'vite-plugin-specifier'
export default defineConfig(({
build: {
lib: {
formats: ['es', 'cjs'],
entry: ['src/index.ts', 'src/file.ts'],
},
},
plugins: [
specifier({
extMap: {
'.js': '.mjs',
},
}),
],
}))
After running the vite build, all relative specifiers ending in .js
would be updated to end in .mjs
, and your dist
would contain the following:
.
├── dist/
│ ├── index.cjs
│ ├── index.mjs
│ ├── file.cjs
│ └── file.mjs
├── src/
│ ├── index.ts
│ └── file.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
You can do the same for a CJS-first project and change the extensions to .cjs
.
If you need more fine-grained control than extMap
offers, you can use the handler
and writer
options to update specifier and file extensions any way you see fit.
As an example of how to use handler
and writer
, they can be used to create the same build as above done with extMap
.
The updated vite.config.ts:
+import { writeFile, rm } from 'node:fs/promises'
import { defineConfig } from 'vite'
import specifier from 'vite-plugin-specifier'
export default defineConfig(({
build: {
lib: {
formats: ['cjs', 'es'],
entry: ['src/index.ts', 'src/file.ts'],
},
},
plugins: [
specifier({
- extMap: {
- '.js': '.mjs',
- },
+ handler({ value }) {
+ if (value.startsWith('./') || value.startsWith('../')) {
+ return value.replace(/([^.]+)\.js$/, '$1.mjs')
+ }
+ },
+ async writer(records) {
+ const files = Object.keys(records)
+
+ for (const filename of files) {
+ if (typeof records[filename] === 'string' && filename.endsWith('.js')) {
+ await writeFile(filename.replace(/\.js$/, '.mjs'), records[filename])
+ await rm(filename, { force: true })
+ }
+ }
+ },
}),
],
}))
As you can see, it's much simpler to just use extMap
which does this for you. However, if you want to modify file extensions and/or specifiers in general (not just relative ones) after a vite build, then handler
and writer
are what you want.
You can change file and relative specifier extensions in .d.ts
files using the extMap
option.
Run tsc
first to build your types resulting in the following dist
:
.
├── dist/
│ ├── index.d.ts
│ ├── file.d.ts
├── src/
│ ├── index.ts
│ └── file.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
Now update your vite.config.ts to the following:
build: {
+ emptyOutDir: false,
lib: {
formats: ['es', 'cjs'],
entry: ['src/index.ts', 'src/file.ts'],
},
},
plugins: [
specifier({
extMap: {
'.js': '.mjs',
+ '.d.ts': 'dual'
},
}),
],
After running the vite build, the .d.ts
files will have been transformed twice, once to update relative specifiers to end with .mjs
, and once to end with .cjs
. Your dist
will now contain the following:
.
├── dist/
│ ├── index.cjs
│ ├── index.d.cts
│ ├── index.d.mts
│ ├── index.mjs
│ ├── file.cjs
│ ├── file.d.cts
│ ├── file.d.mts
│ └── file.mjs
├── src/
│ ├── index.ts
│ └── file.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
Besides the unique value dual
, you can also map .d.ts
to either .mjs
or .cjs
if you are not running vite with multiple build.lib.formats
. It will do what you expect, i.e. update the relative specifiers and output the declaration files with correct extensions.
type
type Hook = 'writeBundle' | 'transform'
Determines what vite build hook this plugin runs under. By default, this plugin runs after the vite build is finished writing files, during the writeBundle
hook.
If you run this plugin under transform
, then depending on what you're doing you might need to include some sort of resolve.alias
configuration to remap the changed specifier extensions. For example, running the example above under transform
would require this added to the vite.config.ts:
resolve: {
alias: [
{
find: /(.+)\.mjs$/,
replacement: '$1.js'
}
]
},
type
type Map = Record<string, string>
An object that maps one string to another. If any specifier matches a map
's key, the corresponding value will be used to update the specifier.
type
type ExtMap = Map<{
'.js': '.mjs' | '.cjs'
'.mjs': '.js'
'.cjs': '.js'
'.jsx': '.js' | '.mjs' | '.cjs'
'.ts': '.js' | '.mjs' | '.cjs'
'.mts': '.mjs' | '.js'
'.cts': '.cjs' | '.js'
'.tsx': '.js' | '.mjs' | '.cjs'
'.d.ts': '.d.mts' | '.d.cts' | 'dual'
}>
type Map<Exts> = {
[P in keyof Exts]?: Exts[P]
}
An object of common file extensions mapping one extension to another. Using this option allows you to easily change one extension into another for relative specifiers and their associated files.
type
type Handler = Callback | RegexMap
type Callback = (spec: Spec) => string
interface RegexMap {
[regex: string]: string
}
interface Spec {
type: 'StringLiteral' | 'TemplateLiteral' | 'BinaryExpression' | 'NewExpression'
start: number
end: number
value: string
loc: SourceLocation
}
Allows updating of specifiers on a per-file basis, using a callback or regular expression map to determine the updated specifier values. The Spec
used in the callback is essentially a portion of an AST node. The handler
is passed to @knighted/specifier
to get the updated specifier value.
type
type Writer = ((records: BundleRecords) => Promise<void>) | boolean
type BundleRecords = Record<string, { error: UpdateError | undefined; code: string }>
interface UpdateError {
error: boolean
msg: string
filename?: string
syntaxError?: {
code: string
reasonCode: string
}
}
Used to modify the emitted build files, for instance to change their file extensions. Receives a BundleRecords
object mapping the filenames from the emitted build, to their updated source code string, or an object describing an error that occured.
Setting this option to true
will use a default writer that writes the updated source code back to the original filename.