diff --git a/docs/guide/index.md b/docs/guide/index.md
index dcb93b5d..6e0fd129 100644
--- a/docs/guide/index.md
+++ b/docs/guide/index.md
@@ -198,9 +198,9 @@ export default defineConfig({
| [`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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| ~~`loadInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`load`](https://rollupjs.org/plugin-development/#load) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ |
-| `transformInclude`2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| ~~`transformInclude`~~2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`transform`](https://rollupjs.org/plugin-development/#transform) | ✅ | ✅ | ✅ | ✅ 3 | ✅ | ✅ | ✅ |
| [`watchChange`](https://rollupjs.org/plugin-development/#watchchange) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| [`buildEnd`](https://rollupjs.org/plugin-development/#buildend) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -209,11 +209,14 @@ export default defineConfig({
::: details Notice
1. Rollup and esbuild do not support using `enforce` to control the order of plugins. Users need to maintain the order manually.
-2. webpack's id filter is outside of loader logic; an additional hook is needed for better perf on webpack. In Rollup and Vite, this hook has been polyfilled to match the behaviors. See for the following usage examples.
+2. Webpack's id filter is outside of loader logic; an additional hook is needed for better performance on Webpack and Rolldown.
+ However, it is now deprecated. Please use `transform/load/resolveId.filter` instead.
+ In Rollup, this hook has been polyfilled to match the behaviors. See the following usage examples for reference.
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.
- :::
+
+:::
### Usage
@@ -227,14 +230,14 @@ export interface Options {
export const unpluginFactory: UnpluginFactory = options => ({
name: 'unplugin-starter',
- // webpack's id filter is outside of loader logic,
- // an additional hook is needed for better perf on webpack
- transformInclude(id) {
- return id.endsWith('main.ts')
- },
- // just like rollup transform
- transform(code) {
- return code.replace(//, 'Injected
')
+ transform: {
+ // an additional hook is needed for better perf on webpack and rolldown
+ filter: {
+ id: /main\.ts$/
+ },
+ handler(code) {
+ return code.replace(//, 'Injected
')
+ },
},
// more hooks coming
})
@@ -334,11 +337,14 @@ export const unpluginFactory: UnpluginFactory = (
console.log(meta.framework) // vite rollup webpack esbuild rspack...
return {
name: 'unplugin-starter',
- transform(code) {
- return code.replace(//, 'Injected
')
- },
- transformInclude(id) {
- return id.endsWith('main.ts')
+ transform: {
+ // an additional hook is needed for better perf on webpack and rolldown
+ filter: {
+ id: /main\.ts$/
+ },
+ handler(code) {
+ return code.replace(//, 'Injected
')
+ },
},
vite: {
// Vite plugin
diff --git a/package.json b/package.json
index 6f14ef6a..48e7fcd5 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
},
"dependencies": {
"acorn": "^8.14.1",
+ "picomatch": "^4.0.2",
"webpack-virtual-modules": "^0.6.2"
},
"devDependencies": {
@@ -55,6 +56,7 @@
"@rspack/core": "^1.3.4",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.14.0",
+ "@types/picomatch": "^3.0.2",
"ansis": "^3.17.0",
"bumpp": "^10.1.0",
"esbuild": "^0.25.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fff33a9b..d9833385 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
acorn:
specifier: ^8.14.1
version: 8.14.1
+ picomatch:
+ specifier: ^4.0.2
+ version: 4.0.2
webpack-virtual-modules:
specifier: ^0.6.2
version: 0.6.2
@@ -45,6 +48,9 @@ importers:
'@types/node':
specifier: ^22.14.0
version: 22.14.0
+ '@types/picomatch':
+ specifier: ^3.0.2
+ version: 3.0.2
ansis:
specifier: ^3.17.0
version: 3.17.0
@@ -1475,6 +1481,9 @@ packages:
'@types/object-path@0.11.4':
resolution: {integrity: sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw==}
+ '@types/picomatch@3.0.2':
+ resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==}
+
'@types/qs@6.9.18':
resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==}
@@ -6672,6 +6681,8 @@ snapshots:
'@types/object-path@0.11.4': {}
+ '@types/picomatch@3.0.2': {}
+
'@types/qs@6.9.18': {}
'@types/range-parser@1.2.7': {}
diff --git a/src/esbuild/index.ts b/src/esbuild/index.ts
index e5d61094..c2109403 100644
--- a/src/esbuild/index.ts
+++ b/src/esbuild/index.ts
@@ -10,6 +10,7 @@ import type {
} from '../types'
import fs from 'node:fs'
import path from 'node:path'
+import { normalizeObjectHook } from '../utils/filter'
import { toArray } from '../utils/general'
import {
combineSourcemaps,
@@ -167,20 +168,24 @@ function buildSetup() {
if (plugin.resolveId) {
onResolve({ filter: onResolveFilter }, async (args) => {
- if (initialOptions.external?.includes(args.path)) {
+ const id = args.path
+ if (initialOptions.external?.includes(id)) {
// 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
+ return
}
- const { errors, warnings, mixedContext }
- = createPluginContext(context)
+ const { handler, filter } = normalizeObjectHook('resolveId', plugin.resolveId!)
+ if (!filter(id))
+ return
+
+ const { errors, warnings, mixedContext } = createPluginContext(context)
const isEntry = args.kind === 'entry-point'
- const result = await plugin.resolveId!.call(
+ const result = await handler.call(
mixedContext,
- args.path,
+ id,
// We explicitly have this if statement here for consistency with
// the integration of other bundlers.
// Here, `args.importer` is just an empty string on entry files
@@ -212,26 +217,26 @@ function buildSetup() {
if (plugin.load) {
onLoad({ filter: onLoadFilter }, async (args) => {
+ const { handler, filter } = normalizeObjectHook('load', plugin.load!)
const id = args.path + (args.suffix || '') // compat for #427
- const { errors, warnings, mixedContext }
- = createPluginContext(context)
+ if (plugin.loadInclude && !plugin.loadInclude(id))
+ return
+ if (!filter(id))
+ return
- // 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)
+ const { errors, warnings, mixedContext } = createPluginContext(context)
- let code: string | undefined, map: SourceMap | null | undefined
+ let code: string | undefined
+ let map: SourceMap | null | undefined
- if (plugin.load && (!plugin.loadInclude || plugin.loadInclude(id))) {
- const result = await plugin.load.call(mixedContext, id)
- if (typeof result === 'string') {
- code = result
- }
- else if (typeof result === 'object' && result !== null) {
- code = result.code
- map = result.map as any
- }
+ const result = await handler.call(mixedContext, id)
+ if (typeof result === 'string') {
+ code = result
+ }
+ else if (typeof result === 'object' && result !== null) {
+ code = result.code
+ map = result.map as any
}
if (code === undefined)
@@ -240,6 +245,10 @@ function buildSetup() {
if (map)
code = processCodeWithSourceMap(map, code)
+ // 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)
+
return {
contents: code,
errors,
@@ -253,17 +262,20 @@ function buildSetup() {
if (plugin.transform) {
onTransform({ filter: onLoadFilter }, async (args) => {
- const id = args.path + (args.suffix || '')
+ const { handler, filter } = normalizeObjectHook('transform', plugin.transform!)
+ const id = args.path + (args.suffix || '')
if (plugin.transformInclude && !plugin.transformInclude(id))
return
+ let code = await args.getContents()
+ if (!filter(id, code))
+ return
const { mixedContext, errors, warnings } = createPluginContext(context)
const resolveDir = path.dirname(args.path)
- let code = await args.getContents()
let map: SourceMap | null | undefined
- const result = await plugin.transform!.call(mixedContext, code, id)
+ const result = await handler.call(mixedContext, code, id)
if (typeof result === 'string') {
code = result
}
diff --git a/src/farm/index.ts b/src/farm/index.ts
index 9b887e63..a94dc26c 100644
--- a/src/farm/index.ts
+++ b/src/farm/index.ts
@@ -18,6 +18,7 @@ import type { JsPluginExtended, WatchChangeEvents } from './utils'
import path from 'node:path'
+import { normalizeObjectHook } from '../utils/filter'
import { toArray } from '../utils/general'
import { createFarmContext, unpluginContext } from './context'
import {
@@ -92,15 +93,21 @@ export function toFarmPlugin(plugin: UnpluginOptions, options?: Record {
const resolvedPath = decodeStr(params.resolvedPath)
-
const id = appendQuery(resolvedPath, params.query)
-
const loader = formatTransformModuleType(id)
- const shouldLoadInclude
- = plugin.loadInclude?.(id)
-
- if (!shouldLoadInclude)
+ if (plugin.loadInclude && !plugin.loadInclude?.(id))
+ return null
+ const { handler, filter } = normalizeObjectHook('load', _load)
+ if (!filter(id))
return null
const farmContext = createFarmContext(context!, id)
-
- const content: TransformResult = await _load.call(
+ const content: TransformResult = await handler.call(
Object.assign(unpluginContext(context!), farmContext),
id,
)
@@ -178,19 +182,18 @@ export function toFarmPlugin(plugin: UnpluginOptions, options?: Record, Nested extends boolean = boolean>(
@@ -15,28 +16,89 @@ export function getRollupPlugin, Nested exte
}
export function toRollupPlugin(plugin: UnpluginOptions, key: 'rollup' | 'rolldown' | 'vite' | 'unloader'): RollupPlugin {
- if (plugin.transform && plugin.transformInclude) {
- const _transform = plugin.transform
- plugin.transform = function (code, id, ...args) {
- if (plugin.transformInclude && !plugin.transformInclude(id))
- return null
+ const nativeFilter = key === 'rolldown'
- return _transform.call(this, code, id, ...args)
- }
+ if (
+ plugin.resolveId
+ && (!nativeFilter && typeof plugin.resolveId === 'object' && plugin.resolveId.filter)
+ ) {
+ const resolveIdHook = plugin.resolveId
+ const { handler, filter } = normalizeObjectHook('load', resolveIdHook)
+
+ replaceHookHandler('resolveId', resolveIdHook, function (...args) {
+ const [id] = args
+ const supportFilter = supportNativeFilter(this)
+ if (!supportFilter && !filter(id))
+ return
+
+ return handler.apply(this, args)
+ })
}
- if (plugin.load && plugin.loadInclude) {
- const _load = plugin.load
- plugin.load = function (id, ...args) {
+ if (plugin.load && (
+ plugin.loadInclude
+ || (!nativeFilter && typeof plugin.load === 'object' && plugin.load.filter))
+ ) {
+ const loadHook = plugin.load
+ const { handler, filter } = normalizeObjectHook('load', loadHook)
+
+ replaceHookHandler('load', loadHook, function (...args) {
+ const [id] = args
if (plugin.loadInclude && !plugin.loadInclude(id))
- return null
+ return
- return _load.call(this, id, ...args)
- }
+ const supportFilter = supportNativeFilter(this)
+ if (!supportFilter && !filter(id))
+ return
+
+ return handler.apply(this, args)
+ })
+ }
+
+ if (plugin.transform && (
+ plugin.transformInclude
+ || (!nativeFilter && typeof plugin.transform === 'object' && plugin.transform.filter))
+ ) {
+ const transformHook = plugin.transform
+ const { handler, filter } = normalizeObjectHook('transform', transformHook)
+
+ replaceHookHandler('transform', transformHook, function (...args) {
+ const [code, id] = args
+ if (plugin.transformInclude && !plugin.transformInclude(id))
+ return
+
+ const supportFilter = supportNativeFilter(this)
+ if (!supportFilter && !filter(id, code))
+ return
+
+ return handler.apply(this, args)
+ })
}
if (plugin[key])
Object.assign(plugin, plugin[key])
return plugin as RollupPlugin
+
+ function replaceHookHandler(
+ name: K,
+ hook: Hook,
+ handler: HookFnMap[K],
+ ) {
+ if (typeof hook === 'function') {
+ plugin[name] = handler as any
+ }
+ else {
+ hook.handler = handler
+ }
+ }
+}
+
+function supportNativeFilter(context: any) {
+ const rollupVersion: string | undefined = context?.meta?.rollupVersion
+ if (!rollupVersion)
+ return false
+
+ const [major, minor] = rollupVersion.split('.')
+ return (Number(major) > 4 || (Number(major) === 4 && Number(minor) >= 38))
}
diff --git a/src/rspack/index.ts b/src/rspack/index.ts
index 51778969..662fac83 100644
--- a/src/rspack/index.ts
+++ b/src/rspack/index.ts
@@ -8,6 +8,7 @@ import type {
} from '../types'
import fs from 'node:fs'
import { resolve } from 'node:path'
+import { normalizeObjectHook } from '../utils/filter'
import { toArray } from '../utils/general'
import { normalizeAbsolutePath, transformUse } from '../utils/webpack-like'
import { createBuildContext, normalizeMessage } from './context'
@@ -82,7 +83,12 @@ export function getRspackPlugin>(
console.warn(`unplugin/rspack: warning from resolveId hook: ${msg}`)
},
}
- const resolveIdResult = await plugin.resolveId!.call!({ ...context, ...pluginContext }, id, importer, { isEntry })
+
+ const { handler, filter } = normalizeObjectHook('resolveId', plugin.resolveId!)
+ if (!filter(id))
+ return
+
+ const resolveIdResult = await handler.call!({ ...context, ...pluginContext }, id, importer, { isEntry })
if (error != null)
throw error
@@ -122,6 +128,10 @@ export function getRspackPlugin>(
if (plugin.loadInclude && !plugin.loadInclude(id))
return false
+ const { filter } = normalizeObjectHook('load', plugin.load!)
+ if (!filter(id))
+ return false
+
// Don't run load hook for external modules
return !externalModules.has(id)
},
diff --git a/src/rspack/loaders/load.ts b/src/rspack/loaders/load.ts
index 827720f0..242ec1c2 100644
--- a/src/rspack/loaders/load.ts
+++ b/src/rspack/loaders/load.ts
@@ -1,5 +1,6 @@
import type { LoaderContext } from '@rspack/core'
import type { ResolvedUnpluginOptions } from '../../types'
+import { normalizeObjectHook } from '../../utils/filter'
import { normalizeAbsolutePath } from '../../utils/webpack-like'
import { createBuildContext, createContext } from '../context'
import { decodeVirtualModuleId, isVirtualModuleId } from '../utils'
@@ -16,7 +17,8 @@ export default async function load(this: LoaderContext, source: string, map: any
id = decodeVirtualModuleId(id, plugin)
const context = createContext(this)
- const res = await plugin.load.call(
+ const { handler } = normalizeObjectHook('load', plugin.load)
+ const res = await handler.call(
Object.assign(
{},
this._compilation && createBuildContext(this._compiler, this._compilation, this),
diff --git a/src/rspack/loaders/transform.ts b/src/rspack/loaders/transform.ts
index 2b53e875..b0ac2a66 100644
--- a/src/rspack/loaders/transform.ts
+++ b/src/rspack/loaders/transform.ts
@@ -1,5 +1,6 @@
import type { LoaderContext } from '@rspack/core'
import type { ResolvedUnpluginOptions } from '../../types'
+import { normalizeObjectHook } from '../../utils/filter'
import { createBuildContext, createContext } from '../context'
export default async function transform(
@@ -14,9 +15,12 @@ export default async function transform(
const id = this.resource
const context = createContext(this)
+ const { handler, filter } = normalizeObjectHook('transform', plugin.transform)
+ if (!filter(this.resource, source))
+ return callback(null, source, map)
try {
- const res = await plugin.transform.call(
+ const res = await handler.call(
Object.assign(
{},
this._compilation && createBuildContext(this._compiler, this._compilation, this),
diff --git a/src/types.ts b/src/types.ts
index 8e7bf026..f3d8d567 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -61,29 +61,67 @@ export interface UnpluginBuildContext {
getNativeBuildContext?: () => NativeBuildContext
}
+export type StringOrRegExp = string | RegExp
+export type StringFilter =
+ | Arrayable
+ | { include?: Arrayable, exclude?: Arrayable }
+export interface HookFilter {
+ id?: StringFilter
+ code?: StringFilter
+}
+
+export interface ObjectHook {
+ filter?: Pick
+ handler: T
+}
+export type Hook<
+ T extends HookFnMap[keyof HookFnMap],
+ F extends keyof HookFilter,
+> = T | ObjectHook
+
+export interface HookFnMap {
+ // Build Hooks
+ buildStart: (this: UnpluginBuildContext) => Thenable
+ buildEnd: (this: UnpluginBuildContext) => Thenable
+
+ transform: (this: UnpluginBuildContext & UnpluginContext, code: string, id: string) => Thenable
+ load: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable
+ resolveId: (
+ this: UnpluginBuildContext & UnpluginContext,
+ id: string,
+ importer: string | undefined,
+ options: { isEntry: boolean }
+ ) => Thenable
+
+ // Output Generation Hooks
+ writeBundle: (this: void) => Thenable
+}
+
export interface UnpluginOptions {
name: string
enforce?: 'post' | 'pre' | undefined
- // Build Hooks
- buildStart?: (this: UnpluginBuildContext) => Promise | void
- buildEnd?: (this: UnpluginBuildContext) => Promise | void
- transform?: (this: UnpluginBuildContext & UnpluginContext, code: string, id: string) => Thenable
- load?: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable
- resolveId?: (this: UnpluginBuildContext & UnpluginContext, id: string, importer: string | undefined, options: { isEntry: boolean }) => Thenable
- watchChange?: (this: UnpluginBuildContext, id: string, change: { event: 'create' | 'update' | 'delete' }) => void
+ buildStart?: HookFnMap['buildStart']
+ buildEnd?: HookFnMap['buildEnd']
+ transform?: Hook
+ load?: Hook
+ resolveId?: Hook
+ writeBundle?: HookFnMap['writeBundle']
- // Output Generation Hooks
- writeBundle?: (this: void) => Promise | void
+ watchChange?: (this: UnpluginBuildContext, id: string, change: { event: 'create' | 'update' | 'delete' }) => void
/**
* Custom predicate function to filter modules to be loaded.
* When omitted, all modules will be included (might have potential perf impact on Webpack).
+ *
+ * @deprecated Use `load.filter` instead.
*/
loadInclude?: (id: string) => boolean | null | undefined
/**
* Custom predicate function to filter modules to be transformed.
* When omitted, all modules will be included (might have potential perf impact on Webpack).
+ *
+ * @deprecated Use `transform.filter` instead.
*/
transformInclude?: (id: string) => boolean | null | undefined
diff --git a/src/utils/filter.ts b/src/utils/filter.ts
new file mode 100644
index 00000000..19ef104f
--- /dev/null
+++ b/src/utils/filter.ts
@@ -0,0 +1,190 @@
+import type { Hook, HookFilter, StringFilter, StringOrRegExp } from '../types'
+import { resolve } from 'node:path'
+import picomatch from 'picomatch'
+import { toArray } from './general'
+
+const BACKSLASH_REGEX = /\\/g
+function normalize(path: string): string {
+ return path.replace(BACKSLASH_REGEX, '/')
+}
+
+const ABSOLUTE_PATH_REGEX = /^(?:\/|(?:[A-Z]:)?[/\\|])/i
+function isAbsolute(path: string): boolean {
+ return ABSOLUTE_PATH_REGEX.test(path)
+}
+
+const FALLBACK_TRUE = 1
+const FALLBACK_FALSE = 0
+type FallbackValues = typeof FALLBACK_TRUE | typeof FALLBACK_FALSE
+type PluginFilterWithFallback = (input: string) => boolean | FallbackValues
+
+export type PluginFilter = (input: string) => boolean
+export type TransformHookFilter = (id: string, code: string) => boolean
+
+interface NormalizedStringFilter {
+ include?: StringOrRegExp[]
+ exclude?: StringOrRegExp[]
+}
+
+function getMatcherString(glob: string, cwd: string) {
+ if (glob.startsWith('**') || isAbsolute(glob)) {
+ return normalize(glob)
+ }
+
+ const resolved = resolve(cwd, glob)
+ return normalize(resolved)
+}
+
+function patternToIdFilter(pattern: StringOrRegExp): PluginFilter {
+ if (pattern instanceof RegExp) {
+ return (id: string) => {
+ const normalizedId = normalize(id)
+ const result = pattern.test(normalizedId)
+ pattern.lastIndex = 0
+ return result
+ }
+ }
+ const cwd = process.cwd()
+ const glob = getMatcherString(pattern, cwd)
+ const matcher = picomatch(glob, { dot: true })
+ return (id: string) => {
+ const normalizedId = normalize(id)
+ return matcher(normalizedId)
+ }
+}
+
+function patternToCodeFilter(pattern: StringOrRegExp): PluginFilter {
+ if (pattern instanceof RegExp) {
+ return (code: string) => {
+ const result = pattern.test(code)
+ pattern.lastIndex = 0
+ return result
+ }
+ }
+ return (code: string) => code.includes(pattern)
+}
+
+function createFilter(
+ exclude: PluginFilter[] | undefined,
+ include: PluginFilter[] | undefined,
+): PluginFilterWithFallback | undefined {
+ if (!exclude && !include) {
+ return
+ }
+
+ return (input) => {
+ if (exclude?.some(filter => filter(input))) {
+ return false
+ }
+ if (include?.some(filter => filter(input))) {
+ return true
+ }
+ return !!include && include.length > 0 ? FALLBACK_FALSE : FALLBACK_TRUE
+ }
+}
+
+function normalizeFilter(filter: StringFilter): NormalizedStringFilter {
+ if (typeof filter === 'string' || filter instanceof RegExp) {
+ return {
+ include: [filter],
+ }
+ }
+ if (Array.isArray(filter)) {
+ return {
+ include: toArray(filter),
+ }
+ }
+ return {
+ exclude: filter.exclude ? toArray(filter.exclude) : undefined,
+ include: filter.include ? toArray(filter.include) : undefined,
+ }
+}
+
+function createIdFilter(filter: StringFilter | undefined): PluginFilterWithFallback | undefined {
+ if (!filter)
+ return
+ const { exclude, include } = normalizeFilter(filter)
+ const excludeFilter = exclude?.map(patternToIdFilter)
+ const includeFilter = include?.map(patternToIdFilter)
+ return createFilter(excludeFilter, includeFilter)
+}
+
+function createCodeFilter(filter: StringFilter | undefined): PluginFilterWithFallback | undefined {
+ if (!filter)
+ return
+ const { exclude, include } = normalizeFilter(filter)
+ const excludeFilter = exclude?.map(patternToCodeFilter)
+ const includeFilter = include?.map(patternToCodeFilter)
+ return createFilter(excludeFilter, includeFilter)
+}
+
+function createFilterForId(filter: StringFilter | undefined): PluginFilter | undefined {
+ const filterFunction = createIdFilter(filter)
+ return filterFunction ? id => !!filterFunction(id) : undefined
+}
+
+function createFilterForTransform(
+ idFilter: StringFilter | undefined,
+ codeFilter: StringFilter | undefined,
+): TransformHookFilter | undefined {
+ if (!idFilter && !codeFilter)
+ return
+ const idFilterFunction = createIdFilter(idFilter)
+ const codeFilterFunction = createCodeFilter(codeFilter)
+ return (id, code) => {
+ let fallback = true
+ if (idFilterFunction) {
+ const idResult = idFilterFunction(id)
+ if (typeof idResult === 'boolean') {
+ return idResult
+ }
+ fallback &&= !!idResult
+ }
+ if (codeFilterFunction) {
+ const codeResult = codeFilterFunction(code)
+ if (typeof codeResult === 'boolean') {
+ return codeResult
+ }
+ fallback &&= !!codeResult
+ }
+ return fallback
+ }
+}
+
+export function normalizeObjectHook any, F extends keyof HookFilter>(
+ name: 'resolveId' | 'load',
+ hook: Hook,
+): { handler: T, filter: PluginFilter }
+export function normalizeObjectHook any, F extends keyof HookFilter>(
+ name: 'transform',
+ hook: Hook,
+): { handler: T, filter: TransformHookFilter }
+export function normalizeObjectHook any, F extends keyof HookFilter>(
+ name: 'resolveId' | 'load' | 'transform',
+ hook: Hook,
+): {
+ handler: T
+ filter: PluginFilter | TransformHookFilter
+ } {
+ let handler: T
+ let filter: PluginFilter | TransformHookFilter | undefined
+
+ if (typeof hook === 'function') {
+ handler = hook
+ }
+ else {
+ handler = hook.handler
+ const hookFilter = hook.filter as HookFilter | undefined
+ if (name === 'resolveId' || name === 'load') {
+ filter = createFilterForId(hookFilter?.id)
+ }
+ else {
+ filter = createFilterForTransform(hookFilter?.id, hookFilter?.code)
+ }
+ }
+
+ return {
+ handler,
+ filter: filter || (() => true),
+ }
+}
diff --git a/src/utils/webpack-like.ts b/src/utils/webpack-like.ts
index 7074d554..3e09f4b8 100644
--- a/src/utils/webpack-like.ts
+++ b/src/utils/webpack-like.ts
@@ -1,6 +1,7 @@
import type { RuleSetUseItem } from '@rspack/core'
import type { ResolvedUnpluginOptions } from '../types'
import { isAbsolute, normalize } from 'node:path'
+import { normalizeObjectHook } from './filter'
export function transformUse(
data: { resource?: string, resourceQuery?: string },
@@ -11,16 +12,25 @@ export function transformUse(
return []
const id = normalizeAbsolutePath(data.resource + (data.resourceQuery || ''))
- if (!plugin.transformInclude || plugin.transformInclude(id)) {
- return [
- {
- loader: transformLoader,
- options: { plugin },
- ident: plugin.name,
- },
- ]
- }
- return []
+ if (plugin.transformInclude && !plugin.transformInclude(id))
+ return []
+
+ const { filter } = normalizeObjectHook(
+ // WARN: treat `transform` as `load` here, since cannot get `code` outside of `transform`
+ // `code` should be checked in the loader
+ 'load',
+ plugin.transform!,
+ )
+ if (!filter(id))
+ return []
+
+ return [
+ {
+ loader: transformLoader,
+ options: { plugin },
+ ident: plugin.name,
+ },
+ ]
}
/**
diff --git a/src/webpack/index.ts b/src/webpack/index.ts
index a48b7e0b..0454276c 100644
--- a/src/webpack/index.ts
+++ b/src/webpack/index.ts
@@ -4,6 +4,7 @@ import fs from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
import VirtualModulesPlugin from 'webpack-virtual-modules'
+import { normalizeObjectHook } from '../utils/filter'
import { toArray } from '../utils/general'
import { normalizeAbsolutePath, transformUse } from '../utils/webpack-like'
import { contextOptionsFromCompilation, createBuildContext, normalizeMessage } from './context'
@@ -104,7 +105,12 @@ export function getWebpackPlugin>(
console.warn(`unplugin/webpack: warning from resolveId hook: ${msg}`)
},
}
- const resolveIdResult = await plugin.resolveId!.call!({ ...context, ...pluginContext }, id, importer, { isEntry })
+
+ const { handler, filter } = normalizeObjectHook('resolveId', plugin.resolveId!)
+ if (!filter(id))
+ return callback()
+
+ const resolveIdResult = await handler.call!({ ...context, ...pluginContext }, id, importer, { isEntry })
if (error != null)
return callback(error)
@@ -227,6 +233,10 @@ export function shouldLoad(id: string, plugin: ResolvedUnpluginOptions, external
if (plugin.loadInclude && !plugin.loadInclude(id))
return false
+ const { filter } = normalizeObjectHook('load', plugin.load!)
+ if (!filter(id))
+ return false
+
// Don't run load hook for external modules
return !externalModules.has(id)
}
diff --git a/src/webpack/loaders/load.ts b/src/webpack/loaders/load.ts
index 780c3c7f..e2fd10fa 100644
--- a/src/webpack/loaders/load.ts
+++ b/src/webpack/loaders/load.ts
@@ -1,5 +1,6 @@
import type { LoaderContext } from 'webpack'
import type { ResolvedUnpluginOptions } from '../../types'
+import { normalizeObjectHook } from '../../utils/filter'
import { normalizeAbsolutePath } from '../../utils/webpack-like'
import { createBuildContext, createContext } from '../context'
@@ -15,7 +16,8 @@ export default async function load(this: LoaderContext, source: string, map
id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))
const context = createContext(this)
- const res = await plugin.load.call(
+ const { handler } = normalizeObjectHook('load', plugin.load)
+ const res = await handler.call(
Object.assign({}, createBuildContext({
addWatchFile: (file) => {
this.addDependency(file)
diff --git a/src/webpack/loaders/transform.ts b/src/webpack/loaders/transform.ts
index 9325f83a..2c2e1f33 100644
--- a/src/webpack/loaders/transform.ts
+++ b/src/webpack/loaders/transform.ts
@@ -1,5 +1,6 @@
import type { LoaderContext } from 'webpack'
import type { ResolvedUnpluginOptions } from '../../types'
+import { normalizeObjectHook } from '../../utils/filter'
import { createBuildContext, createContext } from '../context'
export default async function transform(this: LoaderContext, source: string, map: any): Promise {
@@ -10,9 +11,12 @@ export default async function transform(this: LoaderContext, source: string
return callback(null, source, map)
const context = createContext(this)
+ const { handler, filter } = normalizeObjectHook('transform', plugin.transform)
+ if (!filter(this.resource, source))
+ return callback(null, source, map)
try {
- const res = await plugin.transform.call(
+ const res = await handler.call(
Object.assign({}, createBuildContext({
addWatchFile: (file) => {
this.addDependency(file)
diff --git a/test/unit-tests/filter/filter.test.ts b/test/unit-tests/filter/filter.test.ts
new file mode 100644
index 00000000..0281079f
--- /dev/null
+++ b/test/unit-tests/filter/filter.test.ts
@@ -0,0 +1,172 @@
+import type { UnpluginOptions, VitePlugin } from 'unplugin'
+import type { Mock } from 'vitest'
+import * as path from 'node:path'
+import { createUnplugin } from 'unplugin'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { build, toArray } from '../utils'
+
+function createUnpluginWithHooks(
+ resolveId: UnpluginOptions['resolveId'],
+ load: UnpluginOptions['load'],
+ transform: UnpluginOptions['transform'],
+) {
+ return createUnplugin(() => ({
+ name: 'test-plugin',
+ resolveId,
+ load,
+ transform,
+ }))
+}
+
+function createIdHook() {
+ const handler = vi.fn()
+ return {
+ hook: {
+ filter: {
+ id: { include: [/\.js$/], exclude: ['**/entry.js', /not-expect/] },
+ },
+ handler,
+ },
+ handler,
+ }
+}
+
+function createTransformHook() {
+ const handler = vi.fn()
+ return {
+ hook: {
+ filter: {
+ id: { include: [/\.js$/], exclude: ['**/entry.js', /not-expect/] },
+ code: { include: '42' },
+ },
+ handler,
+ },
+ handler,
+ }
+}
+
+function check(resolveIdHandler: Mock, loadHandler: Mock, transformHandler: Mock): void {
+ expect(resolveIdHandler).toBeCalledTimes(1)
+ expect(loadHandler).toBeCalledTimes(1)
+ expect(transformHandler).toBeCalledTimes(1)
+
+ const testName = expect.getState().currentTestName
+ const hasExtraOptions = testName?.includes('vite') || testName?.includes('rolldown')
+
+ expect(transformHandler).lastCalledWith(
+ expect.stringMatching('export default 42'),
+ expect.stringMatching(/\bmod\.js$/),
+ ...hasExtraOptions ? [expect.anything()] : [],
+ )
+}
+
+describe('filter', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('vite', 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).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: [plugins],
+ build: {
+ lib: {
+ entry: path.resolve(__dirname, 'test-src/entry.js'),
+ name: 'TestLib',
+ },
+ write: false, // don't output anything
+ },
+ })
+
+ check(resolveIdHandler, loadHandler, transformHandler)
+ })
+
+ it('rollup', 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).rollup
+
+ await build.rollup({
+ input: path.resolve(__dirname, 'test-src/entry.js'),
+ plugins: [plugin()],
+ })
+
+ check(resolveIdHandler, loadHandler, transformHandler)
+ })
+
+ it('rolldown', 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).rolldown
+
+ await build.rolldown({
+ input: path.resolve(__dirname, 'test-src/entry.js'),
+ plugins: [plugin()],
+ })
+
+ check(resolveIdHandler, loadHandler, transformHandler)
+ })
+
+ it('webpack', 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).webpack
+
+ await new Promise((resolve) => {
+ build.webpack(
+ {
+ entry: path.resolve(__dirname, 'test-src/entry.js'),
+ plugins: [plugin()],
+ },
+ resolve,
+ )
+ })
+
+ check(resolveIdHandler, loadHandler, transformHandler)
+ })
+
+ it('rspack', 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).rspack
+
+ await new Promise((resolve) => {
+ build.rspack(
+ {
+ entry: path.resolve(__dirname, 'test-src/entry.js'),
+ plugins: [plugin()],
+ },
+ resolve,
+ )
+ })
+
+ check(resolveIdHandler, loadHandler, transformHandler)
+ })
+
+ it('esbuild', 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).esbuild
+
+ await build.esbuild({
+ entryPoints: [path.resolve(__dirname, 'test-src/entry.js')],
+ plugins: [plugin()],
+ bundle: true, // actually traverse imports
+ write: false, // don't pollute console
+ })
+
+ check(resolveIdHandler, loadHandler, transformHandler)
+ })
+})
diff --git a/test/unit-tests/filter/test-src/entry.js b/test/unit-tests/filter/test-src/entry.js
new file mode 100644
index 00000000..8b3f8761
--- /dev/null
+++ b/test/unit-tests/filter/test-src/entry.js
@@ -0,0 +1,5 @@
+import mod from './mod.js'
+import val from './not-expect.js'
+
+export const hello = mod
+export default val
diff --git a/test/unit-tests/filter/test-src/mod.js b/test/unit-tests/filter/test-src/mod.js
new file mode 100644
index 00000000..02f8a326
--- /dev/null
+++ b/test/unit-tests/filter/test-src/mod.js
@@ -0,0 +1 @@
+export default 42
diff --git a/test/unit-tests/filter/test-src/not-expect.js b/test/unit-tests/filter/test-src/not-expect.js
new file mode 100644
index 00000000..7e942cf4
--- /dev/null
+++ b/test/unit-tests/filter/test-src/not-expect.js
@@ -0,0 +1 @@
+export default 'foo'
diff --git a/test/unit-tests/utils.ts b/test/unit-tests/utils.ts
index 8d91c860..dfa6e26b 100644
--- a/test/unit-tests/utils.ts
+++ b/test/unit-tests/utils.ts
@@ -1,5 +1,6 @@
import * as rspack from '@rspack/core'
import * as esbuild from 'esbuild'
+import * as rolldown from 'rolldown'
import * as rollup from 'rollup'
import * as vite from 'vite'
import * as webpack from 'webpack'
@@ -8,6 +9,7 @@ export * from '../../src/utils/general'
export const viteBuild: typeof vite.build = vite.build
export const rollupBuild: typeof rollup.rollup = rollup.rollup
+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
@@ -18,12 +20,14 @@ export const build: {
webpack: typeof webpack.webpack
rspack: typeof rspackBuild
rollup: typeof rollupBuild
+ rolldown: typeof rolldownBuild
vite: typeof viteBuild
esbuild: typeof esbuildBuild
} = {
webpack: webpackBuild,
rspack: rspackBuild,
rollup: rollupBuild,
+ rolldown: rolldownBuild,
vite(config) {
return viteBuild(vite.mergeConfig(config || {}, {
build: {