From b8287bea17bb4c406217fe265ee94796fd5169bf Mon Sep 17 00:00:00 2001 From: Qingyu Wang Date: Fri, 27 Feb 2026 11:12:31 +0800 Subject: [PATCH 1/2] feat(v4): support CSS directives --- README.md | 16 +++++++ package.json | 5 +- pnpm-lock.yaml | 9 ++-- src/index.ts | 11 ++++- src/postcss.ts | 36 +++++++++++++++ test/css-directives/index.test.ts | 56 +++++++++++++++++++++++ test/css-directives/src/app.css | 34 ++++++++++++++ test/css-directives/src/index.js | 31 +++++++++++++ test/functions/config/functions-theme.css | 12 +---- test/functions/src/components.css | 9 ++++ test/functions/src/index.js | 2 + 11 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 src/postcss.ts create mode 100644 test/css-directives/index.test.ts create mode 100644 test/css-directives/src/app.css create mode 100644 test/css-directives/src/index.js create mode 100644 test/functions/src/components.css diff --git a/README.md b/README.md index d2a297a..d1ab2a7 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,22 @@ export default { This will be auto-loaded by Rsbuild and applied by `rsbuild-plugin-tailwindcss`. +### CSS directives (`@apply`, `@plugin`) + +This plugin automatically wires the official `@tailwindcss/postcss` +plugin into Rsbuild's PostCSS pipeline, so Tailwind CSS +directives like `@apply` and `@plugin` work out of the box without extra +configuration. + +```css +/* src/styles.css */ +@import "tailwindcss/utilities" layer(utilities); + +.btn { + @apply px-4 py-2 rounded bg-blue text-white; +} +``` + ### Custom theme with `@theme` Tailwind CSS v4 also lets you define design tokens using the `@theme` directive in a CSS file. diff --git a/package.json b/package.json index cc49412..102d995 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "^4.2.1", - "@tailwindcss/oxide": "^4.2.1" + "@tailwindcss/oxide": "^4.2.1", + "@tailwindcss/postcss": "^4.2.1" }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -41,8 +42,8 @@ "@rsbuild/core": "2.0.0-beta.4", "@rsdoctor/rspack-plugin": "^1.5.2", "@rslib/core": "^0.19.5", - "@tailwindcss/postcss": "^4.2.1", "@types/node": "^22.19.11", + "postcss": "^8.5.6", "simple-git-hooks": "^2.13.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fc987e..583f4ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@tailwindcss/oxide': specifier: ^4.2.1 version: 4.2.1 + '@tailwindcss/postcss': + specifier: ^4.2.1 + version: 4.2.1 devDependencies: '@biomejs/biome': specifier: ^1.9.4 @@ -40,12 +43,12 @@ importers: '@rslib/core': specifier: ^0.19.5 version: 0.19.5(typescript@5.9.3) - '@tailwindcss/postcss': - specifier: ^4.2.1 - version: 4.2.1 '@types/node': specifier: ^22.19.11 version: 22.19.11 + postcss: + specifier: ^8.5.6 + version: 8.5.6 simple-git-hooks: specifier: ^2.13.1 version: 2.13.1 diff --git a/src/index.ts b/src/index.ts index 4d03193..7bbba99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'; import type { RsbuildPlugin } from '@rsbuild/core'; import { cleanupCache } from './compiler.js'; import type { TailwindCSSLoaderOptions } from './loader.js'; +import { tailwindPostCSSPlugins } from './postcss.js'; const VIRTUAL_UTILITIES_ID = '/virtual-tailwindcss/utilities.css'; const VIRTUAL_GLOBAL_ID = '/virtual-tailwindcss/global.css'; @@ -123,7 +124,10 @@ export const pluginTailwindCSS = ( setup(api) { const require = createRequire(import.meta.url); const config = options?.config ?? 'tailwind.config.js'; - const theme = options?.theme ?? require.resolve('tailwindcss/theme'); + let theme = options?.theme ?? require.resolve('tailwindcss/theme'); + if (!path.isAbsolute(theme)) { + theme = path.resolve(api.context.rootPath, theme); + } const preflight = require.resolve('tailwindcss/preflight'); const utilities = require.resolve('tailwindcss/utilities'); @@ -142,6 +146,11 @@ export const pluginTailwindCSS = ( source: { preEntry: [pathToFileURL(VIRTUAL_GLOBAL_ID).toString()], }, + tools: { + postcss(_, { addPlugins }) { + addPlugins(tailwindPostCSSPlugins({ theme }), { order: 'pre' }); + }, + }, }, config, ); diff --git a/src/postcss.ts b/src/postcss.ts new file mode 100644 index 0000000..3abfa79 --- /dev/null +++ b/src/postcss.ts @@ -0,0 +1,36 @@ +import tailwindcss from '@tailwindcss/postcss'; +import type { AcceptedPlugin, Plugin, PluginCreator } from 'postcss'; + +export interface TailwindCSSPostCSSOptions { + theme: string; +} + +const injectTailwindThemePlugin: PluginCreator = ( + options?: TailwindCSSPostCSSOptions, +): Plugin => { + const themePath = options?.theme.replace(/\\/g, '/'); + + return { + postcssPlugin: 'rsbuild-inject-tailwind-theme', + + Once(root, { AtRule }) { + if (!themePath) { + return; + } + root.prepend( + new AtRule({ + name: 'import', + params: `"${themePath}"`, + }), + ); + }, + }; +}; + +injectTailwindThemePlugin.postcss = true; + +export function tailwindPostCSSPlugins( + options: TailwindCSSPostCSSOptions, +): AcceptedPlugin[] { + return [injectTailwindThemePlugin(options), tailwindcss()]; +} diff --git a/test/css-directives/index.test.ts b/test/css-directives/index.test.ts new file mode 100644 index 0000000..9e8065e --- /dev/null +++ b/test/css-directives/index.test.ts @@ -0,0 +1,56 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test } from '@playwright/test'; +import { createRsbuild } from '@rsbuild/core'; + +import { pluginTailwindCSS } from '../../src'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +test('supports CSS directives (@apply, @utility, @variant, @custom-variant)', async ({ + page, +}) => { + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + plugins: [pluginTailwindCSS()], + }, + }); + + const { close } = await rsbuild.build(); + const { server, urls } = await rsbuild.preview(); + + try { + await page.goto(urls[0]); + + // @apply turns the `.btn` utility into real CSS. + await expect(page.locator('#apply-test')).toHaveCSS('display', 'flex'); + + // @utility registers custom utilities that can be used from HTML. + await expect(page.locator('#utility-test')).toHaveCSS( + 'outline-style', + 'solid', + ); + + // @custom-variant + Tailwind utilities (theme-midnight:bg-black). + await expect(page.locator('#custom-variant-utility')).toHaveCSS( + 'background-color', + 'rgb(0, 0, 0)', + ); + + // @variant used with a custom variant defined via @custom-variant. + await expect(page.locator('#custom-variant-css')).toHaveCSS( + 'color', + 'rgb(0, 0, 255)', + ); + + // @variant hover used inside custom CSS. + const card = page.locator('#variant-test'); + await card.hover(); + await expect(card).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + } finally { + await server.close(); + await close(); + } +}); diff --git a/test/css-directives/src/app.css b/test/css-directives/src/app.css new file mode 100644 index 0000000..8873ef1 --- /dev/null +++ b/test/css-directives/src/app.css @@ -0,0 +1,34 @@ +@import "tailwindcss/utilities.css" layer(utilities); + +@custom-variant theme-midnight { + &:where([data-theme="midnight"] *) { + @slot; + } +} + +@utility debug-border { + outline-width: 3px; + outline-style: solid; +} + +.btn { + @apply flex; +} + +.card { + background-color: white; + width: 120px; + height: 60px; + + @variant hover { + background-color: black; + } +} + +.link { + color: red; + + @variant theme-midnight { + color: blue; + } +} diff --git a/test/css-directives/src/index.js b/test/css-directives/src/index.js new file mode 100644 index 0000000..e982071 --- /dev/null +++ b/test/css-directives/src/index.js @@ -0,0 +1,31 @@ +import './app.css'; + +const root = document.getElementById('root'); + +// Enable our custom `theme-midnight` variant. +document.documentElement.setAttribute('data-theme', 'midnight'); + +const applyElement = document.createElement('div'); +applyElement.id = 'apply-test'; +applyElement.className = 'btn'; +root.appendChild(applyElement); + +const utilityElement = document.createElement('div'); +utilityElement.id = 'utility-test'; +utilityElement.className = 'debug-border'; +root.appendChild(utilityElement); + +const customVariantUtilityElement = document.createElement('div'); +customVariantUtilityElement.id = 'custom-variant-utility'; +customVariantUtilityElement.className = 'theme-midnight:bg-black'; +root.appendChild(customVariantUtilityElement); + +const customVariantCssElement = document.createElement('div'); +customVariantCssElement.id = 'custom-variant-css'; +customVariantCssElement.className = 'link'; +root.appendChild(customVariantCssElement); + +const variantElement = document.createElement('div'); +variantElement.id = 'variant-test'; +variantElement.className = 'card'; +root.appendChild(variantElement); diff --git a/test/functions/config/functions-theme.css b/test/functions/config/functions-theme.css index 7a34edb..8e16de8 100644 --- a/test/functions/config/functions-theme.css +++ b/test/functions/config/functions-theme.css @@ -1,15 +1,5 @@ -@import "tailwindcss/theme" layer(theme); +@import "tailwindcss/theme"; @theme { --color-functions-alpha: #ff0000; } - -@layer components { - .alpha-text { - color: --alpha(var(--color-functions-alpha) / 50%); - } - - .spacing-box { - margin-top: --spacing(4); - } -} diff --git a/test/functions/src/components.css b/test/functions/src/components.css new file mode 100644 index 0000000..76bee3a --- /dev/null +++ b/test/functions/src/components.css @@ -0,0 +1,9 @@ +@layer components { + .alpha-text { + color: --alpha(var(--color-functions-alpha) / 50%); + } + + .spacing-box { + margin-top: --spacing(4); + } +} diff --git a/test/functions/src/index.js b/test/functions/src/index.js index f02dd80..2fbde12 100644 --- a/test/functions/src/index.js +++ b/test/functions/src/index.js @@ -1,3 +1,5 @@ +import './components.css'; + const root = document.getElementById('root'); const alphaElement = document.createElement('div'); From 5cdf3e675aef14edca54f71c1b49e0e41634dc4d Mon Sep 17 00:00:00 2001 From: Qingyu Wang <40660121+colinaaa@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:54:28 +0800 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 4 ++-- src/postcss.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d1ab2a7..954685c 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ export default { This will be auto-loaded by Rsbuild and applied by `rsbuild-plugin-tailwindcss`. -### CSS directives (`@apply`, `@plugin`) +### CSS directives (`@apply`, `@variant`, etc.) This plugin automatically wires the official `@tailwindcss/postcss` plugin into Rsbuild's PostCSS pipeline, so Tailwind CSS -directives like `@apply` and `@plugin` work out of the box without extra +directives like `@apply`, `@variant`, `@utility`, and `@custom-variant` work out of the box without extra configuration. ```css diff --git a/src/postcss.ts b/src/postcss.ts index 3abfa79..1af6ca9 100644 --- a/src/postcss.ts +++ b/src/postcss.ts @@ -20,7 +20,7 @@ const injectTailwindThemePlugin: PluginCreator = ( root.prepend( new AtRule({ name: 'import', - params: `"${themePath}"`, + params: JSON.stringify(themePath), }), ); },