diff --git a/internal/tailwind-config/src/theme.css b/internal/tailwind-config/src/theme.css index 2bf31bbe6..091a3efe1 100644 --- a/internal/tailwind-config/src/theme.css +++ b/internal/tailwind-config/src/theme.css @@ -1,11 +1,14 @@ /* * 级联层顺序声明(必须先于 @import 'tailwindcss' 首次出现): - * preflight(base) < UI 库组件样式(antd / el / td) < Tailwind 工具类, + * properties(Tailwind 的 --tw-* 回退变量层) < preflight(base) < + * UI 库组件样式(antd / el / td) < Tailwind 工具类, * 使组件库样式(antdv-next 经 StyleProvider layer 注入 @layer antd; * element-plus / tdesign 经各自应用的 vite css-layer 插件包入对应层) - * 能被工具类覆盖 + * 能被工具类覆盖。 + * 与 internal/vite-config/src/plugins/css-layer.ts 中的 LAYER_ORDER_STATEMENT + * 保持一致。 */ -@layer theme, base, ant, antd, el, td, components, utilities; +@layer properties, theme, base, ant, antd, el, td, components, utilities; @import 'tailwindcss'; @import 'tw-animate-css'; diff --git a/internal/vite-config/src/plugins/css-layer.test.ts b/internal/vite-config/src/plugins/css-layer.test.ts new file mode 100644 index 000000000..c2399a270 --- /dev/null +++ b/internal/vite-config/src/plugins/css-layer.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { viteCssLayerPlugin } from './css-layer'; + +interface FakeAsset { + fileName: string; + source: string; + type: string; +} + +function createBundle(assets: FakeAsset[]) { + return Object.fromEntries(assets.map((asset) => [asset.fileName, asset])); +} + +const LAYER_ORDER_STATEMENT = + '@layer properties, theme, base, ant, antd, el, td, components, utilities;'; + +describe('viteCssLayerPlugin', () => { + it('wraps matching css modules in the target layer', () => { + const plugin = viteCssLayerPlugin({ + layerName: 'el', + packageName: 'element-plus', + }); + const transform = plugin.transform; + + if (typeof transform !== 'function') return; + + const result = transform.call( + undefined as never, + '.el-button { color: red; }', + '/node_modules/element-plus/es/components/button/style/css/index.css?used', + ); + + expect(result).toEqual({ + code: '@layer el {\n.el-button { color: red; }\n}', + map: null, + }); + }); + + it('does not wrap css modules outside the target package', () => { + const plugin = viteCssLayerPlugin({ + layerName: 'el', + packageName: 'element-plus', + }); + const transform = plugin.transform; + + if (typeof transform !== 'function') return; + + const result = transform.call( + undefined as never, + '.local { color: red; }', + '/src/views/home/index.css', + ); + + expect(result).toBeUndefined(); + }); + + it('injects the layer order statement into matching css assets', () => { + const plugin = viteCssLayerPlugin({ + layerName: 'el', + packageName: 'element-plus', + }); + const generateBundle = plugin.generateBundle; + + if (typeof generateBundle !== 'function') return; + + const bundle = createBundle([ + { + fileName: 'element-x.css', + source: '@layer el{.el-button{color:red}}', + type: 'asset', + }, + { + fileName: 'theme-x.css', + source: '.local { color: blue; }', + type: 'asset', + }, + ]); + + generateBundle.call(undefined as never, {} as never, bundle); + + expect(bundle['element-x.css'].source).toBe( + `${LAYER_ORDER_STATEMENT}\n@layer el{.el-button{color:red}}`, + ); + expect(bundle['theme-x.css'].source).toBe('.local { color: blue; }'); + }); + + it('does not inject the statement twice when already present', () => { + const plugin = viteCssLayerPlugin({ + layerName: 'el', + packageName: 'element-plus', + }); + const generateBundle = plugin.generateBundle; + + if (typeof generateBundle !== 'function') return; + + const bundle = createBundle([ + { + fileName: 'element-x.css', + source: `${LAYER_ORDER_STATEMENT}\n@layer el{.el-button{color:red}}`, + type: 'asset', + }, + ]); + + generateBundle.call(undefined as never, {} as never, bundle); + + expect(bundle['element-x.css'].source).toBe( + `${LAYER_ORDER_STATEMENT}\n@layer el{.el-button{color:red}}`, + ); + }); + + it('injects the statement even when the asset starts with a BOM', () => { + const plugin = viteCssLayerPlugin({ + layerName: 'el', + packageName: 'element-plus', + }); + const generateBundle = plugin.generateBundle; + + if (typeof generateBundle !== 'function') return; + + const bundle = createBundle([ + { + fileName: 'element-x.css', + source: `\uFEFF@layer el{.el-button{color:red}}`, + type: 'asset', + }, + ]); + + generateBundle.call(undefined as never, {} as never, bundle); + + expect(bundle['element-x.css'].source).toBe( + `${LAYER_ORDER_STATEMENT}\n\uFEFF@layer el{.el-button{color:red}}`, + ); + }); + + it('ignores non-css assets', () => { + const plugin = viteCssLayerPlugin({ + layerName: 'el', + packageName: 'element-plus', + }); + const generateBundle = plugin.generateBundle; + + if (typeof generateBundle !== 'function') return; + + const bundle = createBundle([ + { + fileName: 'element-x.js', + source: '@layer el{.el-button{color:red}}', + type: 'chunk', + }, + ]); + + generateBundle.call(undefined as never, {} as never, bundle); + + expect(bundle['element-x.js'].source).toBe( + '@layer el{.el-button{color:red}}', + ); + }); +}); diff --git a/internal/vite-config/src/plugins/css-layer.ts b/internal/vite-config/src/plugins/css-layer.ts index d12e60cb8..9542bf2b3 100644 --- a/internal/vite-config/src/plugins/css-layer.ts +++ b/internal/vite-config/src/plugins/css-layer.ts @@ -11,12 +11,33 @@ function escapeRegExp(value: string): string { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } +/** + * 与 internal/tailwind-config/src/theme.css 中的层顺序保持一致: + * properties(Tailwind 的 --tw-* 回退变量层) < preflight(base) < + * UI 库组件样式(antd / el / td) < Tailwind 工具类。 + * 该声明必须在打包后注入:生产构建下 css 按 chunk 注入,若组件库的 css + * chunk 先于含层顺序声明的 theme.css 加载,层名会先被注册为最低优先级, + * 导致 Tailwind base/utilities 覆盖组件样式。打包后在每个含组件库层的 + * css 产物顶部补上该声明,无论 chunk 加载顺序如何,层优先级都正确 + * (theme.css 先加载时该声明是幂等的 no-op)。 + * 注意 properties 必须排在首位:它是 Tailwind 为不支持 @property 的旧 + * 浏览器准备的 --tw-* 回退变量层,必须始终是最低优先级,否则其回退声明 + * 会覆盖工具类写入的变量。 + */ +const LAYER_ORDER_STATEMENT = + '@layer properties, theme, base, ant, antd, el, td, components, utilities;'; + /** * 把指定包内的 css 包进 @layer: * 组件库的 css 是无层样式,无层样式在级联中永远压过 @layer 内的 Tailwind 工具类; * 包层后由 theme.css 的层声明决定顺序(utilities 排在组件库层之后), * 使 Tailwind 工具类可以覆盖组件库样式。 * + * 注意:层顺序声明不能在 transform 阶段随包层 css 一起输出——rolldown-vite + * 的 css 合并会将「层顺序声明 + @layer 规则」形式的模块拆层(规则被脱层), + * 且产物压缩阶段也会丢弃该声明。因此改在 generateBundle 阶段对最终 css + * 产物统一注入(见 LAYER_ORDER_STATEMENT)。 + * * @example * ```ts * plugins: [viteCssLayerPlugin({ packageName: 'element-plus', layerName: 'el' })] @@ -41,5 +62,28 @@ export function viteCssLayerPlugin( return { code: `@layer ${matched.layerName} {\n${code}\n}`, map: null }; } }, + generateBundle(_options, bundle) { + for (const file of Object.values(bundle)) { + if (file.type !== 'asset' || !file.fileName.endsWith('.css')) { + continue; + } + const css = file.source.toString(); + const matched = matchers.some((m) => + css.includes(`@layer ${m.layerName}`), + ); + if (!matched) { + continue; + } + // 先去除可能的 BOM 再判断,避免重复注入层顺序声明 + const hasStatement = css + .replace(/^\uFEFF/, '') + .trimStart() + .startsWith(LAYER_ORDER_STATEMENT); + if (hasStatement) { + continue; + } + file.source = `${LAYER_ORDER_STATEMENT}\n${css}`; + } + }, }; }