diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7452d0e..fc58435 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,3 +15,36 @@ permissions: {} jobs: unit-test: uses: sxzz/workflows/.github/workflows/unit-test.yml@v1 + with: + typecheck: pnpm run build && pnpm run typecheck + + unit-test-bun: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + + - name: Setup node + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: lts/* + cache: pnpm + + - name: Setup Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: latest + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm run build + + - name: Test with bun + run: bun run scripts/buildFixtures.ts && bun -b vitest --allowOnly diff --git a/README.md b/README.md index 2f7157f..5d3d800 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Currently supports: - [Rspack](https://www.rspack.dev/) - [Rolldown](https://rolldown.rs/) - [Farm](https://www.farmfe.org/) +- [Bun](https://bun.com/) - And every framework built on top of them. ## Documentations diff --git a/docs/.vitepress/constant.ts b/docs/.vitepress/constant.ts index 6087adb..44771ab 100644 --- a/docs/.vitepress/constant.ts +++ b/docs/.vitepress/constant.ts @@ -1,4 +1,4 @@ export const title = 'Unplugin' -export const description = 'Unified plugin system. Support Vite, Rollup, webpack, esbuild, and every frameworks on top of them.' +export const description = 'Unified plugin system. Support Vite, Rollup, webpack, esbuild, Bun, and every frameworks on top of them.' export const url = 'https://unplugin.unjs.io/' export const ogImage = `${url}/og.png` diff --git a/docs/README.md b/docs/README.md index 09eaac1..b652793 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ Unplugin

-Unified plugin system, Support Vite, Rollup, webpack, esbuild, and more +Unified plugin system, Support Vite, Rollup, webpack, esbuild, Bun, and more

diff --git a/docs/guide/index.md b/docs/guide/index.md index 60d27fc..6b0fcc5 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -18,6 +18,7 @@ lastUpdated: false - [Rspack](https://www.rspack.dev/) - [Rolldown](https://rolldown.rs/) - [Farm](https://www.farmfe.org/) +- [Bun](https://bun.com/) ## Trying It Online @@ -153,6 +154,21 @@ export default defineConfig({ }) ``` +```ts [Bun] +// bun.config.ts +import Starter from 'unplugin-starter/bun' + +await Bun.build({ + entrypoints: ['./src/index.ts'], + outdir: './dist', + plugins: [ + Starter({ + /* options */ + }), + ], +}) +``` + ```js [Vue-CLI] // vue.config.js module.exports = { @@ -193,18 +209,18 @@ export default defineConfig({ ## Supported Hooks -| Hook | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | -| --------------------------------------------------------------------------------- | :-------------: | :--: | :-----: | :-------------: | :-------------: | :--: | :------: | -| [`enforce`](https://vite.dev/guide/api-plugin.html#plugin-ordering) | ❌ 1 | ✅ | ✅ | ❌ 1 | ✅ | ✅ | ✅ | -| [`buildStart`](https://rollupjs.org/plugin-development/#buildstart) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [`resolveId`](https://rollupjs.org/plugin-development/#resolveid) | ✅ | ✅ | ✅ | ✅ | ✅ 5 | ✅ | ✅ | -| ~~`loadInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [`load`](https://rollupjs.org/plugin-development/#load) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ | -| ~~`transformInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [`transform`](https://rollupjs.org/plugin-development/#transform) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ | -| [`watchChange`](https://rollupjs.org/plugin-development/#watchchange) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | -| [`buildEnd`](https://rollupjs.org/plugin-development/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [`writeBundle`](https://rollupjs.org/plugin-development/#writebundle)4 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Hook | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | Bun | +| --------------------------------------------------------------------------------- | :-------------: | :--: | :-----: | :-------------: | :-------------: | :--: | :------: | :-------------: | +| [`enforce`](https://vite.dev/guide/api-plugin.html#plugin-ordering) | ❌ 1 | ✅ | ✅ | ❌ 1 | ✅ | ✅ | ✅ | ❌ | +| [`buildStart`](https://rollupjs.org/plugin-development/#buildstart) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`resolveId`](https://rollupjs.org/plugin-development/#resolveid) | ✅ | ✅ | ✅ | ✅ | ✅ 5 | ✅ | ✅ | ✅ | +| ~~`loadInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`load`](https://rollupjs.org/plugin-development/#load) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ | ✅ | +| ~~`transformInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`transform`](https://rollupjs.org/plugin-development/#transform) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ | ✅ | +| [`watchChange`](https://rollupjs.org/plugin-development/#watchchange) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | +| [`buildEnd`](https://rollupjs.org/plugin-development/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ 6 | +| [`writeBundle`](https://rollupjs.org/plugin-development/#writebundle)4 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ 6 | ::: details Notice @@ -215,6 +231,7 @@ export default defineConfig({ 3. Although esbuild can handle both JavaScript and CSS and many other file formats, you can only return JavaScript in `load` and `transform` results. 4. Currently, `writeBundle` is only serves as a hook for the timing. It doesn't pass any arguments. 5. Rspack supports `resolveId` with a minimum required version of v1.0.0-alpha.1. +6. Bun's plugin API doesn't have an `onEnd` hook yet, so `buildEnd` and `writeBundle` are not supported. ::: @@ -253,6 +270,7 @@ export const webpackPlugin = unplugin.webpack export const rspackPlugin = unplugin.rspack export const esbuildPlugin = unplugin.esbuild export const farmPlugin = unplugin.farm +export const bunPlugin = unplugin.bun ``` ### Filters @@ -289,14 +307,14 @@ More details can be found in the [Rolldown's documentation](https://rolldown.rs/ ## Supported Context -| Context | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | -| ------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :-----: | :----: | :--: | :------: | -| [`this.parse`](https://rollupjs.org/plugin-development/#this-parse) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [`this.addWatchFile`](https://rollupjs.org/plugin-development/#this-addwatchfile) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | -| [`this.emitFile`](https://rollupjs.org/plugin-development/#this-emitfile)1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [`this.getWatchFiles`](https://rollupjs.org/plugin-development/#this-getwatchfiles) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | -| [`this.warn`](https://rollupjs.org/plugin-development/#this-warn) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [`this.error`](https://rollupjs.org/plugin-development/#this-error) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Context | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | Bun | +| ------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :-----: | :----: | :--: | :------: | :-: | +| [`this.parse`](https://rollupjs.org/plugin-development/#this-parse) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`this.addWatchFile`](https://rollupjs.org/plugin-development/#this-addwatchfile) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | +| [`this.emitFile`](https://rollupjs.org/plugin-development/#this-emitfile)1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`this.getWatchFiles`](https://rollupjs.org/plugin-development/#this-getwatchfiles) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | +| [`this.warn`](https://rollupjs.org/plugin-development/#this-warn) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`this.error`](https://rollupjs.org/plugin-development/#this-error) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ::: info Notice @@ -309,9 +327,9 @@ More details can be found in the [Rolldown's documentation](https://rolldown.rs/ ### Bundler Supported -| Rollup | Vite | webpack | Rspack | esbuild | Farm | Rolldown | -| :--------------------: | :--: | :-----: | :----: | :-----: | :--: | :------: | -| ✅ `>=3.1`1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Rollup | Vite | webpack | Rspack | esbuild | Farm | Rolldown | Bun | +| :--------------------: | :--: | :-----: | :----: | :-----: | :--: | :------: | :-: | +| ✅ `>=3.1`1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ::: details Notice @@ -409,6 +427,9 @@ export const unpluginFactory: UnpluginFactory = ( farm: { // Farm plugin }, + bun: { + // Bun plugin + }, } } @@ -424,6 +445,7 @@ Each of the function takes the same generic factory argument as `createUnplugin` ```ts import { + createBunPlugin, createEsbuildPlugin, createFarmPlugin, createRolldownPlugin, @@ -440,4 +462,5 @@ const esbuildPlugin = createEsbuildPlugin(/* factory */) const webpackPlugin = createWebpackPlugin(/* factory */) const rspackPlugin = createRspackPlugin(/* factory */) const farmPlugin = createFarmPlugin(/* factory */) +const bunPlugin = createBunPlugin(/* factory */) ``` diff --git a/docs/index.md b/docs/index.md index 3fe6144..663cbcf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ sidebar: false hero: name: Unplugin text: The Unified
Plugin System - tagline: Supports Vite, Rollup, webpack, esbuild, and every framework built on top of them. + tagline: Supports Vite, Rollup, webpack, esbuild, Bun, and every framework built on top of them. image: light: /logo_light.svg dark: /logo_dark.svg @@ -64,6 +64,12 @@ features: icon: src: /features/rolldown.svg + - title: Bun + details: All-in-one JavaScript runtime & toolkit + link: https://bun.com/ + icon: + src: /features/bun.svg + - title: More details: More supported bundlers... link: /guide/#supported-hooks diff --git a/docs/public/features/bun.svg b/docs/public/features/bun.svg new file mode 100644 index 0000000..7ef1500 --- /dev/null +++ b/docs/public/features/bun.svg @@ -0,0 +1 @@ +Bun Logo \ No newline at end of file diff --git a/package.json b/package.json index eef977e..cace4a8 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@types/picomatch": "catalog:", "ansis": "catalog:", "bumpp": "catalog:", + "bun-types-no-globals": "catalog:", "esbuild": "catalog:", "eslint": "catalog:", "eslint-plugin-format": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 154fa11..b8318bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: bumpp: specifier: ^10.3.1 version: 10.3.1 + bun-types-no-globals: + specifier: ^1.2.22 + version: 1.3.1 eslint: specifier: ^9.39.0 version: 9.39.0 @@ -196,6 +199,9 @@ importers: bumpp: specifier: 'catalog:' version: 10.3.1 + bun-types-no-globals: + specifier: 'catalog:' + version: 1.3.1 esbuild: specifier: ^0.25.12 version: 0.25.12 @@ -2107,6 +2113,9 @@ packages: engines: {node: '>=18'} hasBin: true + bun-types-no-globals@1.3.1: + resolution: {integrity: sha512-nVhf54PRc8MzKvMK0IXy6TlPGy8Qtk/BY/kk7IUuAxDROYWGRhD+9WF1H0VvzIeB3/AMnuV3az9K7h/4GfSWCw==} + bundle-name@3.0.0: resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==} engines: {node: '>=12'} @@ -5319,14 +5328,14 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.27.7 + '@babel/parser': 7.28.5 '@babel/types': 7.28.5 '@babel/traverse@7.27.7': dependencies: '@babel/code-frame': 7.27.1 '@babel/generator': 7.28.5 - '@babel/parser': 7.27.7 + '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 debug: 4.4.3 @@ -7188,6 +7197,8 @@ snapshots: transitivePeerDependencies: - magicast + bun-types-no-globals@1.3.1: {} + bundle-name@3.0.0: dependencies: run-applescript: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b897399..74c4df7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ catalog: '@types/picomatch': ^4.0.2 ansis: ^4.2.0 bumpp: ^10.3.1 + bun-types-no-globals: ^1.2.22 esbuild: ^0.25.12 eslint: ^9.39.0 eslint-plugin-format: ^1.0.2 diff --git a/scripts/buildFixtures.ts b/scripts/buildFixtures.ts index 1323783..73017b6 100644 --- a/scripts/buildFixtures.ts +++ b/scripts/buildFixtures.ts @@ -5,6 +5,8 @@ import { join, resolve } from 'node:path' import process from 'node:process' import c from 'ansis' +const isBun = !!process.versions.bun + const dir = resolve(import.meta.dirname, '../test/fixtures') let fixtures = await readdir(dir) @@ -16,6 +18,13 @@ for (const name of fixtures) { if (existsSync(join(path, 'dist'))) await rm(join(path, 'dist')).catch(() => {}) + if (isBun) { + console.log(c.magentaBright.inverse.bold`\n Bun `, name, '\n') + execSync('bun --version', { cwd: path, stdio: 'inherit' }) + execSync('bun bun.config.js', { cwd: path, stdio: 'inherit' }) + continue // skip other builders in bun environment + } + console.log(c.yellow.inverse.bold`\n Vite `, name, '\n') execSync('npx vite --version', { cwd: path, stdio: 'inherit' }) execSync('npx vite build', { cwd: path, stdio: 'inherit' }) diff --git a/src/bun/index.ts b/src/bun/index.ts new file mode 100644 index 0000000..6cafde9 --- /dev/null +++ b/src/bun/index.ts @@ -0,0 +1,238 @@ +import type { BunPlugin, Loader } from 'bun' +import type { TransformResult, UnpluginContextMeta, UnpluginFactory, UnpluginInstance } from '../types' +import { isAbsolute } from 'node:path' +import { normalizeObjectHook } from '../utils/filter' +import { toArray } from '../utils/general' +import { createBuildContext, createPluginContext, guessLoader } from './utils' + +export function getBunPlugin>( + factory: UnpluginFactory, +): UnpluginInstance['bun'] { + return (userOptions?: UserOptions): BunPlugin => { + if (typeof Bun === 'undefined') { + throw new ReferenceError('Bun is not supported in this environment') + } + + if (!Bun.semver.satisfies(Bun.version, '>=1.2.22')) { + throw new Error('Bun 1.2.22 or higher is required, please upgrade Bun') + } + + const meta: UnpluginContextMeta = { + framework: 'bun', + } + + const plugins = toArray(factory(userOptions!, meta)) + + return { + name: (plugins.length === 1 ? plugins[0].name : meta.bunHostName) + ?? `unplugin-host:${plugins.map(p => p.name).join(':')}`, + + async setup(build) { + const context = createBuildContext(build) + + if (plugins.some(plugin => plugin.buildStart)) { + build.onStart(async () => { + for (const plugin of plugins) { + if (plugin.buildStart) { + await plugin.buildStart.call(context) + } + } + }) + } + + const resolveIdHooks = plugins + .filter(plugin => plugin.resolveId) + .map(plugin => ({ + plugin, + ...normalizeObjectHook('resolveId', plugin.resolveId!), + })) + + const loadHooks = plugins + .filter(plugin => plugin.load) + .map(plugin => ({ + plugin, + ...normalizeObjectHook('load', plugin.load!), + })) + + const transformHooks = plugins + .filter(plugin => plugin.transform || plugin.transformInclude) + .map(plugin => ({ + plugin, + ...normalizeObjectHook('transform', plugin.transform!), + })) + + const virtualModulePlugins = new Set() + for (const plugin of plugins) { + if (plugin.resolveId && plugin.load) { + virtualModulePlugins.add(plugin.name) + } + } + + if (resolveIdHooks.length) { + build.onResolve({ filter: /.*/ }, async (args) => { + if (build.config?.external?.includes(args.path)) { + return + } + + for (const { plugin, handler, filter } of resolveIdHooks) { + if (!filter(args.path)) + continue + + const { mixedContext, errors, warnings } = createPluginContext(context) + const isEntry = args.kind === 'entry-point-run' || args.kind === 'entry-point-build' + + const result = await handler.call( + mixedContext, + args.path, + isEntry ? undefined : args.importer, + { isEntry }, + ) + + for (const warning of warnings) { + console.warn('[unplugin]', typeof warning === 'string' ? warning : warning.message) + } + if (errors.length > 0) { + const errorMessage = errors.map(e => typeof e === 'string' ? e : e.message).join('\n') + throw new Error(`[unplugin] ${plugin.name}: ${errorMessage}`) + } + + if (typeof result === 'string') { + if (!isAbsolute(result)) { + return { + path: result, + namespace: plugin.name, + } + } + return { path: result } + } + else if (typeof result === 'object' && result !== null) { + if (!isAbsolute(result.id)) { + return { + path: result.id, + external: result.external, + namespace: plugin.name, + } + } + return { + path: result.id, + external: result.external, + } + } + } + }) + } + + async function processLoadTransform( + id: string, + namespace: string, + loader?: Loader, + ): Promise<{ contents: string, loader: Loader } | undefined> { + let code: string | undefined + let hasResult = false + + const namespaceLoadHooks = namespace === 'file' + ? loadHooks + : loadHooks.filter(h => h.plugin.name === namespace) + + for (const { plugin, handler, filter } of namespaceLoadHooks) { + if (plugin.loadInclude && !plugin.loadInclude(id)) + continue + if (!filter(id)) + continue + + const { mixedContext, errors, warnings } = createPluginContext(context) + const result = await handler.call(mixedContext, id) + + for (const warning of warnings) { + console.warn('[unplugin]', typeof warning === 'string' ? warning : warning.message) + } + if (errors.length > 0) { + const errorMessage = errors.map(e => typeof e === 'string' ? e : e.message).join('\n') + throw new Error(`[unplugin] ${plugin.name}: ${errorMessage}`) + } + + if (typeof result === 'string') { + code = result + hasResult = true + break + } + else if (typeof result === 'object' && result !== null) { + code = result.code + hasResult = true + break + } + } + + if (!hasResult && namespace === 'file' && transformHooks.length > 0) { + code = await Bun.file(id).text() + } + + if (code !== undefined) { + const namespaceTransformHooks = namespace === 'file' + ? transformHooks + : transformHooks.filter(h => h.plugin.name === namespace) + + for (const { plugin, handler, filter } of namespaceTransformHooks) { + if (plugin.transformInclude && !plugin.transformInclude(id)) + continue + if (!filter(id, code)) + continue + + const { mixedContext, errors, warnings } = createPluginContext(context) + const result: TransformResult = await handler.call(mixedContext, code, id) + + for (const warning of warnings) { + console.warn('[unplugin]', typeof warning === 'string' ? warning : warning.message) + } + if (errors.length > 0) { + const errorMessage = errors.map(e => typeof e === 'string' ? e : e.message).join('\n') + throw new Error(`[unplugin] ${plugin.name}: ${errorMessage}`) + } + + if (typeof result === 'string') { + code = result + hasResult = true + } + else if (typeof result === 'object' && result !== null) { + code = result.code + hasResult = true + } + } + } + + if (hasResult && code !== undefined) { + return { + contents: code, + loader: loader ?? guessLoader(id), + } + } + } + + if (loadHooks.length || transformHooks.length) { + build.onLoad({ filter: /.*/, namespace: 'file' }, async (args) => { + return processLoadTransform(args.path, 'file', args.loader) + }) + } + + for (const pluginName of virtualModulePlugins) { + build.onLoad({ filter: /.*/, namespace: pluginName }, async (args) => { + return processLoadTransform(args.path, pluginName, args.loader) + }) + } + + if (plugins.some(plugin => plugin.buildEnd || plugin.writeBundle)) { + build.onEnd(async () => { + for (const plugin of plugins) { + if (plugin.buildEnd) { + await plugin.buildEnd.call(context) + } + if (plugin.writeBundle) { + await plugin.writeBundle() + } + } + }) + } + }, + } + } +} diff --git a/src/bun/utils.ts b/src/bun/utils.ts new file mode 100644 index 0000000..2d117fb --- /dev/null +++ b/src/bun/utils.ts @@ -0,0 +1,89 @@ +import type { Loader, PluginBuilder } from 'bun' +import type { UnpluginBuildContext, UnpluginContext, UnpluginMessage } from '../types' +import fs from 'node:fs' +import path from 'node:path' +import * as acorn from 'acorn' + +const ExtToLoader: Record = { + '.js': 'js', + '.mjs': 'js', + '.cjs': 'js', + '.jsx': 'jsx', + '.ts': 'ts', + '.cts': 'ts', + '.mts': 'ts', + '.tsx': 'tsx', + '.css': 'css', + '.less': 'css', + '.stylus': 'css', + '.scss': 'css', + '.sass': 'css', + '.json': 'json', + '.txt': 'text', +} + +export function guessLoader(id: string): Loader { + return ExtToLoader[path.extname(id).toLowerCase()] || 'js' +} + +export function createBuildContext(build: PluginBuilder): UnpluginBuildContext { + const watchFiles: string[] = [] + + return { + addWatchFile(file) { + watchFiles.push(file) + }, + getWatchFiles() { + return watchFiles + }, + emitFile(emittedFile) { + const outFileName = emittedFile.fileName || emittedFile.name + const outdir = build?.config?.outdir + if (outdir && emittedFile.source && outFileName) { + const outPath = path.resolve(outdir, outFileName) + const outDir = path.dirname(outPath) + if (!fs.existsSync(outDir)) + fs.mkdirSync(outDir, { recursive: true }) + fs.writeFileSync(outPath, emittedFile.source) + } + }, + parse(code, opts = {}) { + return acorn.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }) + }, + getNativeBuildContext() { + return { framework: 'bun', build } + }, + } +} + +export function createPluginContext( + buildContext: UnpluginBuildContext, +): { + errors: Array + warnings: Array + mixedContext: UnpluginBuildContext & UnpluginContext +} { + const errors: Array = [] + const warnings: Array = [] + + const mixedContext: UnpluginBuildContext & UnpluginContext = { + ...buildContext, + error(error) { + errors.push(error) + }, + warn(warning) { + warnings.push(warning) + }, + } + + return { + errors, + warnings, + mixedContext, + } +} diff --git a/src/define.ts b/src/define.ts index 15f1330..540b661 100644 --- a/src/define.ts +++ b/src/define.ts @@ -1,4 +1,5 @@ import type { UnpluginFactory, UnpluginInstance } from './types' +import { getBunPlugin } from './bun' import { getEsbuildPlugin } from './esbuild' import { getFarmPlugin } from './farm' import { getRolldownPlugin } from './rolldown' @@ -36,6 +37,9 @@ export function createUnplugin( get unloader() { return getUnloaderPlugin(factory) }, + get bun() { + return getBunPlugin(factory) + }, get raw() { return factory }, @@ -89,3 +93,9 @@ export function createUnloaderPlugin['unloader'] { return getUnloaderPlugin(factory) } + +export function createBunPlugin( + factory: UnpluginFactory, +): UnpluginInstance['bun'] { + return getBunPlugin(factory) +} diff --git a/src/globals.d.ts b/src/globals.d.ts index f11e5cf..d8a1544 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1,8 +1,14 @@ -/** - * Flag that is replaced with a boolean during build time. - * __DEV__ is false in the final library output, and it is - * true when the library is ad-hoc transpiled, ie. during tests. - * - * See "tsdown.config.ts" and "vitest.config.ts" for more info. - */ -declare const __DEV__: boolean +import * as BunModule from 'bun' + +declare global { + export import Bun = BunModule + + /** + * Flag that is replaced with a boolean during build time. + * __DEV__ is false in the final library output, and it is + * true when the library is ad-hoc transpiled, ie. during tests. + * + * See "tsdown.config.ts" and "vitest.config.ts" for more info. + */ + declare const __DEV__: boolean +} diff --git a/src/types.ts b/src/types.ts index 245e187..784200c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ import type { CompilationContext as FarmCompilationContext, JsPlugin as FarmPlugin } from '@farmfe/core' import type { Compilation as RspackCompilation, Compiler as RspackCompiler, LoaderContext as RspackLoaderContext, RspackPluginInstance } from '@rspack/core' +import type { Options as AcornOptions } from 'acorn' +import type { BunPlugin, PluginBuilder as BunPluginBuilder } from 'bun' import type { BuildOptions, Plugin as EsbuildPlugin, Loader, PluginBuild } from 'esbuild' import type { Plugin as RolldownPlugin } from 'rolldown' import type { AstNode, EmittedAsset, PluginContextMeta as RollupContextMeta, Plugin as RollupPlugin, SourceMapInput } from 'rollup' @@ -9,6 +11,7 @@ import type { Compilation as WebpackCompilation, Compiler as WebpackCompiler, Lo import type VirtualModulesPlugin from 'webpack-virtual-modules' export type { + BunPlugin, EsbuildPlugin, RolldownPlugin, RollupPlugin, @@ -52,12 +55,13 @@ export type NativeBuildContext | { framework: 'esbuild', build: PluginBuild } | { framework: 'rspack', compiler: RspackCompiler, compilation: RspackCompilation, loaderContext?: RspackLoaderContext | undefined } | { framework: 'farm', context: FarmCompilationContext } + | { framework: 'bun', build: BunPluginBuilder } export interface UnpluginBuildContext { addWatchFile: (id: string) => void emitFile: (emittedFile: EmittedAsset) => void getWatchFiles: () => string[] - parse: (input: string, options?: any) => AstNode + parse: (input: string, options?: Partial) => AstNode getNativeBuildContext?: (() => NativeBuildContext) | undefined } @@ -142,6 +146,7 @@ export interface UnpluginOptions { config?: ((options: BuildOptions) => void) | undefined } | undefined farm?: Partial | undefined + bun?: Partial | undefined } export interface ResolvedUnpluginOptions extends UnpluginOptions { @@ -168,6 +173,7 @@ export interface UnpluginInstance esbuild: UnpluginFactoryOutput unloader: UnpluginFactoryOutput : UnloaderPlugin> farm: UnpluginFactoryOutput + bun: UnpluginFactoryOutput raw: UnpluginFactory } @@ -180,6 +186,10 @@ export type UnpluginContextMeta = Partial & ({ framework: 'esbuild' /** Set the host plugin name of esbuild when returning multiple plugins */ esbuildHostName?: string | undefined +} | { + framework: 'bun' + /** Set the host plugin name of bun when returning multiple plugins */ + bunHostName?: string | undefined } | { framework: 'rspack' rspack: { compiler: RspackCompiler } diff --git a/test/fixtures/load/__test__/build.test.ts b/test/fixtures/load/__test__/build.test.ts index e9e6427..504287d 100644 --- a/test/fixtures/load/__test__/build.test.ts +++ b/test/fixtures/load/__test__/build.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { onlyBun } from '../../../utils' const r = (...args: string[]) => resolve(__dirname, '../dist', ...args) @@ -38,4 +39,10 @@ describe('load-called-before-transform', () => { expect(content).toContain('it is a msg -> through the load hook -> transform-[Injected Farm]') }) + + onlyBun('bun', async () => { + const content = await fs.readFile(r('bun/main.js'), 'utf-8') + + expect(content).toContain('it is a msg -> through the load hook -> transform-[Injected Bun]') + }) }) diff --git a/test/fixtures/load/bun.config.js b/test/fixtures/load/bun.config.js new file mode 100644 index 0000000..e37211f --- /dev/null +++ b/test/fixtures/load/bun.config.js @@ -0,0 +1,8 @@ +const Bun = require('bun') +const { bun } = require('./unplugin') + +await Bun.build({ + entrypoints: ['./src/main.js'], + outdir: './dist/bun', + plugins: [bun({ msg: 'Bun' })], +}) diff --git a/test/fixtures/transform/__test__/build.test.ts b/test/fixtures/transform/__test__/build.test.ts index bd27983..fb3618a 100644 --- a/test/fixtures/transform/__test__/build.test.ts +++ b/test/fixtures/transform/__test__/build.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { onlyBun } from '../../../utils' const r = (...args: string[]) => resolve(__dirname, '../dist', ...args) @@ -18,6 +19,7 @@ describe('transform build', () => { expect(content).toContain('NON-TARGET: __UNPLUGIN__') expect(content).toContain('TARGET: [Injected Post Rollup]') + // Query imports are external in Rollup }) it('webpack', async () => { @@ -51,4 +53,12 @@ describe('transform build', () => { expect(content).toContain('TARGET: [Injected Post Farm]') expect(content).toContain('QUERY: [Injected Post Farm]') }) + + onlyBun('bun', async () => { + const content = await fs.readFile(r('bun/main.js'), 'utf-8') + + expect(content).toContain('NON-TARGET: __UNPLUGIN__') + expect(content).toContain('TARGET: [Injected Post Bun]') + // Like Rollup, imports with query params are marked as external in Bun + }) }) diff --git a/test/fixtures/transform/bun.config.js b/test/fixtures/transform/bun.config.js new file mode 100644 index 0000000..e37211f --- /dev/null +++ b/test/fixtures/transform/bun.config.js @@ -0,0 +1,8 @@ +const Bun = require('bun') +const { bun } = require('./unplugin') + +await Bun.build({ + entrypoints: ['./src/main.js'], + outdir: './dist/bun', + plugins: [bun({ msg: 'Bun' })], +}) diff --git a/test/fixtures/transform/unplugin.js b/test/fixtures/transform/unplugin.js index a014dac..66d4a57 100644 --- a/test/fixtures/transform/unplugin.js +++ b/test/fixtures/transform/unplugin.js @@ -6,8 +6,8 @@ module.exports = createUnplugin((options, meta) => { { name: 'transform-fixture-pre', resolveId(id) { - // Rollup doesn't know how to import module with query string so we ignore the module - if (id.includes('?query-param=query-value') && meta.framework === 'rollup') { + // Rollup and Bun don't know how to import module with query string so we ignore the module + if (id.includes('?query-param=query-value') && (meta.framework === 'rollup' || meta.framework === 'bun')) { return { id, external: true, diff --git a/test/fixtures/virtual-module/__test__/build.test.ts b/test/fixtures/virtual-module/__test__/build.test.ts index fbc06b5..2d09bbe 100644 --- a/test/fixtures/virtual-module/__test__/build.test.ts +++ b/test/fixtures/virtual-module/__test__/build.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { onlyBun } from '../../../utils' const r = (...args: string[]) => resolve(__dirname, '../dist', ...args) @@ -46,4 +47,11 @@ describe('virtual-module build', () => { expect(content).toContain('VIRTUAL:ONE') expect(content).toContain('VIRTUAL:TWO') }) + + onlyBun('bun', async () => { + const content = await fs.readFile(r('bun/main.js'), 'utf-8') + + expect(content).toContain('VIRTUAL:ONE') + expect(content).toContain('VIRTUAL:TWO') + }) }) diff --git a/test/fixtures/virtual-module/bun.config.js b/test/fixtures/virtual-module/bun.config.js new file mode 100644 index 0000000..7a29641 --- /dev/null +++ b/test/fixtures/virtual-module/bun.config.js @@ -0,0 +1,8 @@ +const Bun = require('bun') +const { bun } = require('./unplugin') + +await Bun.build({ + entrypoints: ['./src/main.js'], + outdir: './dist/bun', + plugins: [bun()], +}) diff --git a/test/unit-tests/bun/index.test.ts b/test/unit-tests/bun/index.test.ts new file mode 100644 index 0000000..374f67a --- /dev/null +++ b/test/unit-tests/bun/index.test.ts @@ -0,0 +1,51 @@ +import { createUnplugin } from 'unplugin' +import { describe, expect, it } from 'vitest' + +describe.skipIf(typeof Bun === 'undefined')('bun plugin', () => { + it('should export bun plugin', () => { + const unplugin = createUnplugin(() => ({ + name: 'test-plugin', + })) + + expect(unplugin.bun).toBeDefined() + expect(typeof unplugin.bun).toBe('function') + }) + + it('should create bun plugin with correct name', () => { + const unplugin = createUnplugin(() => ({ + name: 'test-plugin', + })) + + const bunPlugin = unplugin.bun() + expect(bunPlugin.name).toBe('test-plugin') + expect(bunPlugin.setup).toBeDefined() + expect(typeof bunPlugin.setup).toBe('function') + }) + + it('should handle options correctly', () => { + interface Options { + value: string + } + + const unplugin = createUnplugin(options => ({ + name: 'test-plugin', + buildStart() { + expect(options.value).toBe('test') + }, + })) + + const bunPlugin = unplugin.bun({ value: 'test' }) + expect(bunPlugin).toBeDefined() + }) + + it('should support multiple plugins with host name', () => { + const unplugin = createUnplugin(() => [ + { name: 'plugin-1' }, + { name: 'plugin-2' }, + ]) + + const bunPlugin = unplugin.bun() + expect(bunPlugin.name).toBe('unplugin-host:plugin-1:plugin-2') + expect(bunPlugin.setup).toBeDefined() + }) +}) diff --git a/test/unit-tests/bun/nested.test.ts b/test/unit-tests/bun/nested.test.ts new file mode 100644 index 0000000..7458a51 --- /dev/null +++ b/test/unit-tests/bun/nested.test.ts @@ -0,0 +1,134 @@ +import { createUnplugin } from 'unplugin' +import { describe, expect, it, vi } from 'vitest' + +describe.skipIf(typeof Bun === 'undefined')('bun nested plugin support', () => { + it('should call buildStart for all nested plugins', async () => { + const buildStart1 = vi.fn() + const buildStart2 = vi.fn() + + const unplugin = createUnplugin(() => [ + { + name: 'plugin-1', + buildStart: buildStart1, + }, + { + name: 'plugin-2', + buildStart: buildStart2, + }, + ]) + + const bunPlugin = unplugin.bun() + const mockBuild: Bun.PluginBuilder = { + onResolve: vi.fn(), + onLoad: vi.fn(), + onStart: vi.fn(callback => callback()), + config: { outdir: './dist' } as Bun.BuildConfig & { plugins: Bun.BunPlugin[] }, + } as Partial as Bun.PluginBuilder + + await bunPlugin.setup(mockBuild) + + expect(buildStart1).toHaveBeenCalledTimes(1) + expect(buildStart2).toHaveBeenCalledTimes(1) + }) + + it('should handle resolveId from multiple plugins', async () => { + const resolveId1 = vi.fn().mockResolvedValue('resolved-1') + const resolveId2 = vi.fn().mockResolvedValue('resolved-2') + + const unplugin = createUnplugin(() => [ + { + name: 'plugin-1', + resolveId: resolveId1, + }, + { + name: 'plugin-2', + resolveId: resolveId2, + }, + ]) + + const bunPlugin = unplugin.bun() + const onResolveCallback = vi.fn() + const mockBuild = { + onResolve: vi.fn((options, callback) => { + onResolveCallback.mockImplementation(callback) + }), + onLoad: vi.fn(), + onStart: vi.fn(), + config: { outdir: './dist' }, + } as never as Bun.PluginBuilder + + await bunPlugin.setup(mockBuild) + + expect(mockBuild.onResolve).toHaveBeenCalledWith( + { filter: /.*/ }, + expect.any(Function), + ) + + const result = await onResolveCallback({ + path: 'test.js', + importer: 'index.js', + kind: 'import-statement', + }) + + expect(result).toEqual({ path: 'resolved-1', namespace: 'plugin-1' }) + expect(resolveId1).toHaveBeenCalledWith( + 'test.js', + 'index.js', + { isEntry: false }, + ) + + expect(resolveId2).not.toHaveBeenCalled() + }) + + it('should handle transform from multiple plugins', async () => { + const transform1 = vi.fn((code: string) => `${code}\n// transformed by plugin-1`) + const transform2 = vi.fn((code: string) => `${code}\n// transformed by plugin-2`) + + const unplugin = createUnplugin(() => [ + { + name: 'plugin-1', + transform: transform1, + }, + { + name: 'plugin-2', + transform: transform2, + }, + ]) + + const bunPlugin = unplugin.bun() + let onLoadCallback: Bun.OnLoadCallback + const mockBuild = { + onResolve: vi.fn(), + onLoad: vi.fn((options, callback) => { + if (!onLoadCallback) { + onLoadCallback = callback + } + }), + onStart: vi.fn(), + config: { outdir: './dist' }, + } as never as Bun.PluginBuilder + + const originalFile = Bun.file + + Bun.file = vi.fn().mockReturnValue({ + text: vi.fn().mockResolvedValue('original code'), + }) + + await bunPlugin.setup(mockBuild) + + const result = await onLoadCallback!({ + path: 'test.js', + loader: 'js', + } as Bun.OnLoadArgs) + + expect(result).toEqual({ + contents: 'original code\n// transformed by plugin-1\n// transformed by plugin-2', + loader: 'js', + }) + + expect(transform1).toHaveBeenCalledWith('original code', 'test.js') + expect(transform2).toHaveBeenCalledWith('original code\n// transformed by plugin-1', 'test.js') + + Bun.file = originalFile + }) +}) diff --git a/test/unit-tests/bun/utils.test.ts b/test/unit-tests/bun/utils.test.ts new file mode 100644 index 0000000..ed14c45 --- /dev/null +++ b/test/unit-tests/bun/utils.test.ts @@ -0,0 +1,295 @@ +import type { PluginBuilder } from 'bun' +import { Buffer } from 'node:buffer' +import fs from 'node:fs' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createBuildContext, + createPluginContext, +} from '../../../src/bun/utils' + +vi.mock('node:fs') +vi.mock('node:path') + +describe('bun utils', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + describe('createBuildContext', () => { + it('should create build context with all required methods', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + expect(context.addWatchFile).toBeInstanceOf(Function) + expect(context.getWatchFiles).toBeInstanceOf(Function) + expect(context.emitFile).toBeInstanceOf(Function) + expect(context.parse).toBeInstanceOf(Function) + expect(context.getNativeBuildContext).toBeInstanceOf(Function) + }) + + it('should handle addWatchFile and getWatchFiles', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + expect(context.getWatchFiles()).toEqual([]) + + context.addWatchFile('file1.js') + context.addWatchFile('file2.js') + + expect(context.getWatchFiles()).toEqual(['file1.js', 'file2.js']) + }) + + it('should emit file with fileName', () => { + const mockExistsSync = vi.mocked(fs.existsSync) + const mockWriteFileSync = vi.mocked(fs.writeFileSync) + const mockResolve = vi.mocked(path.resolve) + const mockDirname = vi.mocked(path.dirname) + + mockExistsSync.mockReturnValue(true) + mockResolve.mockReturnValue('/path/to/outdir/output.js') + mockDirname.mockReturnValue('/path/to/outdir') + + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + context.emitFile({ + type: 'asset', + fileName: 'output.js', + source: 'console.log("hello")', + } as any) + + expect(mockResolve).toHaveBeenCalledWith('/path/to/outdir', 'output.js') + expect(mockDirname).toHaveBeenCalledWith('/path/to/outdir/output.js') + expect(mockWriteFileSync).toHaveBeenCalledWith( + '/path/to/outdir/output.js', + 'console.log("hello")', + ) + }) + + it('should emit file with name when fileName is not provided', () => { + const mockExistsSync = vi.mocked(fs.existsSync) + const mockWriteFileSync = vi.mocked(fs.writeFileSync) + const mockResolve = vi.mocked(path.resolve) + const mockDirname = vi.mocked(path.dirname) + + mockExistsSync.mockReturnValue(true) + mockResolve.mockReturnValue('/path/to/outdir/output.js') + mockDirname.mockReturnValue('/path/to/outdir') + + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + context.emitFile({ + type: 'asset', + name: 'output.js', + source: 'console.log("hello")', + } as any) + + expect(mockResolve).toHaveBeenCalledWith('/path/to/outdir', 'output.js') + expect(mockWriteFileSync).toHaveBeenCalledWith( + '/path/to/outdir/output.js', + 'console.log("hello")', + ) + }) + + it('should create directory if it does not exist when emitting file', () => { + const mockExistsSync = vi.mocked(fs.existsSync) + const mockMkdirSync = vi.mocked(fs.mkdirSync) + const mockWriteFileSync = vi.mocked(fs.writeFileSync) + const mockResolve = vi.mocked(path.resolve) + const mockDirname = vi.mocked(path.dirname) + + mockExistsSync.mockReturnValue(false) + mockResolve.mockReturnValue('/path/to/outdir/nested/output.js') + mockDirname.mockReturnValue('/path/to/outdir/nested') + + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + context.emitFile({ + type: 'asset', + fileName: 'nested/output.js', + source: 'console.log("hello")', + } as any) + + expect(mockMkdirSync).toHaveBeenCalledWith('/path/to/outdir/nested', { recursive: true }) + expect(mockWriteFileSync).toHaveBeenCalledWith( + '/path/to/outdir/nested/output.js', + 'console.log("hello")', + ) + }) + + it('should handle Buffer source when emitting file', () => { + const mockExistsSync = vi.mocked(fs.existsSync) + const mockWriteFileSync = vi.mocked(fs.writeFileSync) + const mockResolve = vi.mocked(path.resolve) + const mockDirname = vi.mocked(path.dirname) + + mockExistsSync.mockReturnValue(true) + mockResolve.mockReturnValue('/path/to/outdir/output.bin') + mockDirname.mockReturnValue('/path/to/outdir') + + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + const bufferSource = Buffer.from('binary data') + + context.emitFile({ + type: 'asset', + fileName: 'output.bin', + source: bufferSource, + } as any) + + expect(mockWriteFileSync).toHaveBeenCalledWith( + '/path/to/outdir/output.bin', + bufferSource, + ) + }) + + it('should not emit file when source is missing', () => { + const mockWriteFileSync = vi.mocked(fs.writeFileSync) + + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + context.emitFile({ + type: 'asset', + fileName: 'output.js', + } as any) + + expect(mockWriteFileSync).not.toHaveBeenCalled() + }) + + it('should not emit file when both fileName and name are missing', () => { + const mockWriteFileSync = vi.mocked(fs.writeFileSync) + + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + context.emitFile({ + type: 'asset', + source: 'console.log("hello")', + } as any) + + expect(mockWriteFileSync).not.toHaveBeenCalled() + }) + + it('should not emit file when outdir is not configured', () => { + const mockWriteFileSync = vi.mocked(fs.writeFileSync) + + const mockBuild = { config: {} } + const context = createBuildContext(mockBuild as PluginBuilder) + + context.emitFile({ + type: 'asset', + fileName: 'output.js', + source: 'console.log("hello")', + } as any) + + expect(mockWriteFileSync).not.toHaveBeenCalled() + }) + + it('should parse code with acorn', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + const ast = context.parse('const x = 1') + expect(ast).toBeDefined() + expect(ast.type).toBe('Program') + expect((ast as any).body).toHaveLength(1) + expect((ast as any).body[0].type).toBe('VariableDeclaration') + }) + + it('should parse code with custom options', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + const ast = context.parse('const x = 1', { + sourceType: 'script', + ecmaVersion: 2015, + }) + expect(ast).toBeDefined() + expect(ast.type).toBe('Program') + }) + + it('should return native build context', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const context = createBuildContext(mockBuild as PluginBuilder) + + const nativeContext = context.getNativeBuildContext!() + expect(nativeContext).toEqual({ + framework: 'bun', + build: mockBuild, + }) + }) + }) + + describe('createPluginContext', () => { + it('should create plugin context with error and warn methods', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const buildContext = createBuildContext(mockBuild as PluginBuilder) + const pluginContext = createPluginContext(buildContext) + + expect(pluginContext.errors).toEqual([]) + expect(pluginContext.warnings).toEqual([]) + expect(pluginContext.mixedContext).toBeDefined() + expect(pluginContext.mixedContext.error).toBeInstanceOf(Function) + expect(pluginContext.mixedContext.warn).toBeInstanceOf(Function) + }) + + it('should collect errors when error is called', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const buildContext = createBuildContext(mockBuild as PluginBuilder) + const pluginContext = createPluginContext(buildContext) + + pluginContext.mixedContext.error('Error message') + expect(pluginContext.errors).toHaveLength(1) + expect(pluginContext.errors[0]).toBe('Error message') + + pluginContext.mixedContext.error('Another error') + expect(pluginContext.errors).toHaveLength(2) + expect(pluginContext.errors[1]).toBe('Another error') + }) + + it('should collect warnings when warn is called', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const buildContext = createBuildContext(mockBuild as PluginBuilder) + const pluginContext = createPluginContext(buildContext) + + pluginContext.mixedContext.warn('Warning message') + expect(pluginContext.warnings).toHaveLength(1) + expect(pluginContext.warnings[0]).toBe('Warning message') + + pluginContext.mixedContext.warn('Another warning') + expect(pluginContext.warnings).toHaveLength(2) + expect(pluginContext.warnings[1]).toBe('Another warning') + }) + + it('should include build context methods in mixed context', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const buildContext = createBuildContext(mockBuild as PluginBuilder) + const pluginContext = createPluginContext(buildContext) + + expect(pluginContext.mixedContext.addWatchFile).toBeInstanceOf(Function) + expect(pluginContext.mixedContext.getWatchFiles).toBeInstanceOf(Function) + expect(pluginContext.mixedContext.emitFile).toBeInstanceOf(Function) + expect(pluginContext.mixedContext.parse).toBeInstanceOf(Function) + }) + + it('should handle complex error objects', () => { + const mockBuild = { config: { outdir: '/path/to/outdir' } } + const buildContext = createBuildContext(mockBuild as PluginBuilder) + const pluginContext = createPluginContext(buildContext) + + const errorObj = { + message: 'Complex error', + code: 'ERR_001', + stack: 'Error stack trace', + } + + pluginContext.mixedContext.error(errorObj) + expect(pluginContext.errors).toHaveLength(1) + expect(pluginContext.errors[0]).toEqual(errorObj) + }) + }) +}) diff --git a/test/unit-tests/filter/.gitignore b/test/unit-tests/filter/.gitignore new file mode 100644 index 0000000..44cb941 --- /dev/null +++ b/test/unit-tests/filter/.gitignore @@ -0,0 +1 @@ +test-out diff --git a/test/unit-tests/filter/filter.test.ts b/test/unit-tests/filter/filter.test.ts index 0281079..7977271 100644 --- a/test/unit-tests/filter/filter.test.ts +++ b/test/unit-tests/filter/filter.test.ts @@ -3,6 +3,7 @@ import type { Mock } from 'vitest' import * as path from 'node:path' import { createUnplugin } from 'unplugin' import { afterEach, describe, expect, it, vi } from 'vitest' +import { onlyBun } from '../../utils' import { build, toArray } from '../utils' function createUnpluginWithHooks( @@ -169,4 +170,19 @@ describe('filter', () => { check(resolveIdHandler, loadHandler, transformHandler) }) + + onlyBun('bun', async () => { + const { hook: resolveId, handler: resolveIdHandler } = createIdHook() + const { hook: load, handler: loadHandler } = createIdHook() + const { hook: transform, handler: transformHandler } = createTransformHook() + const plugin = createUnpluginWithHooks(resolveId, load, transform).bun + + await build.bun({ + entrypoints: [path.resolve(__dirname, 'test-src/entry.js')], + plugins: [plugin()], + outdir: path.resolve(__dirname, 'test-out/bun'), + }) + + check(resolveIdHandler, loadHandler, transformHandler) + }) }) diff --git a/test/unit-tests/id-consistency/.gitignore b/test/unit-tests/id-consistency/.gitignore new file mode 100644 index 0000000..44cb941 --- /dev/null +++ b/test/unit-tests/id-consistency/.gitignore @@ -0,0 +1 @@ +test-out diff --git a/test/unit-tests/id-consistency/id-consistency.test.ts b/test/unit-tests/id-consistency/id-consistency.test.ts index 0b5e7af..07d1bf4 100644 --- a/test/unit-tests/id-consistency/id-consistency.test.ts +++ b/test/unit-tests/id-consistency/id-consistency.test.ts @@ -3,6 +3,7 @@ import type { Mock } from 'vitest' import * as path from 'node:path' import { createUnplugin } from 'unplugin' import { afterEach, describe, expect, it, vi } from 'vitest' +import { onlyBun } from '../../utils' import { build, toArray } from '../utils' const entryFilePath = path.resolve(__dirname, './test-src/entry.js') @@ -25,7 +26,7 @@ function createUnpluginWithCallback( // We extract this check because all bundlers should behave the same function checkHookCalls( - name: 'webpack' | 'rollup' | 'vite' | 'rspack' | 'esbuild', + name: 'webpack' | 'rollup' | 'vite' | 'rspack' | 'esbuild' | 'bun', resolveIdCallback: Mock, transformIncludeCallback: Mock, transformCallback: Mock, @@ -213,4 +214,27 @@ describe('id parameter should be consistent across hooks and plugins', () => { checkHookCalls('esbuild', mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook) }) + + onlyBun('bun', async () => { + const mockResolveIdHook = vi.fn(() => undefined) + const mockTransformIncludeHook = vi.fn(() => true) + const mockTransformHook = vi.fn(() => undefined) + const mockLoadHook = vi.fn(() => undefined) + + const plugin = createUnpluginWithCallback( + mockResolveIdHook, + mockTransformIncludeHook, + mockTransformHook, + mockLoadHook, + ).bun + + await build.bun({ + entrypoints: [entryFilePath], + plugins: [plugin()], + external: externals, + outdir: path.resolve(__dirname, 'test-out/bun'), + }) + + checkHookCalls('bun', mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook) + }) }) diff --git a/test/unit-tests/resolve-id-external/.gitignore b/test/unit-tests/resolve-id-external/.gitignore new file mode 100644 index 0000000..44cb941 --- /dev/null +++ b/test/unit-tests/resolve-id-external/.gitignore @@ -0,0 +1 @@ +test-out diff --git a/test/unit-tests/resolve-id-external/resolve-id-external.test.ts b/test/unit-tests/resolve-id-external/resolve-id-external.test.ts index 2c4a44d..4c727cb 100644 --- a/test/unit-tests/resolve-id-external/resolve-id-external.test.ts +++ b/test/unit-tests/resolve-id-external/resolve-id-external.test.ts @@ -2,6 +2,7 @@ import type { VitePlugin } from 'unplugin' import * as path from 'node:path' import { createUnplugin } from 'unplugin' import { afterEach, describe, expect, it, vi } from 'vitest' +import { onlyBun } from '../../utils' import { build, toArray } from '../utils' const entryFilePath = path.resolve(__dirname, './test-src/entry.js') @@ -141,4 +142,17 @@ describe('load hook should not be called when resolveId hook returned `external: checkHookCalls() }) + + onlyBun('bun', async () => { + const plugin = createMockedUnplugin().bun + + await build.bun({ + entrypoints: [entryFilePath], + plugins: [plugin()], + external: externals, + outdir: path.resolve(__dirname, 'test-out/bun'), + }) + + checkHookCalls() + }) }) diff --git a/test/unit-tests/resolve-id/.gitignore b/test/unit-tests/resolve-id/.gitignore new file mode 100644 index 0000000..44cb941 --- /dev/null +++ b/test/unit-tests/resolve-id/.gitignore @@ -0,0 +1 @@ +test-out diff --git a/test/unit-tests/resolve-id/resolve-id.test.ts b/test/unit-tests/resolve-id/resolve-id.test.ts index 6bd7def..818f260 100644 --- a/test/unit-tests/resolve-id/resolve-id.test.ts +++ b/test/unit-tests/resolve-id/resolve-id.test.ts @@ -3,6 +3,7 @@ import type { Mock } from 'vitest' import * as path from 'node:path' import { createUnplugin } from 'unplugin' import { afterEach, describe, expect, it, vi } from 'vitest' +import { onlyBun } from '../../utils' import { build, toArray } from '../utils' function createUnpluginWithCallback(resolveIdCallback: UnpluginOptions['resolveId']) { @@ -138,4 +139,17 @@ describe('resolveId hook', () => { checkResolveIdHook(mockResolveIdHook) }) + + onlyBun('bun', async () => { + const mockResolveIdHook = createResolveIdHook() + const plugin = createUnpluginWithCallback(mockResolveIdHook).bun + + await build.bun({ + entrypoints: [path.resolve(__dirname, 'test-src/entry.js')], + plugins: [plugin()], + outdir: path.resolve(__dirname, 'test-out/bun'), // Bun requires outdir + }) + + checkResolveIdHook(mockResolveIdHook) + }) }) diff --git a/test/unit-tests/utils.ts b/test/unit-tests/utils.ts index dfa6e26..f29c166 100644 --- a/test/unit-tests/utils.ts +++ b/test/unit-tests/utils.ts @@ -13,6 +13,11 @@ export const rolldownBuild: typeof rolldown.build = rolldown.build export const esbuildBuild: typeof esbuild.build = esbuild.build export const webpackBuild: typeof webpack.webpack = webpack.webpack || (webpack as any).default || webpack export const rspackBuild: typeof rspack.rspack = rspack.rspack +export const bunBuild: typeof Bun.build = typeof Bun !== 'undefined' + ? Bun.build + : () => { + throw new ReferenceError('Bun.build does not exist in this environment. Please run your app with the Bun runtime.') + } export const webpackVersion: string = ((webpack as any).default || webpack).version @@ -23,6 +28,7 @@ export const build: { rolldown: typeof rolldownBuild vite: typeof viteBuild esbuild: typeof esbuildBuild + bun: typeof bunBuild } = { webpack: webpackBuild, rspack: rspackBuild, @@ -39,4 +45,5 @@ export const build: { })) }, esbuild: esbuildBuild, + bun: bunBuild, } diff --git a/test/unit-tests/virtual-id/virtual-id.test.ts b/test/unit-tests/virtual-id/virtual-id.test.ts index 1b6367c..ee3485c 100644 --- a/test/unit-tests/virtual-id/virtual-id.test.ts +++ b/test/unit-tests/virtual-id/virtual-id.test.ts @@ -4,6 +4,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { createUnplugin } from 'unplugin' import { afterEach, describe, expect, it, vi } from 'vitest' +import { onlyBun } from '../../utils' import { build, toArray } from '../utils' function createUnpluginWithCallbacks(resolveIdCallback: UnpluginOptions['resolveId'], loadCallback: UnpluginOptions['load']) { @@ -158,4 +159,18 @@ describe('virtual ids', () => { checkResolveIdHook(mockResolveIdHook) checkLoadHook(mockLoadHook) }) + + onlyBun('bun', async () => { + const mockResolveIdHook = createResolveIdHook() + const mockLoadHook = createLoadHook() + const plugin = createUnpluginWithCallbacks(mockResolveIdHook, mockLoadHook).bun + + await build.bun({ + entrypoints: [path.resolve(__dirname, 'test-src/entry.js')], + plugins: [plugin()], + }) + + checkResolveIdHook(mockResolveIdHook) + checkLoadHook(mockLoadHook) + }) }) diff --git a/test/unit-tests/write-bundle/write-bundle.test.ts b/test/unit-tests/write-bundle/write-bundle.test.ts index 5157f04..8efc093 100644 --- a/test/unit-tests/write-bundle/write-bundle.test.ts +++ b/test/unit-tests/write-bundle/write-bundle.test.ts @@ -5,6 +5,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { createUnplugin } from 'unplugin' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { onlyBun } from '../../utils' import { build, toArray, webpackVersion } from '../utils' function createUnpluginWithCallback(writeBundleCallback: UnpluginOptions['writeBundle']) { @@ -166,4 +167,20 @@ describe('writeBundle hook', () => { checkWriteBundleHook(mockResolveIdHook) }) + + onlyBun('bun', async () => { + expect.assertions(3) + const mockResolveIdHook = vi.fn(generateMockWriteBundleHook(path.resolve(__dirname, 'test-out/bun'))) + const plugin = createUnpluginWithCallback(mockResolveIdHook).bun + + await build.bun({ + entrypoints: [path.resolve(__dirname, 'test-src/entry.js')], + plugins: [plugin()], + outdir: path.resolve(__dirname, 'test-out/bun'), + naming: 'output.[ext]', + sourcemap: 'external', + }) + + checkWriteBundleHook(mockResolveIdHook) + }) }) diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..58d8de5 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,3 @@ +import { it } from 'vitest' + +export const onlyBun: typeof it.only | typeof it.skip = typeof Bun !== 'undefined' ? it.only : it.skip diff --git a/tsconfig.json b/tsconfig.json index 07caa1d..cc6a6ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,14 +5,10 @@ "moduleDetection": "force", "module": "preserve", "moduleResolution": "bundler", - "paths": { - "unplugin": [ - "./src/index.ts" - ] - }, "resolveJsonModule": true, "types": [ - "node" + "node", + "bun-types-no-globals" ], "strict": true, "noUnusedLocals": true,