From 8aec5f759805e8ae105ee86ab78d2d23316b92ec Mon Sep 17 00:00:00 2001 From: shulaoda <165626830+shulaoda@users.noreply.github.com> Date: Thu, 7 May 2026 22:57:27 +0800 Subject: [PATCH 1/8] feat: add @rolldown/pluginutils package --- .github/workflows/publish.yml | 1 + .github/workflows/release-tag.yml | 1 + packages/pluginutils/CHANGELOG.md | 1 + packages/pluginutils/README.md | 13 + packages/pluginutils/package.json | 41 +++ .../src/filter/composable-filters.test.ts | 252 +++++++++++++ .../src/filter/composable-filters.ts | 331 ++++++++++++++++++ .../src/filter/filter-vite-plugins.test.ts | 153 ++++++++ .../src/filter/filter-vite-plugins.ts | 83 +++++ packages/pluginutils/src/filter/index.ts | 3 + .../src/filter/simple-filters.test-d.ts | 47 +++ .../src/filter/simple-filters.test.ts | 126 +++++++ .../pluginutils/src/filter/simple-filters.ts | 114 ++++++ packages/pluginutils/src/index.ts | 1 + packages/pluginutils/src/utils.ts | 19 + packages/pluginutils/tsdown.config.ts | 12 + packages/pluginutils/vitest.config.ts | 8 + pnpm-lock.yaml | 29 +- scripts/release.ts | 1 + 19 files changed, 1232 insertions(+), 4 deletions(-) create mode 100644 packages/pluginutils/CHANGELOG.md create mode 100644 packages/pluginutils/README.md create mode 100644 packages/pluginutils/package.json create mode 100644 packages/pluginutils/src/filter/composable-filters.test.ts create mode 100644 packages/pluginutils/src/filter/composable-filters.ts create mode 100644 packages/pluginutils/src/filter/filter-vite-plugins.test.ts create mode 100644 packages/pluginutils/src/filter/filter-vite-plugins.ts create mode 100644 packages/pluginutils/src/filter/index.ts create mode 100644 packages/pluginutils/src/filter/simple-filters.test-d.ts create mode 100644 packages/pluginutils/src/filter/simple-filters.test.ts create mode 100644 packages/pluginutils/src/filter/simple-filters.ts create mode 100644 packages/pluginutils/src/index.ts create mode 100644 packages/pluginutils/src/utils.ts create mode 100644 packages/pluginutils/tsdown.config.ts create mode 100644 packages/pluginutils/vitest.config.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7bda596..46ecdf3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,7 @@ on: tags: - 'plugin-*' - 'oxc-unshadowed-visitor@*' + - 'pluginutils@*' jobs: publish: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 970d838..154e14d 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -5,6 +5,7 @@ on: tags: - 'plugin-*' - 'oxc-unshadowed-visitor@*' + - 'pluginutils@*' jobs: release: diff --git a/packages/pluginutils/CHANGELOG.md b/packages/pluginutils/CHANGELOG.md new file mode 100644 index 0000000..8af44dc --- /dev/null +++ b/packages/pluginutils/CHANGELOG.md @@ -0,0 +1 @@ +# @rolldown/pluginutils Changelog diff --git a/packages/pluginutils/README.md b/packages/pluginutils/README.md new file mode 100644 index 0000000..acaa0a6 --- /dev/null +++ b/packages/pluginutils/README.md @@ -0,0 +1,13 @@ +# @rolldown/pluginutils + +Plugin utilities for [Rolldown](https://rolldown.rs). + +## Installation + +```bash +npm i -D @rolldown/pluginutils +``` + +## Usage + +See [the source](./src) for the available APIs (filter helpers, regex builders, etc.). diff --git a/packages/pluginutils/package.json b/packages/pluginutils/package.json new file mode 100644 index 0000000..f1f7e01 --- /dev/null +++ b/packages/pluginutils/package.json @@ -0,0 +1,41 @@ +{ + "name": "@rolldown/pluginutils", + "version": "1.0.0", + "description": "Plugin utilities for Rolldown", + "keywords": [ + "filter", + "plugin", + "rolldown", + "rolldown-plugin" + ], + "homepage": "https://github.com/rolldown/plugins/tree/main/packages/pluginutils#readme", + "bugs": { + "url": "https://github.com/rolldown/plugins/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rolldown/plugins.git", + "directory": "packages/pluginutils" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./dist/index.mjs", + "./filter": "./dist/filter/index.mjs", + "./package.json": "./package.json" + }, + "scripts": { + "dev": "tsdown --watch", + "build": "tsdown", + "test": "vitest --project pluginutils", + "prepublishOnly": "pnpm run build" + }, + "devDependencies": { + "@types/picomatch": "^4.0.3", + "picomatch": "^4.0.4", + "typescript": "^5.9.3" + } +} diff --git a/packages/pluginutils/src/filter/composable-filters.test.ts b/packages/pluginutils/src/filter/composable-filters.test.ts new file mode 100644 index 0000000..6f2583d --- /dev/null +++ b/packages/pluginutils/src/filter/composable-filters.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from 'vitest' +import type { QueryFilterObject } from './composable-filters' +import { + and, + exclude, + id, + importerId, + include, + interpreter, + or, + queries, + query, +} from './composable-filters' + +function queryFilter(id: string, queryFilterObject: QueryFilterObject): boolean { + let topLevelFilterExpression = include(queries(queryFilterObject)) + return interpreter([topLevelFilterExpression], undefined, id, undefined) +} +describe('queryFilter', () => { + test('boolean', () => { + expect( + queryFilter('/foo/bar?a=1111&b=2222', { + a: true, + b: true, + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a=1111&b=2222', { + a: true, + b: false, + }), + ).toBe(false) + + expect( + queryFilter('/foo/bar?a=1111&b=2222', { + a: true, + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a=1111&b=2222', { + bar: false, + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a', { + a: true, + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a=', { + a: true, + }), + ).toBe(true) + }) + + test('string', () => { + expect( + queryFilter('/foo/bar?a=1111&b=2222', { + a: '1111', + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a=1111&b=bar', { + a: '1111', + b: 'bar', + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a=1111&b=bar', { + d: '1111', + }), + ).toBe(false) + }) + + test('regex', () => { + expect( + queryFilter('/foo/bar?a=1111&b=2222', { + a: /[\d]+/, + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a=1111&b=bar', { + b: /bar/, + }), + ).toBe(true) + + expect( + queryFilter('/foo/bar?a=1111&b=bar', { + d: /1111/, + }), + ).toBe(false) + }) + + test('custom', () => { + // https://github.com/sveltejs/vite-plugin-svelte/blob/3589433cd19464c484f560516d41e670e5d40710/packages/vite-plugin-svelte/src/utils/id.js#L35-L40 + let filterExpr = or( + query('url', true), + and(query('svelte', false), or(query('raw', true), query('direct', true))), + ) + // include `url`, should return `false` + expect(interpreter([exclude(filterExpr)], undefined, '/foo/bar?url=1', undefined)).toBe(false) + // don't have `svelte` and has `raw`, should return `false` + expect(interpreter([exclude(filterExpr)], undefined, '/foo/bar?raw=1', undefined)).toBe(false) + // don't have `svelte`, but don't have `raw` and `direct` neither, should return `true` + expect(interpreter([exclude(filterExpr)], undefined, '/foo/bar', undefined)).toBe(true) + // have `url` should return `false` even query also has `svelte` + expect( + interpreter([exclude(filterExpr)], undefined, '/foo/bar?url=1111&svelte=true', undefined), + ).toBe(false) + }) +}) + +describe('importerIdFilter', () => { + test('string pattern', () => { + // exact match + expect( + interpreter( + [include(importerId('/src/main.ts'))], + undefined, + '/src/foo.ts', + undefined, + '/src/main.ts', + ), + ).toBe(true) + + // no match + expect( + interpreter( + [include(importerId('/src/main.ts'))], + undefined, + '/src/foo.ts', + undefined, + '/src/other.ts', + ), + ).toBe(false) + }) + + test('regex pattern', () => { + // regex match + expect( + interpreter( + [include(importerId(/\.vue$/))], + undefined, + '/src/component.ts', + undefined, + '/src/App.vue', + ), + ).toBe(true) + + // regex no match + expect( + interpreter( + [include(importerId(/\.vue$/))], + undefined, + '/src/component.ts', + undefined, + '/src/main.ts', + ), + ).toBe(false) + }) + + test('cleanUrl option', () => { + // with cleanUrl, should strip query params + expect( + interpreter( + [include(importerId('/src/main.ts', { cleanUrl: true }))], + undefined, + '/src/foo.ts', + undefined, + '/src/main.ts?query=1', + ), + ).toBe(true) + + // without cleanUrl, query params are included + expect( + interpreter( + [include(importerId('/src/main.ts'))], + undefined, + '/src/foo.ts', + undefined, + '/src/main.ts?query=1', + ), + ).toBe(false) + }) + + test('combined with id filter', () => { + // both id and importerId must match + expect( + interpreter( + [include(and(id(/\.ts$/), importerId(/\.vue$/)))], + undefined, + '/src/component.ts', + undefined, + '/src/App.vue', + ), + ).toBe(true) + + // id matches but importerId doesn't + expect( + interpreter( + [include(and(id(/\.ts$/), importerId(/\.vue$/)))], + undefined, + '/src/component.ts', + undefined, + '/src/main.ts', + ), + ).toBe(false) + }) + + test('exclude with importerId', () => { + // exclude files imported from node_modules + expect( + interpreter( + [exclude(importerId(/node_modules/))], + undefined, + '/src/foo.ts', + undefined, + '/node_modules/some-package/index.js', + ), + ).toBe(false) + + // include files imported from src + expect( + interpreter( + [exclude(importerId(/node_modules/))], + undefined, + '/src/foo.ts', + undefined, + '/src/utils.ts', + ), + ).toBe(true) + }) + + test('returns false when importerId is undefined', () => { + expect( + interpreter( + [include(importerId('/src/main.ts'))], + undefined, + '/src/foo.ts', + undefined, + undefined, + ), + ).toBe(false) + }) +}) diff --git a/packages/pluginutils/src/filter/composable-filters.ts b/packages/pluginutils/src/filter/composable-filters.ts new file mode 100644 index 0000000..af3ac04 --- /dev/null +++ b/packages/pluginutils/src/filter/composable-filters.ts @@ -0,0 +1,331 @@ +import { cleanUrl, extractQueryWithoutFragment } from '../utils.ts' + +type StringOrRegExp = string | RegExp + +// Inline this type to avoid import it from `rolldown`. +// The only downside is we need to keep it in sync with `rolldown` manually, +// it is alright since it is pretty stable now. +type PluginModuleType = + | 'js' + | 'jsx' + | 'ts' + | 'tsx' + | 'json' + | 'text' + | 'base64' + | 'dataurl' + | 'binary' + | 'empty' + | (string & {}) + +export type FilterExpressionKind = FilterExpression['kind'] + +export type FilterExpression = And | Or | Not | Id | ImporterId | ModuleType | Code | Query + +export type TopLevelFilterExpression = Include | Exclude + +class And { + kind: 'and' + args: FilterExpression[] + constructor(...args: FilterExpression[]) { + if (args.length === 0) { + throw new Error('`And` expects at least one operand') + } + this.args = args + this.kind = 'and' + } +} +class Or { + kind: 'or' + args: FilterExpression[] + constructor(...args: FilterExpression[]) { + if (args.length === 0) { + throw new Error('`Or` expects at least one operand') + } + this.args = args + this.kind = 'or' + } +} + +class Not { + kind: 'not' + expr: FilterExpression + constructor(expr: FilterExpression) { + this.expr = expr + this.kind = 'not' + } +} + +export interface QueryFilterObject { + [key: string]: StringOrRegExp | boolean +} + +interface IdParams { + cleanUrl?: boolean +} + +class Id { + kind: 'id' + pattern: StringOrRegExp + params: IdParams + constructor(pattern: StringOrRegExp, params?: IdParams) { + this.pattern = pattern + this.kind = 'id' + this.params = params ?? { + cleanUrl: false, + } + } +} + +class ImporterId { + kind: 'importerId' + pattern: StringOrRegExp + params: IdParams + constructor(pattern: StringOrRegExp, params?: IdParams) { + this.pattern = pattern + this.kind = 'importerId' + this.params = params ?? { + cleanUrl: false, + } + } +} + +class ModuleType { + kind: 'moduleType' + pattern: PluginModuleType + constructor(pattern: PluginModuleType) { + this.pattern = pattern + this.kind = 'moduleType' + } +} + +class Code { + kind: 'code' + pattern: StringOrRegExp + constructor(expr: StringOrRegExp) { + this.pattern = expr + this.kind = 'code' + } +} + +class Query { + kind: 'query' + key: string + pattern: StringOrRegExp | boolean + constructor(key: string, pattern: StringOrRegExp | boolean) { + this.pattern = pattern + this.key = key + this.kind = 'query' + } +} + +class Include { + kind: 'include' + expr: FilterExpression + constructor(expr: FilterExpression) { + this.expr = expr + this.kind = 'include' + } +} + +class Exclude { + kind: 'exclude' + expr: FilterExpression + constructor(expr: FilterExpression) { + this.expr = expr + this.kind = 'exclude' + } +} + +export function and(...args: FilterExpression[]): And { + return new And(...args) +} + +export function or(...args: FilterExpression[]): Or { + return new Or(...args) +} + +export function not(expr: FilterExpression): Not { + return new Not(expr) +} + +export function id(pattern: StringOrRegExp, params?: IdParams): Id { + return new Id(pattern, params) +} + +export function importerId(pattern: StringOrRegExp, params?: IdParams): ImporterId { + return new ImporterId(pattern, params) +} + +export function moduleType(pattern: PluginModuleType): ModuleType { + return new ModuleType(pattern) +} + +export function code(pattern: StringOrRegExp): Code { + return new Code(pattern) +} + +/* + * There are three kinds of conditions are supported: + * 1. `boolean`: if the value is `true`, the key must exist and be truthy. if the value is `false`, the key must not exist or be falsy. + * 2. `string`: the key must exist and be equal to the value. + * 3. `RegExp`: the key must exist and match the value. + */ +export function query(key: string, pattern: StringOrRegExp | boolean): Query { + return new Query(key, pattern) +} + +export function include(expr: FilterExpression): Include { + return new Include(expr) +} + +export function exclude(expr: FilterExpression): Exclude { + return new Exclude(expr) +} + +/** + * convert a queryObject to FilterExpression like + * ```js + * and(query(k1, v1), query(k2, v2)) + * ``` + * @param queryFilterObject The query filter object needs to be matched. + * @returns a `And` FilterExpression + */ +export function queries(queryFilter: QueryFilterObject): And { + let arr = Object.entries(queryFilter).map(([key, value]) => { + return new Query(key, value) + }) + return and(...arr) +} + +export function interpreter( + exprs: TopLevelFilterExpression | TopLevelFilterExpression[], + code?: string, + id?: string, + moduleType?: PluginModuleType, + importerId?: string, +): boolean { + let arr: TopLevelFilterExpression[] = [] + if (Array.isArray(exprs)) { + arr = exprs + } else { + arr = [exprs] + } + return interpreterImpl(arr, code, id, moduleType, importerId) +} + +interface InterpreterCtx { + urlSearchParamsCache?: URLSearchParams +} + +export function interpreterImpl( + expr: TopLevelFilterExpression[], + code?: string, + id?: string, + moduleType?: PluginModuleType, + importerId?: string, + ctx: InterpreterCtx = {}, +): boolean { + let hasInclude = false + for (const e of expr) { + switch (e.kind) { + case 'include': { + hasInclude = true + if (exprInterpreter(e.expr, code, id, moduleType, importerId, ctx)) { + return true + } + break + } + case 'exclude': { + if (exprInterpreter(e.expr, code, id, moduleType, importerId, ctx)) { + return false + } + break + } + } + } + return !hasInclude +} + +export function exprInterpreter( + expr: FilterExpression, + code?: string, + id?: string, + moduleType?: PluginModuleType, + importerId?: string, + ctx: InterpreterCtx = {}, +): boolean { + switch (expr.kind) { + case 'and': { + return expr.args.every((e) => exprInterpreter(e, code, id, moduleType, importerId, ctx)) + } + case 'or': { + return expr.args.some((e) => exprInterpreter(e, code, id, moduleType, importerId, ctx)) + } + case 'not': { + return !exprInterpreter(expr.expr, code, id, moduleType, importerId, ctx) + } + case 'id': { + if (id === undefined) { + throw new Error('`id` is required for `id` expression') + } + let idToMatch = id + if (expr.params.cleanUrl) { + idToMatch = cleanUrl(idToMatch) + } + return typeof expr.pattern === 'string' + ? idToMatch === expr.pattern + : expr.pattern.test(idToMatch) + } + case 'importerId': { + if (importerId === undefined) { + return false // Entry files have no importer, so no match + } + let importerIdToMatch = importerId + if (expr.params.cleanUrl) { + importerIdToMatch = cleanUrl(importerIdToMatch) + } + return typeof expr.pattern === 'string' + ? importerIdToMatch === expr.pattern + : expr.pattern.test(importerIdToMatch) + } + case 'moduleType': { + if (moduleType === undefined) { + throw new Error('`moduleType` is required for `moduleType` expression') + } + return moduleType === expr.pattern + } + case 'code': { + if (code === undefined) { + throw new Error('`code` is required for `code` expression') + } + return typeof expr.pattern === 'string' + ? code.includes(expr.pattern) + : expr.pattern.test(code) + } + case 'query': { + if (id === undefined) { + throw new Error('`id` is required for `Query` expression') + } + if (!ctx.urlSearchParamsCache) { + let queryString = extractQueryWithoutFragment(id) + ctx.urlSearchParamsCache = new URLSearchParams(queryString) + } + let urlParams = ctx.urlSearchParamsCache + if (typeof expr.pattern === 'boolean') { + if (expr.pattern) { + return urlParams.has(expr.key) + } else { + return !urlParams.has(expr.key) + } + } else if (typeof expr.pattern === 'string') { + return urlParams.get(expr.key) === expr.pattern + } else { + return expr.pattern.test(urlParams.get(expr.key) ?? '') + } + } + default: { + throw new Error(`Expression ${JSON.stringify(expr)} is not expected.`) + } + } +} diff --git a/packages/pluginutils/src/filter/filter-vite-plugins.test.ts b/packages/pluginutils/src/filter/filter-vite-plugins.test.ts new file mode 100644 index 0000000..a1de054 --- /dev/null +++ b/packages/pluginutils/src/filter/filter-vite-plugins.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'vitest' +import { filterVitePlugins } from './filter-vite-plugins.js' + +describe('filterVitePlugins', () => { + test('returns empty array for null/undefined input', () => { + expect(filterVitePlugins(null)).toEqual([]) + expect(filterVitePlugins(undefined)).toEqual([]) + expect(filterVitePlugins(false)).toEqual([]) + }) + + test('includes plugins without apply property', () => { + const plugins = [{ name: 'plugin1' }, { name: 'plugin2' }] + + const result = filterVitePlugins(plugins) + expect(result).toEqual(plugins) + }) + + test('filters out plugins with apply: "serve"', () => { + const plugins = [ + { name: 'plugin1', apply: 'build' }, + { name: 'plugin2', apply: 'serve' }, + { name: 'plugin3' }, + ] + + const result = filterVitePlugins(plugins) + expect(result).toEqual([{ name: 'plugin1', apply: 'build' }, { name: 'plugin3' }]) + }) + + test('includes plugins with apply: "build"', () => { + const plugins = [ + { name: 'plugin1', apply: 'build' }, + { name: 'plugin2', apply: 'build' }, + ] + + const result = filterVitePlugins(plugins) + expect(result).toEqual(plugins) + }) + + test('handles nested arrays', () => { + const plugins = [ + { name: 'plugin1' }, + [ + { name: 'plugin2', apply: 'serve' }, + { name: 'plugin3', apply: 'build' }, + ], + { name: 'plugin4' }, + ] + + const result = filterVitePlugins(plugins) + expect(result).toEqual([ + { name: 'plugin1' }, + { name: 'plugin3', apply: 'build' }, + { name: 'plugin4' }, + ]) + }) + + test('handles function apply that returns true', () => { + const plugins = [ + { + name: 'plugin1', + apply: () => true, + }, + ] + + const result = filterVitePlugins(plugins) + expect(result).toHaveLength(1) + expect(result[0]).toHaveProperty('name', 'plugin1') + }) + + test('filters out plugins with function apply that returns false', () => { + const plugins = [ + { + name: 'plugin1', + apply: () => false, + }, + { name: 'plugin2' }, + ] + + const result = filterVitePlugins(plugins) + expect(result).toEqual([{ name: 'plugin2' }]) + }) + + test('calls apply function with correct arguments', () => { + let calledConfig + let calledEnv + + const plugins = [ + { + name: 'plugin1', + apply: (config: any, env: any) => { + calledConfig = config + calledEnv = env + return true + }, + }, + ] + + filterVitePlugins(plugins) + + expect(calledConfig).toEqual({}) + expect(calledEnv).toEqual({ command: 'build', mode: 'production' }) + }) + + test('includes plugin if apply function throws', () => { + const plugins = [ + { + name: 'plugin1', + apply: () => { + throw new Error('test error') + }, + }, + ] + + const result = filterVitePlugins(plugins) + expect(result).toHaveLength(1) + expect(result[0]).toHaveProperty('name', 'plugin1') + }) + + test('filters out falsy values in array', () => { + const plugins = [{ name: 'plugin1' }, null, undefined, false, { name: 'plugin2' }] + + const result = filterVitePlugins(plugins) + expect(result).toEqual([{ name: 'plugin1' }, { name: 'plugin2' }]) + }) + + test('handles single plugin (not in array)', () => { + const plugin = { name: 'plugin1' } + const result = filterVitePlugins(plugin) + expect(result).toEqual([plugin]) + }) + + test('filters single plugin with apply: "serve"', () => { + const plugin = { name: 'plugin1', apply: 'serve' } + const result = filterVitePlugins(plugin) + expect(result).toEqual([]) + }) + + test('complex nested scenario', () => { + const plugins = [ + { name: 'plugin1' }, + [ + { name: 'plugin2', apply: 'serve' }, + [{ name: 'plugin3', apply: 'build' }, null, { name: 'plugin4', apply: 'serve' }], + ], + false, + { name: 'plugin5', apply: () => true }, + { name: 'plugin6', apply: () => false }, + ] + + const result = filterVitePlugins(plugins) + expect(result.map((p: any) => p.name)).toEqual(['plugin1', 'plugin3', 'plugin5']) + }) +}) diff --git a/packages/pluginutils/src/filter/filter-vite-plugins.ts b/packages/pluginutils/src/filter/filter-vite-plugins.ts new file mode 100644 index 0000000..b42fc95 --- /dev/null +++ b/packages/pluginutils/src/filter/filter-vite-plugins.ts @@ -0,0 +1,83 @@ +/** + * Filters out Vite plugins that have `apply: 'serve'` set. + * + * Since Rolldown operates in build mode, plugins marked with `apply: 'serve'` + * are intended only for Vite's dev server and should be excluded from the build process. + * + * @param plugins - Array of plugins (can include nested arrays) + * @returns Filtered array with serve-only plugins removed + * + * @example + * ```ts + * import { defineConfig } from 'rolldown'; + * import { filterVitePlugins } from '@rolldown/pluginutils'; + * import viteReact from '@vitejs/plugin-react'; + * + * export default defineConfig({ + * plugins: filterVitePlugins([ + * viteReact(), + * { + * name: 'dev-only', + * apply: 'serve', // This will be filtered out + * // ... + * } + * ]) + * }); + * ``` + */ +export function filterVitePlugins(plugins: T | T[] | null | undefined | false): T[] { + if (!plugins) { + return [] + } + + const pluginArray = Array.isArray(plugins) ? plugins : [plugins] + const result: T[] = [] + + for (const plugin of pluginArray) { + // Skip falsy values + if (!plugin) { + continue + } + + // Handle nested arrays recursively + if (Array.isArray(plugin)) { + result.push(...filterVitePlugins(plugin)) + continue + } + + // Check if plugin has apply property + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + const pluginWithApply = plugin as any + + if ('apply' in pluginWithApply) { + const applyValue = pluginWithApply.apply + + // If apply is a function, call it with build mode + if (typeof applyValue === 'function') { + try { + const shouldApply = applyValue( + {}, // config object + { command: 'build', mode: 'production' }, + ) + if (shouldApply) { + result.push(plugin) + } + } catch { + // If function throws, include the plugin to be safe + result.push(plugin) + } + } // If apply is 'serve', skip this plugin + else if (applyValue === 'serve') { + continue + } // If apply is 'build' or anything else, include it + else { + result.push(plugin) + } + } else { + // No apply property, include the plugin + result.push(plugin) + } + } + + return result +} diff --git a/packages/pluginutils/src/filter/index.ts b/packages/pluginutils/src/filter/index.ts new file mode 100644 index 0000000..051ab64 --- /dev/null +++ b/packages/pluginutils/src/filter/index.ts @@ -0,0 +1,3 @@ +export * from './composable-filters.ts' +export * from './filter-vite-plugins.ts' +export * from './simple-filters.ts' diff --git a/packages/pluginutils/src/filter/simple-filters.test-d.ts b/packages/pluginutils/src/filter/simple-filters.test-d.ts new file mode 100644 index 0000000..b6f6c57 --- /dev/null +++ b/packages/pluginutils/src/filter/simple-filters.test-d.ts @@ -0,0 +1,47 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { makeIdFiltersToMatchWithQuery } from './simple-filters.js' + +describe('makeIdFiltersToMatchWithQuery', () => { + test('single string input', () => { + const input = 'foo' + expectTypeOf(makeIdFiltersToMatchWithQuery(input)).toEqualTypeOf() + + // string literal should return normal string + expectTypeOf(makeIdFiltersToMatchWithQuery('foo')).not.toEqualTypeOf<'foo'>() + expectTypeOf(makeIdFiltersToMatchWithQuery('foo')).toEqualTypeOf() + }) + + test('single regex input', () => { + expectTypeOf(makeIdFiltersToMatchWithQuery(/foo/)).toEqualTypeOf() + }) + + test('single string or regex input', () => { + const input = 'foo' as string | RegExp + expectTypeOf(makeIdFiltersToMatchWithQuery(input)).toEqualTypeOf() + }) + + test('array string input', () => { + const input = ['foo'] + expectTypeOf(makeIdFiltersToMatchWithQuery(input)).toEqualTypeOf() + + // string literal should return normal string + expectTypeOf(makeIdFiltersToMatchWithQuery(['foo'])).not.toEqualTypeOf<'foo'[]>() + expectTypeOf(makeIdFiltersToMatchWithQuery(['foo'])).toEqualTypeOf() + }) + + test('array regex input', () => { + expectTypeOf(makeIdFiltersToMatchWithQuery([/foo/])).toEqualTypeOf() + }) + + test('array string or regex input', () => { + const input = ['foo'] as (string | RegExp)[] + expectTypeOf(makeIdFiltersToMatchWithQuery(input)).toEqualTypeOf<(string | RegExp)[]>() + }) + + test('mixed input', () => { + const input = ['foo', /bar/] as (string | RegExp)[] | string | RegExp + expectTypeOf(makeIdFiltersToMatchWithQuery(input)).toEqualTypeOf< + (string | RegExp)[] | string | RegExp + >() + }) +}) diff --git a/packages/pluginutils/src/filter/simple-filters.test.ts b/packages/pluginutils/src/filter/simple-filters.test.ts new file mode 100644 index 0000000..2dab81f --- /dev/null +++ b/packages/pluginutils/src/filter/simple-filters.test.ts @@ -0,0 +1,126 @@ +import picomatch from 'picomatch' +import { describe, expect, test } from 'vitest' +import { exactRegex, makeIdFiltersToMatchWithQuery, prefixRegex } from './simple-filters.js' + +describe('exactRegex', () => { + test('supports without flag parameter', () => { + const regex = exactRegex('foo') + expect(regex).toStrictEqual(/^foo$/) + + expect(regex.test('foo')).toBe(true) + expect(regex.test('fooa')).toBe(false) + expect(regex.test('afoo')).toBe(false) + }) + + test('supports with flag parameter', () => { + const regex = exactRegex('foo', 'i') + expect(regex).toStrictEqual(/^foo$/i) + + expect(regex.test('foo')).toBe(true) + expect(regex.test('Foo')).toBe(true) + expect(regex.test('Fooa')).toBe(false) + expect(regex.test('aFoo')).toBe(false) + }) + + test('escapes special characters for Regex', () => { + const regex = exactRegex('foo(bar)') + expect(regex).toStrictEqual(/^foo\(bar\)$/) + + expect(regex.test('foo(bar)')).toBe(true) + expect(regex.test('foo(bar\\)')).toBe(false) + expect(regex.test('foo(bar)a')).toBe(false) + expect(regex.test('afoo(bar)')).toBe(false) + }) +}) + +describe('prefixRegex', () => { + test('supports without flag parameter', () => { + const regex = prefixRegex('foo') + expect(regex).toStrictEqual(/^foo/) + + expect(regex.test('foo')).toBe(true) + expect(regex.test('fooa')).toBe(true) + expect(regex.test('afoo')).toBe(false) + }) + + test('supports with flag parameter', () => { + const regex = prefixRegex('foo', 'i') + expect(regex).toStrictEqual(/^foo/i) + + expect(regex.test('foo')).toBe(true) + expect(regex.test('Foo')).toBe(true) + expect(regex.test('Fooa')).toBe(true) + expect(regex.test('aFoo')).toBe(false) + }) + + test('escapes special characters for Regex', () => { + const regex = prefixRegex('foo(bar)') + expect(regex).toStrictEqual(/^foo\(bar\)/) + + expect(regex.test('foo(bar)')).toBe(true) + expect(regex.test('foo(bar\\)')).toBe(false) + expect(regex.test('foo(bar)a')).toBe(true) + expect(regex.test('afoo(bar)')).toBe(false) + }) +}) + +describe('makeIdFiltersToMatchWithQuery', () => { + function expectWithAnyQuery(matcher: (path: string) => boolean, path: string, expected: boolean) { + expect(matcher(path), path).toBe(expected) + expect(matcher(`${path}?foo`), `${path}?foo`).toBe(expected) + expect(matcher(`${path}?foo=bar`), `${path}?foo=bar`).toBe(expected) + } + + test('supports glob patterns', () => { + const input = '/foo/**/*.js' + const output = makeIdFiltersToMatchWithQuery(input) + + const matcher = picomatch(output) + expectWithAnyQuery(matcher, '/foo/bar.js', true) + expectWithAnyQuery(matcher, '/foo/bar.ts', false) + expect(matcher('/foo/bar.txt?.js')).toBe(true) + }) + + test('supports regex patterns without `$`', () => { + const input = /\/foo\// + const output = makeIdFiltersToMatchWithQuery(input) + + const matcher = (path: string) => output.test(path) + expectWithAnyQuery(matcher, '/foo/bar.js', true) + expectWithAnyQuery(matcher, '/bar/bar.ts', false) + expect(matcher('/foo/bar.txt?.js')).toBe(true) + }) + + test('supports regex patterns with `$`', () => { + const input = /\/foo\/.*\.js$/ + const output = makeIdFiltersToMatchWithQuery(input) + + const matcher = (path: string) => output.test(path) + expectWithAnyQuery(matcher, '/foo/bar.js', true) + expectWithAnyQuery(matcher, '/foo/bar.ts', false) + expect(matcher('/foo/bar.txt?.js')).toBe(true) + }) + + test('supports regex patterns with multiple `$`', () => { + const input = /\/foo\/[^/]*(\/src|\/dist\/[^/]*\.js$|$)/ + const output = makeIdFiltersToMatchWithQuery(input) + + const matcher = (path: string) => output.test(path) + expectWithAnyQuery(matcher, '/foo/bar/src/foo', true) + expectWithAnyQuery(matcher, '/foo/bar/dist/foo.js', true) + expectWithAnyQuery(matcher, '/foo/bar/dist/foo.ts', false) + expectWithAnyQuery(matcher, '/foo/bar', true) + expectWithAnyQuery(matcher, '/foo/bar/', false) + expect(matcher('/foo/bar/dist/foo.txt?.js')).toBe(true) + }) + + test('supports regex patterns with `\\$`', () => { + const input = /\/foo\/\$.*\.js$/ + const output = makeIdFiltersToMatchWithQuery(input) + + const matcher = (path: string) => output.test(path) + expectWithAnyQuery(matcher, '/foo/$bar.js', true) + expectWithAnyQuery(matcher, '/foo/$bar.ts', false) + expect(matcher('/foo/$bar.txt?.js')).toBe(true) + }) +}) diff --git a/packages/pluginutils/src/filter/simple-filters.ts b/packages/pluginutils/src/filter/simple-filters.ts new file mode 100644 index 0000000..990d517 --- /dev/null +++ b/packages/pluginutils/src/filter/simple-filters.ts @@ -0,0 +1,114 @@ +/** + * Constructs a RegExp that matches the exact string specified. + * + * This is useful for plugin hook filters. + * + * @param str the string to match. + * @param flags flags for the RegExp. + * + * @example + * ```ts + * import { exactRegex } from '@rolldown/pluginutils'; + * const plugin = { + * name: 'plugin', + * resolveId: { + * filter: { id: exactRegex('foo') }, + * handler(id) {} // will only be called for `foo` + * } + * } + * ``` + */ +export function exactRegex(str: string, flags?: string): RegExp { + return new RegExp(`^${escapeRegex(str)}$`, flags) +} + +/** + * Constructs a RegExp that matches a value that has the specified prefix. + * + * This is useful for plugin hook filters. + * + * @param str the string to match. + * @param flags flags for the RegExp. + * + * @example + * ```ts + * import { prefixRegex } from '@rolldown/pluginutils'; + * const plugin = { + * name: 'plugin', + * resolveId: { + * filter: { id: prefixRegex('foo') }, + * handler(id) {} // will only be called for IDs starting with `foo` + * } + * } + * ``` + */ +export function prefixRegex(str: string, flags?: string): RegExp { + return new RegExp(`^${escapeRegex(str)}`, flags) +} + +const escapeRegexRE = /[-/\\^$*+?.()|[\]{}]/g +function escapeRegex(str: string): string { + return str.replace(escapeRegexRE, '\\$&') +} + +type WidenString = T extends string ? string : T + +/** + * Converts a id filter to match with an id with a query. + * + * @param input the id filters to convert. + * + * @example + * ```ts + * import { makeIdFiltersToMatchWithQuery } from '@rolldown/pluginutils'; + * const plugin = { + * name: 'plugin', + * transform: { + * filter: { id: makeIdFiltersToMatchWithQuery(['**' + '/*.js', /\.ts$/]) }, + * // The handler will be called for IDs like: + * // - foo.js + * // - foo.js?foo + * // - foo.txt?foo.js + * // - foo.ts + * // - foo.ts?foo + * // - foo.txt?foo.ts + * handler(code, id) {} + * } + * } + * ``` + */ +export function makeIdFiltersToMatchWithQuery(input: T): WidenString +export function makeIdFiltersToMatchWithQuery( + input: readonly T[], +): WidenString[] +export function makeIdFiltersToMatchWithQuery( + input: string | RegExp | readonly (string | RegExp)[], +): string | RegExp | (string | RegExp)[] +export function makeIdFiltersToMatchWithQuery( + input: string | RegExp | readonly (string | RegExp)[], +): string | RegExp | (string | RegExp)[] { + if (!Array.isArray(input)) { + return makeIdFilterToMatchWithQuery( + // Array.isArray cannot narrow the type + // https://github.com/microsoft/TypeScript/issues/17002 + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + input as Exclude, + ) + } + return input.map((i) => makeIdFilterToMatchWithQuery(i)) +} + +function makeIdFilterToMatchWithQuery(input: string | RegExp): string | RegExp { + if (typeof input === 'string') { + return `${input}{?*,}` + } + return makeRegexIdFilterToMatchWithQuery(input) +} + +function makeRegexIdFilterToMatchWithQuery(input: RegExp) { + return new RegExp( + // replace `$` with `(?:\?.*)?$` (ignore `\$`) + input.source.replace(/(?=14.17'} + hasBin: true + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -4518,7 +4535,7 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260419.1)(rolldown@1.0.0-rc.16): + rolldown-plugin-dts@0.23.2(@typescript/native-preview@7.0.0-dev.20260419.1)(rolldown@1.0.0-rc.16)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 @@ -4533,6 +4550,7 @@ snapshots: rolldown: 1.0.0-rc.16 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260419.1 + typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -4648,7 +4666,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.21.9(@typescript/native-preview@7.0.0-dev.20260419.1)(publint@0.3.17): + tsdown@0.21.9(@typescript/native-preview@7.0.0-dev.20260419.1)(publint@0.3.17)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -4659,7 +4677,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0-rc.16 - rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260419.1)(rolldown@1.0.0-rc.16) + rolldown-plugin-dts: 0.23.2(@typescript/native-preview@7.0.0-dev.20260419.1)(rolldown@1.0.0-rc.16)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.1.1 tinyglobby: 0.2.16 @@ -4668,6 +4686,7 @@ snapshots: unrun: 0.2.36 optionalDependencies: publint: 0.3.17 + typescript: 5.9.3 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -4680,6 +4699,8 @@ snapshots: type-level-regexp@0.1.17: {} + typescript@5.9.3: {} + ufo@1.6.3: {} uglify-js@3.19.3: diff --git a/scripts/release.ts b/scripts/release.ts index e58a74c..aac6d79 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -14,6 +14,7 @@ await release({ 'plugin-styled-jsx', 'plugin-transform-imports', 'oxc-unshadowed-visitor', + 'pluginutils', ], toTag: (pkg, version) => `${pkg}@${version}`, logChangelog: (pkg) => logRecentCommits(pkg, getPkgDir(pkg)), From 9db726d22fdfe43af16576e778320353422e875e Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Mon, 11 May 2026 13:43:46 +0900 Subject: [PATCH 2/8] chore: remove `rolldown-plugin` keyword --- packages/pluginutils/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/pluginutils/package.json b/packages/pluginutils/package.json index f1f7e01..c579441 100644 --- a/packages/pluginutils/package.json +++ b/packages/pluginutils/package.json @@ -5,8 +5,7 @@ "keywords": [ "filter", "plugin", - "rolldown", - "rolldown-plugin" + "rolldown" ], "homepage": "https://github.com/rolldown/plugins/tree/main/packages/pluginutils#readme", "bugs": { From e7213aec335696df910bf011a1007ea4563f3e72 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Mon, 11 May 2026 13:46:00 +0900 Subject: [PATCH 3/8] chore: pr-labeler --- .github/pr-labeler.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index f6ef885..506faaa 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -21,3 +21,7 @@ 'package: oxc-unshadowed-visitor': - changed-files: - any-glob-to-any-file: 'packages/oxc-unshadowed-visitor/**' + +'package: pluginutils': + - changed-files: + - any-glob-to-any-file: 'packages/pluginutils/**' From 070121e9e83b4611a0aa74ebba4ea3dc37c1b009 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Mon, 11 May 2026 13:48:23 +0900 Subject: [PATCH 4/8] chore: empty changelog --- packages/pluginutils/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluginutils/CHANGELOG.md b/packages/pluginutils/CHANGELOG.md index 8af44dc..8b13789 100644 --- a/packages/pluginutils/CHANGELOG.md +++ b/packages/pluginutils/CHANGELOG.md @@ -1 +1 @@ -# @rolldown/pluginutils Changelog + From 59197a4710b560cc8db4f87c191a0c712acc45b2 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Tue, 12 May 2026 20:58:38 +0900 Subject: [PATCH 5/8] test: separate type tests to workaround --- .github/workflows/ci.yml | 6 ++++++ package.json | 1 + packages/pluginutils/package.json | 1 + packages/pluginutils/vitest.config.ts | 1 - pnpm-lock.yaml | 13 +++++++++---- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57a6830..5cf431a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: - name: Test run: pnpm run test + - name: Type Tests + run: pnpm run test:types + test-babel7: timeout-minutes: 20 runs-on: ubuntu-latest @@ -92,6 +95,9 @@ jobs: - name: Test run: pnpm run test + - name: Type Tests + run: pnpm run test:types + - name: Lint (type check) run: pnpm run lint diff --git a/package.json b/package.json index 5e1c502..95930b2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "lint": "oxlint --type-aware --type-check .", "test": "vitest", "test:e2e": "pnpm run --filter=./examples test", + "test:types": "pnpm -r test:types", "dev": "pnpm -r --parallel --filter=\"./packages/*\" run dev", "build": "pnpm -r --filter=\"./packages/*\" run build", "release": "pnpm run --filter=./scripts release", diff --git a/packages/pluginutils/package.json b/packages/pluginutils/package.json index c579441..d42fc4b 100644 --- a/packages/pluginutils/package.json +++ b/packages/pluginutils/package.json @@ -30,6 +30,7 @@ "dev": "tsdown --watch", "build": "tsdown", "test": "vitest --project pluginutils", + "test:types": "vitest --project pluginutils --typecheck.only", "prepublishOnly": "pnpm run build" }, "devDependencies": { diff --git a/packages/pluginutils/vitest.config.ts b/packages/pluginutils/vitest.config.ts index a5cb0b4..51cbe1d 100644 --- a/packages/pluginutils/vitest.config.ts +++ b/packages/pluginutils/vitest.config.ts @@ -3,6 +3,5 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { name: 'pluginutils', - typecheck: { enabled: true }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 634ea0c..ba1f4f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,10 @@ importers: version: 0.22.1 tsdown: specifier: ^0.22.0 - version: 0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(unrun@0.2.36) + version: 0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(typescript@5.9.3)(unrun@0.2.36) + typescript: + specifier: ^5.9.3 + version: 5.9.3 vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@24.12.3)(vite@8.0.12(@types/node@24.12.3)(esbuild@0.27.3)) @@ -4642,7 +4645,7 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rolldown-plugin-dts@0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0): + rolldown-plugin-dts@0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.4 '@babel/helper-validator-identifier': 8.0.0-rc.4 @@ -4655,6 +4658,7 @@ snapshots: rolldown: 1.0.0 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260510.1 + typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -4790,7 +4794,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(unrun@0.2.36): + tsdown@0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(typescript@5.9.3)(unrun@0.2.36): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -4801,7 +4805,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0 - rolldown-plugin-dts: 0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0) + rolldown-plugin-dts: 0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -4809,6 +4813,7 @@ snapshots: unconfig-core: 7.5.0 optionalDependencies: publint: 0.3.20 + typescript: 5.9.3 unrun: 0.2.36 transitivePeerDependencies: - '@ts-macro/tsc' From 8537feb9523009181097c32d024b41d88c92da46 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Tue, 12 May 2026 21:00:33 +0900 Subject: [PATCH 6/8] chore: fix lockfile --- pnpm-lock.yaml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba1f4f8..634ea0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,10 +22,7 @@ importers: version: 0.22.1 tsdown: specifier: ^0.22.0 - version: 0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(typescript@5.9.3)(unrun@0.2.36) - typescript: - specifier: ^5.9.3 - version: 5.9.3 + version: 0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(unrun@0.2.36) vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@24.12.3)(vite@8.0.12(@types/node@24.12.3)(esbuild@0.27.3)) @@ -4645,7 +4642,7 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rolldown-plugin-dts@0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0)(typescript@5.9.3): + rolldown-plugin-dts@0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0): dependencies: '@babel/generator': 8.0.0-rc.4 '@babel/helper-validator-identifier': 8.0.0-rc.4 @@ -4658,7 +4655,6 @@ snapshots: rolldown: 1.0.0 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260510.1 - typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -4794,7 +4790,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(typescript@5.9.3)(unrun@0.2.36): + tsdown@0.22.0(@typescript/native-preview@7.0.0-dev.20260510.1)(publint@0.3.20)(unrun@0.2.36): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -4805,7 +4801,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0 - rolldown-plugin-dts: 0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0)(typescript@5.9.3) + rolldown-plugin-dts: 0.25.0(@typescript/native-preview@7.0.0-dev.20260510.1)(rolldown@1.0.0) semver: 7.7.4 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -4813,7 +4809,6 @@ snapshots: unconfig-core: 7.5.0 optionalDependencies: publint: 0.3.20 - typescript: 5.9.3 unrun: 0.2.36 transitivePeerDependencies: - '@ts-macro/tsc' From 1a053400ed122c0e0a5e9cd1c75b835b6024d521 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Tue, 12 May 2026 21:50:45 +0900 Subject: [PATCH 7/8] docs: add README --- README.md | 3 + packages/pluginutils/README.md | 146 ++++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3acb906..f0cf306 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Official Rolldown plugins ### Other Packages +- [`@rolldown/pluginutils`](https://github.com/rolldown/plugins/tree/main/packages/pluginutils) ([![NPM version][badge-npm-version-pluginutils]][url-npm-pluginutils]): plugin utilities (regex helpers, composable filters, Vite plugin filtering) - [`oxc-unshadowed-visitor`](https://github.com/rolldown/plugins/tree/main/packages/oxc-unshadowed-visitor) ([![NPM version][badge-npm-version-oxc-unshadowed-visitor]][url-npm-oxc-unshadowed-visitor]): scope-aware AST visitor that tracks references to specified names, filtering out those shadowed by local bindings ## License @@ -41,9 +42,11 @@ Official Rolldown plugins [badge-npm-version-styled-jsx]: https://img.shields.io/npm/v/@rolldown/plugin-styled-jsx?color=brightgreen [badge-npm-version-transform-imports]: https://img.shields.io/npm/v/@rolldown/plugin-transform-imports?color=brightgreen [badge-npm-version-oxc-unshadowed-visitor]: https://img.shields.io/npm/v/oxc-unshadowed-visitor?color=brightgreen +[badge-npm-version-pluginutils]: https://img.shields.io/npm/v/@rolldown/pluginutils?color=brightgreen [url-npm-babel]: https://npmx.dev/package/@rolldown/plugin-babel [url-npm-emotion]: https://npmx.dev/package/@rolldown/plugin-emotion [url-npm-jsx-remove-attributes]: https://npmx.dev/package/@rolldown/plugin-jsx-remove-attributes [url-npm-styled-jsx]: https://npmx.dev/package/@rolldown/plugin-styled-jsx [url-npm-transform-imports]: https://npmx.dev/package/@rolldown/plugin-transform-imports [url-npm-oxc-unshadowed-visitor]: https://npmx.dev/package/oxc-unshadowed-visitor +[url-npm-pluginutils]: https://npmx.dev/package/@rolldown/pluginutils diff --git a/packages/pluginutils/README.md b/packages/pluginutils/README.md index acaa0a6..3eebfea 100644 --- a/packages/pluginutils/README.md +++ b/packages/pluginutils/README.md @@ -1,13 +1,151 @@ -# @rolldown/pluginutils +# @rolldown/pluginutils [![npm](https://img.shields.io/npm/v/@rolldown/pluginutils.svg)](https://npmx.dev/package/@rolldown/pluginutils) Plugin utilities for [Rolldown](https://rolldown.rs). -## Installation +Includes regex helpers for plugin hook filters, composable filter expressions, and a helper for filtering out Vite-serve-only plugins. + +## Install ```bash -npm i -D @rolldown/pluginutils +pnpm add -D @rolldown/pluginutils ``` ## Usage -See [the source](./src) for the available APIs (filter helpers, regex builders, etc.). +```ts +import { exactRegex, prefixRegex, makeIdFiltersToMatchWithQuery } from '@rolldown/pluginutils' +``` + +All filter helpers are also exposed via the `/filter` subpath: + +```ts +import { and, or, id, include } from '@rolldown/pluginutils/filter' +``` + +## Regex helpers + +### `exactRegex` + +- **Type:** `(str: string, flags?: string) => RegExp` + +Constructs a `RegExp` that matches the exact string specified. Useful as a plugin hook filter. + +```ts +import { exactRegex } from '@rolldown/pluginutils' + +const plugin = { + name: 'plugin', + resolveId: { + filter: { id: exactRegex('foo') }, + handler(id) {}, // only called for `foo` + }, +} +``` + +### `prefixRegex` + +- **Type:** `(str: string, flags?: string) => RegExp` + +Constructs a `RegExp` that matches values starting with the specified prefix. + +```ts +import { prefixRegex } from '@rolldown/pluginutils' + +const plugin = { + name: 'plugin', + resolveId: { + filter: { id: prefixRegex('foo') }, + handler(id) {}, // called for IDs starting with `foo` + }, +} +``` + +### `makeIdFiltersToMatchWithQuery` + +- **Type:** `(input: string | RegExp | (string | RegExp)[]) => string | RegExp | (string | RegExp)[]` + +Converts an id filter so that it also matches ids that include a query string. + +```ts +import { makeIdFiltersToMatchWithQuery } from '@rolldown/pluginutils' + +const plugin = { + name: 'plugin', + transform: { + filter: { id: makeIdFiltersToMatchWithQuery(['**/*.js', /\.ts$/]) }, + // Matches: + // foo.js, foo.js?foo, foo.txt?foo.js, + // foo.ts, foo.ts?foo, foo.txt?foo.ts + handler(code, id) {}, + }, +} +``` + +## Composable filters + +[Composable filter expressions](https://rolldown.rs/apis/plugin-api/hook-filters#composable-filters) for use cases where a simple `id`/`include`/`exclude` is not enough. For example, when a plugin needs to combine `id`, `moduleType`, `code`, and `query` conditions. + +```ts +import { and, code, id, include, interpreter, moduleType, or } from '@rolldown/pluginutils' + +const expr = include( + and( + or(id(/\.tsx?$/), id(/\.jsx?$/)), + moduleType('tsx'), + code(/import React/), + ), +) + +interpreter(expr, sourceCode, sourceId, 'tsx') // boolean +``` + +### Builders + +| Builder | Description | +| --- | --- | +| `and(...exprs)` | All operands must match. | +| `or(...exprs)` | At least one operand must match. | +| `not(expr)` | Negates the operand. | +| `id(pattern, params?)` | Match the module id. `pattern` is `string` or `RegExp`. `params.cleanUrl` strips the query/hash before matching. | +| `importerId(pattern, params?)` | Match the importer's id. Same shape as `id`. | +| `moduleType(type)` | Match Rolldown's module type (`'js'`, `'jsx'`, `'ts'`, `'tsx'`, `'json'`, `'text'`, `'base64'`, `'dataurl'`, `'binary'`, `'empty'`, or a custom string). | +| `code(pattern)` | Match the module source. `string` matches with `includes`; `RegExp` with `test`. | +| `query(key, pattern)` | Match a single query parameter. `pattern` is `boolean` (key presence/truthiness), `string` (exact value), or `RegExp` (value pattern). | +| `queries(obj)` | Shorthand for `and(...)` over multiple `query` entries. | +| `include(expr)` | Top-level wrapper marking `expr` as an inclusion rule. | +| `exclude(expr)` | Top-level wrapper marking `expr` as an exclusion rule. | + +### `interpreter` + +- **Type:** `(exprs, code?, id?, moduleType?, importerId?) => boolean` + +Evaluates one or more top-level expressions against the given inputs. Returns `true` when at least one `include` matches and no `exclude` matches; when no `include` is present, defaults to `true` unless an `exclude` matches. + +The argument required by each expression must be provided. For example, evaluating an `id(...)` expression without passing `id` will throw. + +## `filterVitePlugins` + +- **Type:** `(plugins: T | T[] | null | undefined | false) => T[]` + +Removes Vite plugins that target the dev server (`apply: 'serve'`) from a (possibly nested) plugin array. Plugins whose `apply` is a function are invoked with a `command: 'build'` context to decide. Useful when reusing a Vite plugin array inside a Rolldown config. + +```ts +import { defineConfig } from 'rolldown' +import { filterVitePlugins } from '@rolldown/pluginutils' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: filterVitePlugins([ + viteReact(), + { + name: 'dev-only', + apply: 'serve', // filtered out + // ... + }, + ]), +}) +``` + +## License + +MIT From 3724be037e335fac51dcf03e11bf16f37cbc7e71 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Tue, 12 May 2026 22:00:06 +0900 Subject: [PATCH 8/8] chore: format --- packages/pluginutils/README.md | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/pluginutils/README.md b/packages/pluginutils/README.md index 3eebfea..7d4d5a5 100644 --- a/packages/pluginutils/README.md +++ b/packages/pluginutils/README.md @@ -88,32 +88,26 @@ const plugin = { ```ts import { and, code, id, include, interpreter, moduleType, or } from '@rolldown/pluginutils' -const expr = include( - and( - or(id(/\.tsx?$/), id(/\.jsx?$/)), - moduleType('tsx'), - code(/import React/), - ), -) +const expr = include(and(or(id(/\.tsx?$/), id(/\.jsx?$/)), moduleType('tsx'), code(/import React/))) interpreter(expr, sourceCode, sourceId, 'tsx') // boolean ``` ### Builders -| Builder | Description | -| --- | --- | -| `and(...exprs)` | All operands must match. | -| `or(...exprs)` | At least one operand must match. | -| `not(expr)` | Negates the operand. | -| `id(pattern, params?)` | Match the module id. `pattern` is `string` or `RegExp`. `params.cleanUrl` strips the query/hash before matching. | -| `importerId(pattern, params?)` | Match the importer's id. Same shape as `id`. | -| `moduleType(type)` | Match Rolldown's module type (`'js'`, `'jsx'`, `'ts'`, `'tsx'`, `'json'`, `'text'`, `'base64'`, `'dataurl'`, `'binary'`, `'empty'`, or a custom string). | -| `code(pattern)` | Match the module source. `string` matches with `includes`; `RegExp` with `test`. | -| `query(key, pattern)` | Match a single query parameter. `pattern` is `boolean` (key presence/truthiness), `string` (exact value), or `RegExp` (value pattern). | -| `queries(obj)` | Shorthand for `and(...)` over multiple `query` entries. | -| `include(expr)` | Top-level wrapper marking `expr` as an inclusion rule. | -| `exclude(expr)` | Top-level wrapper marking `expr` as an exclusion rule. | +| Builder | Description | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `and(...exprs)` | All operands must match. | +| `or(...exprs)` | At least one operand must match. | +| `not(expr)` | Negates the operand. | +| `id(pattern, params?)` | Match the module id. `pattern` is `string` or `RegExp`. `params.cleanUrl` strips the query/hash before matching. | +| `importerId(pattern, params?)` | Match the importer's id. Same shape as `id`. | +| `moduleType(type)` | Match Rolldown's module type (`'js'`, `'jsx'`, `'ts'`, `'tsx'`, `'json'`, `'text'`, `'base64'`, `'dataurl'`, `'binary'`, `'empty'`, or a custom string). | +| `code(pattern)` | Match the module source. `string` matches with `includes`; `RegExp` with `test`. | +| `query(key, pattern)` | Match a single query parameter. `pattern` is `boolean` (key presence/truthiness), `string` (exact value), or `RegExp` (value pattern). | +| `queries(obj)` | Shorthand for `and(...)` over multiple `query` entries. | +| `include(expr)` | Top-level wrapper marking `expr` as an inclusion rule. | +| `exclude(expr)` | Top-level wrapper marking `expr` as an exclusion rule. | ### `interpreter`