From d35e0552d06118a0efc39b02641fcc4f3176fdd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20=28Kevin=29?= Date: Tue, 18 Oct 2022 11:45:30 +0800 Subject: [PATCH] feat: support nested plugins (#176) Co-authored-by: Anthony Fu --- README.md | 37 +- package.json | 4 +- pnpm-lock.yaml | 36 +- scripts/buildFixtures.ts | 2 +- src/esbuild/index.ts | 328 +++++++++--------- src/esbuild/utils.ts | 2 + src/rollup/index.ts | 6 +- src/types.ts | 22 +- src/utils.ts | 16 + src/vite/index.ts | 16 +- src/webpack/index.ts | 323 ++++++++--------- .../fixtures/transform/__test__/build.test.ts | 17 +- test/fixtures/transform/unplugin.js | 89 +++-- .../id-consistency/id-consistency.test.ts | 8 +- .../resolve-id-external.test.ts | 8 +- test/unit-tests/resolve-id/resolve-id.test.ts | 8 +- test/unit-tests/utils.ts | 2 + 17 files changed, 524 insertions(+), 400 deletions(-) diff --git a/README.md b/README.md index ac915fdc..83d534be 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,42 @@ export const webpackPlugin = unplugin.webpack export const esbuildPlugin = unplugin.esbuild ``` -### Plugin Installation +## Nested Plugins + +Since `v0.10.0`, unplugin supports constructing multiple nested plugins to behave like a single one. For example: + +###### Supported + +| Rollup | Vite | Webpack 4 | Webpack 5 | esbuild | +| :----: | :--: | :-------: | :-------: | :-----: | +| ✅ `>=3.1` | ✅ | ✅ | ✅ | ⚠️5 | + +5. Since esbuild does not have a built-in transform phase, the `transform` hook of nested plugin will not work on esbuild yet. Other hooks like `load` or `resolveId` work fine. We will try to find a way to support it in the future. + +###### Usage + +```ts +import { createUnplugin } from 'unplugin' + +export const unplugin = createUnplugin((options: UserOptions) => { + return [ + { + name: 'plugin-a', + transform (code) { + // ... + } + }, + { + name: 'plugin-b', + resolveId (id) { + // ... + } + } + ] +}) +``` + +## Plugin Installation ###### Vite diff --git a/package.json b/package.json index f04353ef..cf700170 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@nuxtjs/eslint-config-typescript": "^11.0.0", "@types/express": "^4.17.14", "@types/fs-extra": "^9.0.13", - "@types/node": "^18.7.16", + "@types/node": "^18.11.0", "@types/webpack-sources": "^3.2.0", "bumpp": "^8.2.1", "conventional-changelog-cli": "^2.2.2", @@ -53,7 +53,7 @@ "jiti": "^1.16.0", "magic-string": "^0.26.7", "picocolors": "^1.0.0", - "rollup": "^2.79.1", + "rollup": "^3.2.2", "tsup": "^6.3.0", "typescript": "^4.8.4", "vite": "^3.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca97c42..48383c17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ specifiers: '@nuxtjs/eslint-config-typescript': ^11.0.0 '@types/express': ^4.17.14 '@types/fs-extra': ^9.0.13 - '@types/node': ^18.7.16 + '@types/node': ^18.11.0 '@types/webpack-sources': ^3.2.0 acorn: ^8.8.0 bumpp: ^8.2.1 @@ -20,7 +20,7 @@ specifiers: jiti: ^1.16.0 magic-string: ^0.26.7 picocolors: ^1.0.0 - rollup: ^2.79.1 + rollup: ^3.2.2 tsup: ^6.3.0 typescript: ^4.8.4 vite: ^3.1.8 @@ -42,7 +42,7 @@ devDependencies: '@nuxtjs/eslint-config-typescript': 11.0.0_z4bbprzjrhnsfa24uvmcbu7f5q '@types/express': 4.17.14 '@types/fs-extra': 9.0.13 - '@types/node': 18.7.16 + '@types/node': 18.11.0 '@types/webpack-sources': 3.2.0 bumpp: 8.2.1 conventional-changelog-cli: 2.2.2 @@ -54,7 +54,7 @@ devDependencies: jiti: 1.16.0 magic-string: 0.26.7 picocolors: 1.0.0 - rollup: 2.79.1 + rollup: 3.2.2 tsup: 6.3.0_typescript@4.8.4 typescript: 4.8.4 vite: 3.1.8 @@ -281,7 +281,7 @@ packages: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 18.7.16 + '@types/node': 18.11.0 dev: true /@types/chai-subset/1.3.3: @@ -297,7 +297,7 @@ packages: /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 18.7.16 + '@types/node': 18.11.0 dev: true /@types/eslint-scope/3.7.3: @@ -321,7 +321,7 @@ packages: /@types/express-serve-static-core/4.17.27: resolution: {integrity: sha512-e/sVallzUTPdyOTiqi8O8pMdBBphscvI6E4JYaKlja4Lm+zh7UFSSdW5VMkRbhDtmrONqOUHOXRguPsDckzxNA==} dependencies: - '@types/node': 18.7.16 + '@types/node': 18.11.0 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 dev: true @@ -338,7 +338,7 @@ packages: /@types/fs-extra/9.0.13: resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} dependencies: - '@types/node': 18.7.16 + '@types/node': 18.11.0 dev: true /@types/json-schema/7.0.9: @@ -357,8 +357,8 @@ packages: resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} dev: true - /@types/node/18.7.16: - resolution: {integrity: sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg==} + /@types/node/18.11.0: + resolution: {integrity: sha512-IOXCvVRToe7e0ny7HpT/X9Rb2RYtElG1a+VshjwT00HxrM2dWBApHQoqsI6WiY7Q03vdf2bCrIGzVrkF/5t10w==} dev: true /@types/normalize-package-data/2.4.1: @@ -377,7 +377,7 @@ packages: resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==} dependencies: '@types/mime': 1.3.2 - '@types/node': 18.7.16 + '@types/node': 18.11.0 dev: true /@types/source-list-map/0.1.2: @@ -387,7 +387,7 @@ packages: /@types/webpack-sources/3.2.0: resolution: {integrity: sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==} dependencies: - '@types/node': 18.7.16 + '@types/node': 18.11.0 '@types/source-list-map': 0.1.2 source-map: 0.7.3 dev: true @@ -2552,7 +2552,7 @@ packages: resolution: {integrity: sha512-f2s8kEdy15cv9r7q4KkzGXvlY0JTcmCbMHZBfSQDwW77REr45IDWwd0lksDFeVHH2jJ5pqb90T77XscrjeGzzg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 18.7.16 + '@types/node': 18.11.0 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true @@ -3344,6 +3344,14 @@ packages: fsevents: 2.3.2 dev: true + /rollup/3.2.2: + resolution: {integrity: sha512-tw8NITEB/A8aa8F+mmIJ7fQ7Abej0R9ugR1ZzsCqb7P8HWVIVdneN69BMTDjhk0qbUsewDSJSDTcVuCTogs8JA==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /run-parallel/1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -3983,7 +3991,7 @@ packages: dependencies: '@types/chai': 4.3.3 '@types/chai-subset': 1.3.3 - '@types/node': 18.7.16 + '@types/node': 18.11.0 chai: 4.3.6 debug: 4.3.4 local-pkg: 0.4.2 diff --git a/scripts/buildFixtures.ts b/scripts/buildFixtures.ts index 8c48b613..57553f85 100644 --- a/scripts/buildFixtures.ts +++ b/scripts/buildFixtures.ts @@ -22,7 +22,7 @@ async function run () { execSync('npx vite build', { cwd: path, stdio: 'inherit' }) console.log(c.red(c.inverse(c.bold('\n Rollup '))), name, '\n') execSync('npx rollup --version', { cwd: path, stdio: 'inherit' }) - execSync('npx rollup -c', { cwd: path, stdio: 'inherit' }) + execSync('npx rollup --bundleConfigAsCjs -c', { cwd: path, stdio: 'inherit' }) console.log(c.blue(c.inverse(c.bold('\n Webpack '))), name, '\n') execSync('npx webpack --version', { cwd: path, stdio: 'inherit' }) execSync('npx webpack', { cwd: path, stdio: 'inherit' }) diff --git a/src/esbuild/index.ts b/src/esbuild/index.ts index 7a71098f..a509052c 100644 --- a/src/esbuild/index.ts +++ b/src/esbuild/index.ts @@ -5,198 +5,208 @@ import type { PartialMessage } from 'esbuild' import type { SourceMap } from 'rollup' import { Parser } from 'acorn' import { RawSourceMap } from '@ampproject/remapping' -import type { UnpluginBuildContext, UnpluginContext, UnpluginContextMeta, UnpluginFactory, UnpluginInstance } from '../types' -import { combineSourcemaps, fixSourceMap, guessLoader } from './utils' +import type { EsbuildPlugin, UnpluginBuildContext, UnpluginContext, UnpluginContextMeta, UnpluginFactory, UnpluginInstance, UnpluginOptions } from '../types' +import { toArray, combineSourcemaps, fixSourceMap, guessLoader } from './utils' const watchListRecord: Record = {} const watchList: Set = new Set() +let i = 0 + export function getEsbuildPlugin ( factory: UnpluginFactory ): UnpluginInstance['esbuild'] { - return (userOptions?: UserOptions) => { + return (userOptions?: UserOptions): EsbuildPlugin => { const meta: UnpluginContextMeta = { framework: 'esbuild' } - const plugin = factory(userOptions!, meta) - - return { - name: plugin.name, - setup: - plugin.esbuild?.setup ?? - function setup ({ onStart, onEnd, onResolve, onLoad, initialOptions, esbuild: { build } }) { - const onResolveFilter = plugin.esbuild?.onResolveFilter ?? /.*/ - const onLoadFilter = plugin.esbuild?.onLoadFilter ?? /.*/ - - const context:UnpluginBuildContext = { - parse (code: string, opts: any = {}) { - return Parser.parse(code, { - sourceType: 'module', - ecmaVersion: 'latest', - locations: true, - ...opts - }) - }, - addWatchFile (id) { - watchList.add(path.resolve(id)) - }, - emitFile (emittedFile) { - const outFileName = emittedFile.fileName || emittedFile.name - if (initialOptions.outdir && emittedFile.source && outFileName) { - fs.writeFileSync(path.resolve(initialOptions.outdir, outFileName), emittedFile.source) - } - }, - getWatchFiles () { - return Array.from(watchList) + const plugins = toArray(factory(userOptions!, meta)) + + const setup = (plugin: UnpluginOptions): EsbuildPlugin['setup'] => + plugin.esbuild?.setup ?? + (({ onStart, onEnd, onResolve, onLoad, initialOptions, esbuild: { build } }) => { + const onResolveFilter = plugin.esbuild?.onResolveFilter ?? /.*/ + const onLoadFilter = plugin.esbuild?.onLoadFilter ?? /.*/ + + const context: UnpluginBuildContext = { + parse (code: string, opts: any = {}) { + return Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts + }) + }, + addWatchFile (id) { + watchList.add(path.resolve(id)) + }, + emitFile (emittedFile) { + const outFileName = emittedFile.fileName || emittedFile.name + if (initialOptions.outdir && emittedFile.source && outFileName) { + fs.writeFileSync(path.resolve(initialOptions.outdir, outFileName), emittedFile.source) } + }, + getWatchFiles () { + return Array.from(watchList) } + } - // Ensure output directory exists for this.emitFile - if (initialOptions.outdir && !fs.existsSync(initialOptions.outdir)) { - fs.mkdirSync(initialOptions.outdir, { recursive: true }) - } + // Ensure output directory exists for this.emitFile + if (initialOptions.outdir && !fs.existsSync(initialOptions.outdir)) { + fs.mkdirSync(initialOptions.outdir, { recursive: true }) + } - if (plugin.buildStart) { - onStart(() => plugin.buildStart!.call(context)) - } + if (plugin.buildStart) { + onStart(() => plugin.buildStart!.call(context)) + } - if (plugin.buildEnd || initialOptions.watch) { - const rebuild = () => build({ - ...initialOptions, - watch: false - }) + if (plugin.buildEnd || initialOptions.watch) { + const rebuild = () => build({ + ...initialOptions, + watch: false + }) + + onEnd(async () => { + await plugin.buildEnd!.call(context) + if (initialOptions.watch) { + Object.keys(watchListRecord).forEach((id) => { + if (!watchList.has(id)) { + watchListRecord[id].close() + delete watchListRecord[id] + } + }) + watchList.forEach((id) => { + if (!Object.keys(watchListRecord).includes(id)) { + watchListRecord[id] = chokidar.watch(id) + watchListRecord[id].on('change', async () => { + await plugin.watchChange?.call(context, id, { event: 'update' }) + rebuild() + }) + watchListRecord[id].on('unlink', async () => { + await plugin.watchChange?.call(context, id, { event: 'delete' }) + rebuild() + }) + } + }) + } + }) + } - onEnd(async () => { - await plugin.buildEnd!.call(context) - if (initialOptions.watch) { - Object.keys(watchListRecord).forEach((id) => { - if (!watchList.has(id)) { - watchListRecord[id].close() - delete watchListRecord[id] - } - }) - watchList.forEach((id) => { - if (!Object.keys(watchListRecord).includes(id)) { - watchListRecord[id] = chokidar.watch(id) - watchListRecord[id].on('change', async () => { - await plugin.watchChange?.call(context, id, { event: 'update' }) - rebuild() - }) - watchListRecord[id].on('unlink', async () => { - await plugin.watchChange?.call(context, id, { event: 'delete' }) - rebuild() - }) - } - }) - } - }) - } + if (plugin.resolveId) { + onResolve({ filter: onResolveFilter }, async (args) => { + if (initialOptions.external?.includes(args.path)) { + // We don't want to call the `resolveId` hook for external modules, since rollup doesn't do + // that and we want to have consistent behaviour across bundlers + return undefined + } - if (plugin.resolveId) { - onResolve({ filter: onResolveFilter }, async (args) => { - if (initialOptions.external?.includes(args.path)) { - // We don't want to call the `resolveId` hook for external modules, since rollup doesn't do - // that and we want to have consistent behaviour across bundlers - return undefined - } + const isEntry = args.kind === 'entry-point' + const result = await plugin.resolveId!( + args.path, + // We explicitly have this if statement here for consistency with the integration of other bundelers. + // Here, `args.importer` is just an empty string on entry files whereas the euqivalent on other bundlers is `undefined.` + isEntry ? undefined : args.importer, + { isEntry } + ) + if (typeof result === 'string') { + return { path: result, namespace: plugin.name } + } else if (typeof result === 'object' && result !== null) { + return { path: result.id, external: result.external, namespace: plugin.name } + } + }) + } - const isEntry = args.kind === 'entry-point' - const result = await plugin.resolveId!( - args.path, - // We explicitly have this if statement here for consistency with the integration of other bundelers. - // Here, `args.importer` is just an empty string on entry files whereas the euqivalent on other bundlers is `undefined.` - isEntry ? undefined : args.importer, - { isEntry } - ) + if (plugin.load || plugin.transform) { + onLoad({ filter: onLoadFilter }, async (args) => { + const id = args.path + args.suffix + + const errors: PartialMessage[] = [] + const warnings: PartialMessage[] = [] + const pluginContext: UnpluginContext = { + error (message) { errors.push({ text: String(message) }) }, + warn (message) { warnings.push({ text: String(message) }) } + } + // because we use `namespace` to simulate virtual modules, + // it is required to forward `resolveDir` for esbuild to find dependencies. + const resolveDir = path.dirname(args.path) + + let code: string | undefined, map: SourceMap | null | undefined + + if (plugin.load && (!plugin.loadInclude || plugin.loadInclude(id))) { + const result = await plugin.load.call(Object.assign(context, pluginContext), id) if (typeof result === 'string') { - return { path: result, namespace: plugin.name } + code = result } else if (typeof result === 'object' && result !== null) { - return { path: result.id, external: result.external, namespace: plugin.name } + code = result.code + map = result.map as any } - }) - } - - if (plugin.load || plugin.transform) { - onLoad({ filter: onLoadFilter }, async (args) => { - const id = args.path + args.suffix + } - const errors: PartialMessage[] = [] - const warnings: PartialMessage[] = [] - const pluginContext: UnpluginContext = { - error (message) { errors.push({ text: String(message) }) }, - warn (message) { warnings.push({ text: String(message) }) } + if (!plugin.transform) { + if (code === undefined) { + return null } - // because we use `namespace` to simulate virtual modules, - // it is required to forward `resolveDir` for esbuild to find dependencies. - const resolveDir = path.dirname(args.path) - - let code: string | undefined, map: SourceMap | null | undefined - - if (plugin.load && (!plugin.loadInclude || plugin.loadInclude(id))) { - const result = await plugin.load.call(Object.assign(context, pluginContext), id) - if (typeof result === 'string') { - code = result - } else if (typeof result === 'object' && result !== null) { - code = result.code - map = result.map + if (map) { + // fix missing sourcesContent, esbuild depends on it + if (!map.sourcesContent || map.sourcesContent.length === 0) { + map.sourcesContent = [code] } + map = fixSourceMap(map as RawSourceMap) + code += `\n//# sourceMappingURL=${map.toUrl()}` } + return { contents: code, errors, warnings, loader: guessLoader(args.path), resolveDir } + } - if (!plugin.transform) { - if (code === undefined) { - return null - } - if (map) { - // fix missing sourcesContent, esbuild depends on it - if (!map.sourcesContent || map.sourcesContent.length === 0) { - map.sourcesContent = [code] - } - map = fixSourceMap(map as RawSourceMap) - code += `\n//# sourceMappingURL=${map.toUrl()}` - } - return { contents: code, errors, warnings, loader: guessLoader(args.path), resolveDir } + if (!plugin.transformInclude || plugin.transformInclude(id)) { + if (!code) { + // caution: 'utf8' assumes the input file is not in binary. + // if you want your plugin handle binary files, make sure to + // `plugin.load()` them first. + code = await fs.promises.readFile(args.path, 'utf8') } - if (!plugin.transformInclude || plugin.transformInclude(id)) { - if (!code) { - // caution: 'utf8' assumes the input file is not in binary. - // if you want your plugin handle binary files, make sure to - // `plugin.load()` them first. - code = await fs.promises.readFile(args.path, 'utf8') - } - - const result = await plugin.transform.call(Object.assign(context, pluginContext), code, id) - if (typeof result === 'string') { - code = result - } else if (typeof result === 'object' && result !== null) { - code = result.code - // if we already got sourcemap from `load()`, - // combine the two sourcemaps - if (map && result.map) { - map = combineSourcemaps(args.path, [ - result.map as RawSourceMap, - map as RawSourceMap - ]) as SourceMap - } else { - // otherwise, we always keep the last one, even if it's empty - map = result.map - } + const result = await plugin.transform.call(Object.assign(context, pluginContext), code, id) + if (typeof result === 'string') { + code = result + } else if (typeof result === 'object' && result !== null) { + code = result.code + // if we already got sourcemap from `load()`, + // combine the two sourcemaps + if (map && result.map) { + map = combineSourcemaps(args.path, [ + result.map as RawSourceMap, + map as RawSourceMap + ]) as SourceMap + } else { + // otherwise, we always keep the last one, even if it's empty + map = result.map as any } } + } - if (code) { - if (map) { - if (!map.sourcesContent || map.sourcesContent.length === 0) { - map.sourcesContent = [code] - } - map = fixSourceMap(map as RawSourceMap) - code += `\n//# sourceMappingURL=${map.toUrl()}` + if (code) { + if (map) { + if (!map.sourcesContent || map.sourcesContent.length === 0) { + map.sourcesContent = [code] } - return { contents: code, errors, warnings, loader: guessLoader(args.path), resolveDir } + map = fixSourceMap(map as RawSourceMap) + code += `\n//# sourceMappingURL=${map.toUrl()}` } - }) - } + return { contents: code, errors, warnings, loader: guessLoader(args.path), resolveDir } + } + }) } - } + }) + + const setupMultiplePlugins = ():EsbuildPlugin['setup'] => + (build) => { + for (const plugin of plugins) { + setup(plugin)(build) + } + } + + return plugins.length === 1 + ? { name: plugins[0].name, setup: setup(plugins[0]) } + : { name: meta.esbuildHostName ?? `unplugin-host-${i++}`, setup: setupMultiplePlugins() } } } diff --git a/src/esbuild/utils.ts b/src/esbuild/utils.ts index 6e6315f3..c146218b 100644 --- a/src/esbuild/utils.ts +++ b/src/esbuild/utils.ts @@ -7,6 +7,8 @@ import type { import type { Loader } from 'esbuild' import type { SourceMap } from 'rollup' +export * from '../utils' + const ExtToLoader: Record = { '.js': 'js', '.mjs': 'js', diff --git a/src/rollup/index.ts b/src/rollup/index.ts index d3539d41..feb9db1c 100644 --- a/src/rollup/index.ts +++ b/src/rollup/index.ts @@ -1,4 +1,5 @@ import { UnpluginInstance, UnpluginFactory, UnpluginOptions, RollupPlugin, UnpluginContextMeta } from '../types' +import { toArray } from '../utils' export function getRollupPlugin ( factory: UnpluginFactory @@ -7,8 +8,9 @@ export function getRollupPlugin ( const meta: UnpluginContextMeta = { framework: 'rollup' } - const rawPlugin = factory(userOptions!, meta) - return toRollupPlugin(rawPlugin) + const rawPlugins = toArray(factory(userOptions!, meta)) + const plugins = rawPlugins.map(plugin => toRollupPlugin(plugin)) + return plugins.length === 1 ? plugins[0] : plugins } } diff --git a/src/types.ts b/src/types.ts index 12d7ef36..b4f261a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import type { Compiler as WebpackCompiler, WebpackPluginInstance } from 'webpack import type { Plugin as VitePlugin } from 'vite' import type { Plugin as EsbuildPlugin } from 'esbuild' import type VirtualModulesPlugin from 'webpack-virtual-modules' +import type { Arrayable } from './utils' export { EsbuildPlugin, @@ -65,25 +66,32 @@ export interface ResolvedUnpluginOptions extends UnpluginOptions { __virtualModulePrefix: string } -export type UnpluginFactory = (options: UserOptions, meta: UnpluginContextMeta) => UnpluginOptions +export type UnpluginFactory = (options: UserOptions, meta: UnpluginContextMeta) => + Arrayable export type UnpluginFactoryOutput = undefined extends UserOptions ? (options?: UserOptions) => Return : (options: UserOptions) => Return export interface UnpluginInstance { - rollup: UnpluginFactoryOutput + rollup: UnpluginFactoryOutput> + vite: UnpluginFactoryOutput> webpack: UnpluginFactoryOutput - vite: UnpluginFactoryOutput esbuild: UnpluginFactoryOutput raw: UnpluginFactory } -export interface UnpluginContextMeta extends Partial { - framework: 'rollup' | 'vite' | 'webpack' | 'esbuild' - webpack?: { +export type UnpluginContextMeta = Partial & ({ + framework: 'rollup' | 'vite' +} | { + framework: 'webpack' + webpack: { compiler: WebpackCompiler } -} +} | { + framework: 'esbuild' + /** Set the host plugin name of esbuild when returning multiple plugins */ + esbuildHostName?: string, +}) export interface UnpluginContext { error(message: any): void diff --git a/src/utils.ts b/src/utils.ts index 85809f19..7292343e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,3 +17,19 @@ export function normalizeAbsolutePath (path: string) { return path } } + +/** + * Null or whatever + */ +export type Nullable = T | null | undefined + +/** + * Array, or not yet + */ +export type Arrayable = T | Array + +export function toArray (array?: Nullable>): Array { + array = array || [] + if (Array.isArray(array)) { return array } + return [array] +} diff --git a/src/vite/index.ts b/src/vite/index.ts index 21b3bb98..a1f44112 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -1,5 +1,6 @@ import { toRollupPlugin } from '../rollup' import { UnpluginInstance, UnpluginFactory, VitePlugin, UnpluginContextMeta } from '../types' +import { toArray } from '../utils' export function getVitePlugin ( factory: UnpluginFactory @@ -8,13 +9,16 @@ export function getVitePlugin ( const meta: UnpluginContextMeta = { framework: 'vite' } - const rawPlugin = factory(userOptions!, meta) + const rawPlugins = toArray(factory(userOptions!, meta)) - const plugin = toRollupPlugin(rawPlugin, false) as VitePlugin + const plugins = rawPlugins.map((rawPlugin) => { + const plugin = toRollupPlugin(rawPlugin, false) as VitePlugin + if (rawPlugin.vite) { + Object.assign(plugin, rawPlugin.vite) + } + return plugin + }) - if (rawPlugin.vite) { - Object.assign(plugin, rawPlugin.vite) - } - return plugin + return plugins.length === 1 ? plugins[0] : plugins } } diff --git a/src/webpack/index.ts b/src/webpack/index.ts index 94c6c9aa..e5fa123d 100644 --- a/src/webpack/index.ts +++ b/src/webpack/index.ts @@ -4,7 +4,7 @@ import { resolve, dirname } from 'path' import VirtualModulesPlugin from 'webpack-virtual-modules' import type { ResolvePluginInstance, RuleSetUseItem } from 'webpack' import type { UnpluginContextMeta, UnpluginInstance, UnpluginFactory, WebpackCompiler, ResolvedUnpluginOptions } from '../types' -import { normalizeAbsolutePath } from '../utils' +import { normalizeAbsolutePath, toArray } from '../utils' import { createContext } from './context' const _dirname = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url)) @@ -29,6 +29,9 @@ export function getWebpackPlugin ( return (userOptions?: UserOptions) => { return { apply (compiler: WebpackCompiler) { + const injected = compiler.$unpluginContext || {} + compiler.$unpluginContext = injected + const meta: UnpluginContextMeta = { framework: 'webpack', webpack: { @@ -36,185 +39,185 @@ export function getWebpackPlugin ( } } - const rawPlugin = factory(userOptions!, meta) - const plugin = Object.assign( - rawPlugin, - { - __unpluginMeta: meta, - __virtualModulePrefix: VIRTUAL_MODULE_PREFIX - } - ) as ResolvedUnpluginOptions + const rawPlugins = toArray(factory(userOptions!, meta)) + for (const rawPlugin of rawPlugins) { + const plugin = Object.assign( + rawPlugin, + { + __unpluginMeta: meta, + __virtualModulePrefix: VIRTUAL_MODULE_PREFIX + } + ) as ResolvedUnpluginOptions - // inject context object to share with loaders - const injected = compiler.$unpluginContext || {} - compiler.$unpluginContext = injected - injected[plugin.name] = plugin + // inject context object to share with loaders + injected[plugin.name] = plugin - compiler.hooks.thisCompilation.tap(plugin.name, (compilation) => { - compilation.hooks.childCompiler.tap(plugin.name, (childCompiler) => { - childCompiler.$unpluginContext = injected + compiler.hooks.thisCompilation.tap(plugin.name, (compilation) => { + compilation.hooks.childCompiler.tap(plugin.name, (childCompiler) => { + childCompiler.$unpluginContext = injected + }) }) - }) - - const externalModules = new Set() - - // transform hook - if (plugin.transform) { - const useLoader: RuleSetUseItem[] = [{ - loader: `${TRANSFORM_LOADER}?unpluginName=${encodeURIComponent(plugin.name)}` - }] - const useNone: RuleSetUseItem[] = [] - compiler.options.module.rules.push({ - enforce: plugin.enforce, - use: (data: { resource: string | null, resourceQuery: string }) => { - if (data.resource == null) { + + const externalModules = new Set() + + // transform hook + if (plugin.transform) { + const useLoader: RuleSetUseItem[] = [{ + loader: `${TRANSFORM_LOADER}?unpluginName=${encodeURIComponent(plugin.name)}` + }] + const useNone: RuleSetUseItem[] = [] + compiler.options.module.rules.unshift({ + enforce: plugin.enforce, + use: (data: { resource: string | null, resourceQuery: string }) => { + if (data.resource == null) { + return useNone + } + const id = normalizeAbsolutePath(data.resource + (data.resourceQuery || '')) + if (!plugin.transformInclude || plugin.transformInclude(id)) { + return useLoader + } return useNone } - const id = normalizeAbsolutePath(data.resource + (data.resourceQuery || '')) - if (!plugin.transformInclude || plugin.transformInclude(id)) { - return useLoader - } - return useNone + }) + } + + // resolveId hook + if (plugin.resolveId) { + let vfs = compiler.options.plugins.find(i => i instanceof VirtualModulesPlugin) as VirtualModulesPlugin + if (!vfs) { + vfs = new VirtualModulesPlugin() + compiler.options.plugins.push(vfs) } - }) - } + plugin.__vfsModules = new Set() + plugin.__vfs = vfs + + const resolverPlugin: ResolvePluginInstance = { + apply (resolver) { + const target = resolver.ensureHook('resolve') + + resolver + .getHook('resolve') + .tapAsync(plugin.name, async (request, resolveContext, callback) => { + if (!request.request) { + return callback() + } - // resolveId hook - if (plugin.resolveId) { - let vfs = compiler.options.plugins.find(i => i instanceof VirtualModulesPlugin) as VirtualModulesPlugin - if (!vfs) { - vfs = new VirtualModulesPlugin() - compiler.options.plugins.push(vfs) - } - plugin.__vfsModules = new Set() - plugin.__vfs = vfs - - const resolverPlugin: ResolvePluginInstance = { - apply (resolver) { - const target = resolver.ensureHook('resolve') - - resolver - .getHook('resolve') - .tapAsync(plugin.name, async (request, resolveContext, callback) => { - if (!request.request) { - return callback() - } - - // filter out invalid requests - if (normalizeAbsolutePath(request.request).startsWith(plugin.__virtualModulePrefix)) { - return callback() - } - - const id = normalizeAbsolutePath(request.request) - - const requestContext = (request as unknown as { context: { issuer: string } }).context - const importer = requestContext.issuer !== '' ? requestContext.issuer : undefined - const isEntry = requestContext.issuer === '' - - // call hook - const resolveIdResult = await plugin.resolveId!(id, importer, { isEntry }) - - if (resolveIdResult == null) { - return callback() - } - - let resolved = typeof resolveIdResult === 'string' ? resolveIdResult : resolveIdResult.id - - const isExternal = typeof resolveIdResult === 'string' ? false : resolveIdResult.external === true - if (isExternal) { - externalModules.add(resolved) - } - - // If the resolved module does not exist, - // we treat it as a virtual module - if (!fs.existsSync(resolved)) { - resolved = normalizeAbsolutePath( - plugin.__virtualModulePrefix + - encodeURIComponent(resolved) // URI encode id so webpack doesn't think it's part of the path - ) - - // webpack virtual module should pass in the correct path - // https://github.com/unjs/unplugin/pull/155 - if (!plugin.__vfsModules!.has(resolved)) { - plugin.__vfs!.writeModule(resolved, '') - plugin.__vfsModules!.add(resolved) + // filter out invalid requests + if (normalizeAbsolutePath(request.request).startsWith(plugin.__virtualModulePrefix)) { + return callback() } - } - // construct the new request - const newRequest = { - ...request, - request: resolved - } + const id = normalizeAbsolutePath(request.request) - // redirect the resolver - resolver.doResolve(target, newRequest, null, resolveContext, callback) - }) - } - } + const requestContext = (request as unknown as { context: { issuer: string } }).context + const importer = requestContext.issuer !== '' ? requestContext.issuer : undefined + const isEntry = requestContext.issuer === '' - compiler.options.resolve.plugins = compiler.options.resolve.plugins || [] - compiler.options.resolve.plugins.push(resolverPlugin) - } + // call hook + const resolveIdResult = await plugin.resolveId!(id, importer, { isEntry }) - // load hook - if (plugin.load) { - compiler.options.module.rules.push({ - include (id) { - if (id.startsWith(plugin.__virtualModulePrefix)) { - id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length)) - } + if (resolveIdResult == null) { + return callback() + } - // load include filter - if (plugin.loadInclude && !plugin.loadInclude(id)) { - return false - } + let resolved = typeof resolveIdResult === 'string' ? resolveIdResult : resolveIdResult.id - // Don't run load hook for external modules - return !externalModules.has(id) - }, - enforce: plugin.enforce, - use: [{ - loader: LOAD_LOADER, - options: { - unpluginName: plugin.name - } - }] - }) - } + const isExternal = typeof resolveIdResult === 'string' ? false : resolveIdResult.external === true + if (isExternal) { + externalModules.add(resolved) + } - if (plugin.webpack) { - plugin.webpack(compiler) - } + // If the resolved module does not exist, + // we treat it as a virtual module + if (!fs.existsSync(resolved)) { + resolved = normalizeAbsolutePath( + plugin.__virtualModulePrefix + + encodeURIComponent(resolved) // URI encode id so webpack doesn't think it's part of the path + ) + + // webpack virtual module should pass in the correct path + // https://github.com/unjs/unplugin/pull/155 + if (!plugin.__vfsModules!.has(resolved)) { + plugin.__vfs!.writeModule(resolved, '') + plugin.__vfsModules!.add(resolved) + } + } - if (plugin.watchChange || plugin.buildStart) { - compiler.hooks.make.tapPromise(plugin.name, async (compilation) => { - const context = createContext(compilation) - if (plugin.watchChange && (compiler.modifiedFiles || compiler.removedFiles)) { - const promises:Promise[] = [] - if (compiler.modifiedFiles) { - compiler.modifiedFiles.forEach(file => - promises.push(Promise.resolve(plugin.watchChange!.call(context, file, { event: 'update' }))) - ) - } - if (compiler.removedFiles) { - compiler.removedFiles.forEach(file => - promises.push(Promise.resolve(plugin.watchChange!.call(context, file, { event: 'delete' }))) - ) + // construct the new request + const newRequest = { + ...request, + request: resolved + } + + // redirect the resolver + resolver.doResolve(target, newRequest, null, resolveContext, callback) + }) } - await Promise.all(promises) } - if (plugin.buildStart) { - return await plugin.buildStart.call(context) - } - }) - } + compiler.options.resolve.plugins = compiler.options.resolve.plugins || [] + compiler.options.resolve.plugins.push(resolverPlugin) + } - if (plugin.buildEnd) { - compiler.hooks.emit.tapPromise(plugin.name, async (compilation) => { - await plugin.buildEnd!.call(createContext(compilation)) - }) + // load hook + if (plugin.load) { + compiler.options.module.rules.unshift({ + include (id) { + if (id.startsWith(plugin.__virtualModulePrefix)) { + id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length)) + } + + // load include filter + if (plugin.loadInclude && !plugin.loadInclude(id)) { + return false + } + + // Don't run load hook for external modules + return !externalModules.has(id) + }, + enforce: plugin.enforce, + use: [{ + loader: LOAD_LOADER, + options: { + unpluginName: plugin.name + } + }] + }) + } + + if (plugin.webpack) { + plugin.webpack(compiler) + } + + if (plugin.watchChange || plugin.buildStart) { + compiler.hooks.make.tapPromise(plugin.name, async (compilation) => { + const context = createContext(compilation) + if (plugin.watchChange && (compiler.modifiedFiles || compiler.removedFiles)) { + const promises:Promise[] = [] + if (compiler.modifiedFiles) { + compiler.modifiedFiles.forEach(file => + promises.push(Promise.resolve(plugin.watchChange!.call(context, file, { event: 'update' }))) + ) + } + if (compiler.removedFiles) { + compiler.removedFiles.forEach(file => + promises.push(Promise.resolve(plugin.watchChange!.call(context, file, { event: 'delete' }))) + ) + } + await Promise.all(promises) + } + + if (plugin.buildStart) { + return await plugin.buildStart.call(context) + } + }) + } + + if (plugin.buildEnd) { + compiler.hooks.emit.tapPromise(plugin.name, async (compilation) => { + await plugin.buildEnd!.call(createContext(compilation)) + }) + } } } } diff --git a/test/fixtures/transform/__test__/build.test.ts b/test/fixtures/transform/__test__/build.test.ts index 5b56f45c..ac2cb0ad 100644 --- a/test/fixtures/transform/__test__/build.test.ts +++ b/test/fixtures/transform/__test__/build.test.ts @@ -9,30 +9,31 @@ describe('transform build', () => { const content = await fs.readFile(r('vite/main.js.mjs'), 'utf-8') expect(content).toContain('NON-TARGET: __UNPLUGIN__') - expect(content).toContain('TARGET: [Injected Vite]') - expect(content).toContain('QUERY: [Injected Vite]') + expect(content).toContain('TARGET: [Injected Post Vite]') + expect(content).toContain('QUERY: [Injected Post Vite]') }) it('rollup', async () => { const content = await fs.readFile(r('rollup/main.js'), 'utf-8') expect(content).toContain('NON-TARGET: __UNPLUGIN__') - expect(content).toContain('TARGET: [Injected Rollup]') + expect(content).toContain('TARGET: [Injected Post Rollup]') }) it('webpack', async () => { const content = await fs.readFile(r('webpack/main.js'), 'utf-8') expect(content).toContain('NON-TARGET: __UNPLUGIN__') - expect(content).toContain('TARGET: [Injected Webpack]') - expect(content).toContain('QUERY: [Injected Webpack]') + expect(content).toContain('TARGET: [Injected Post Webpack]') + expect(content).toContain('QUERY: [Injected Post Webpack]') }) - it('esbuild', async () => { + // TODO: esbuild not yet support nested transform + it.fails('esbuild', async () => { const content = await fs.readFile(r('esbuild/main.js'), 'utf-8') expect(content).toContain('NON-TARGET: __UNPLUGIN__') - expect(content).toContain('TARGET: [Injected Esbuild]') - expect(content).toContain('QUERY: [Injected Esbuild]') + expect(content).toContain('TARGET: [Injected Post Esbuild]') + expect(content).toContain('QUERY: [Injected Post Esbuild]') }) }) diff --git a/test/fixtures/transform/unplugin.js b/test/fixtures/transform/unplugin.js index 21e5862a..809d4abf 100644 --- a/test/fixtures/transform/unplugin.js +++ b/test/fixtures/transform/unplugin.js @@ -1,43 +1,70 @@ -const { createUnplugin } = require('unplugin') const MagicString = require('magic-string') +const { createUnplugin } = require('unplugin') module.exports = createUnplugin((options, meta) => { - return { - name: 'transform-fixture', - 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') { - return { - id, - external: true + return [ + { + 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') { + return { + id, + external: true + } + } + }, + transformInclude (id) { + return id.match(/[/\\]target\.js$/) || id.includes('?query-param=query-value') + }, + transform (code, id) { + const s = new MagicString(code) + const index = code.indexOf('__UNPLUGIN__') + if (index === -1) { + return null } - } - }, - transformInclude (id) { - return id.match(/[/\\]target\.js$/) || id.includes('?query-param=query-value') - }, - transform (code, id) { - const s = new MagicString(code) - const index = code.indexOf('__UNPLUGIN__') - if (index === -1) { - return null - } - const injectedCode = `[Injected ${options.msg}]` + const injectedCode = `[Injected ${options.msg}]` - if (id.includes(injectedCode)) { - throw new Error('File was already transformed') + if (id.includes(injectedCode)) { + throw new Error('File was already transformed') + } + + s.overwrite(index, index + '__UNPLUGIN__'.length, injectedCode) + + return { + code: s.toString(), + map: s.generateMap({ + source: id, + includeContent: true + }) + } } + }, + { + name: 'transform-fixture-post', + transformInclude (id) { + return id.match(/[/\\]target\.js$/) || id.includes('?query-param=query-value') + }, + transform (code, id) { + if (!code.includes('Injected')) { + return null + } - s.overwrite(index, index + '__UNPLUGIN__'.length, injectedCode) + const s = new MagicString(code) + s.replace( + 'Injected', + 'Injected Post' + ) - return { - code: s.toString(), - map: s.generateMap({ - source: id, - includeContent: true - }) + return { + code: s.toString(), + map: s.generateMap({ + source: id, + includeContent: true + }) + } } } - } + ] }) diff --git a/test/unit-tests/id-consistency/id-consistency.test.ts b/test/unit-tests/id-consistency/id-consistency.test.ts index 41195d2a..74ca0760 100644 --- a/test/unit-tests/id-consistency/id-consistency.test.ts +++ b/test/unit-tests/id-consistency/id-consistency.test.ts @@ -1,7 +1,7 @@ import * as path from 'path' import { it, describe, expect, vi, afterEach, Mock } from 'vitest' -import { build } from '../utils' -import { createUnplugin, UnpluginOptions } from 'unplugin' +import { build, toArray } from '../utils' +import { createUnplugin, UnpluginOptions, VitePlugin } from 'unplugin' const entryFilePath = path.resolve(__dirname, './test-src/entry.js') const externals = ['path'] @@ -73,10 +73,12 @@ describe('id parameter should be consistent accross hooks and plugins', () => { mockTransformHook, mockLoadHook ).vite + // we need to define `enforce` here for the plugin to be run + const plugins = toArray(plugin()).map((plugin): VitePlugin => ({ ...plugin, enforce: 'pre' })) await build.vite({ clearScreen: false, - plugins: [{ ...plugin(), enforce: 'pre' }], // we need to define `enforce` here for the plugin to be run + plugins: [plugins], build: { lib: { entry: entryFilePath, 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 9bc11edd..d179ca92 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 @@ -1,7 +1,7 @@ import * as path from 'path' import { it, describe, expect, vi, afterEach } from 'vitest' -import { build } from '../utils' -import { createUnplugin } from 'unplugin' +import { build, toArray } from '../utils' +import { VitePlugin, createUnplugin } from 'unplugin' const entryFilePath = path.resolve(__dirname, './test-src/entry.js') const externals = ['path'] @@ -45,9 +45,11 @@ describe('load hook should not be called when resolveId hook returned `external: it('vite', async () => { const plugin = createMockedUnplugin().vite + // we need to define `enforce` here for the plugin to be run + const plugins = toArray(plugin()).map((plugin): VitePlugin => ({ ...plugin, enforce: 'pre' })) await build.vite({ clearScreen: false, - plugins: [{ ...plugin(), enforce: 'pre' }], // we need to define `enforce` here for the plugin to be run + plugins: [plugins], build: { lib: { entry: entryFilePath, diff --git a/test/unit-tests/resolve-id/resolve-id.test.ts b/test/unit-tests/resolve-id/resolve-id.test.ts index 9c57030f..3831cb8e 100644 --- a/test/unit-tests/resolve-id/resolve-id.test.ts +++ b/test/unit-tests/resolve-id/resolve-id.test.ts @@ -1,7 +1,7 @@ import * as path from 'path' import { it, describe, expect, vi, afterEach, Mock } from 'vitest' -import { build } from '../utils' -import { createUnplugin, UnpluginOptions } from 'unplugin' +import { build, toArray } from '../utils' +import { createUnplugin, UnpluginOptions, VitePlugin } from 'unplugin' function createUnpluginWithCallback (resolveIdCallback: UnpluginOptions['resolveId']) { return createUnplugin(() => ({ @@ -47,10 +47,12 @@ describe('resolveId hook', () => { it('vite', async () => { const mockResolveIdHook = vi.fn(() => undefined) const plugin = createUnpluginWithCallback(mockResolveIdHook).vite + // we need to define `enforce` here for the plugin to be run + const plugins = toArray(plugin()).map((plugin): VitePlugin => ({ ...plugin, enforce: 'pre' })) await build.vite({ clearScreen: false, - plugins: [{ ...plugin(), enforce: 'pre' }], // we need to define `enforce` here for the plugin to be run + plugins: [plugins], build: { lib: { entry: path.resolve(__dirname, 'test-src/entry.js'), diff --git a/test/unit-tests/utils.ts b/test/unit-tests/utils.ts index c8522f30..74647c60 100644 --- a/test/unit-tests/utils.ts +++ b/test/unit-tests/utils.ts @@ -3,6 +3,8 @@ import * as rollup from 'rollup' import * as webpack from 'webpack' import * as esbuild from 'esbuild' +export * from '../../src/utils' + export const viteBuild = vite.build export const rollupBuild = rollup.rollup export const esbuildBuild = esbuild.build