From 62d399500fba301e96a369f89dc466cae014249a Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 15 Sep 2026 17:25:09 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(postcss):=20=E5=B0=86=20CSS=20?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=E8=A7=A3=E6=9E=90=E6=94=B6=E5=8F=A3=E5=88=B0?= =?UTF-8?q?=20@weapp-tailwindcss/postcss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主包不再直接依赖 postcss-scss、@csstools/css-tokenizer 和 lightningcss, 相关 API 与仓库约束一并落到 postcss 包。 --- .changeset/angry-wolves-look.md | 6 ++ AGENTS.md | 1 + packages/AGENTS.md | 1 + packages/postcss/AGENTS.md | 11 +- packages/postcss/src/compat/uni-app-x.ts | 6 +- .../src/generator-plugin/directives.ts | 4 +- packages/postcss/src/index.ts | 16 +++ packages/postcss/src/postcss-runtime.ts | 1 + .../src/syntax}/css-import.ts | 42 ++++++-- packages/postcss/src/syntax/index.ts | 16 +++ packages/postcss/src/syntax/parse.ts | 36 +++++++ .../postcss/test/syntax-css-import.test.ts | 101 ++++++++++++++++++ packages/weapp-tailwindcss/AGENTS.md | 9 ++ packages/weapp-tailwindcss/package.json | 3 - .../src/bundlers/shared/css-imports.ts | 3 +- .../generator-css/output-import-shell.ts | 5 +- .../vite/generate-bundle/entry-style-graph.ts | 3 +- .../scoped-tailwind-noise.ts | 2 +- .../src/compiler/semantic-css.ts | 2 +- .../weapp-tailwindcss/src/compiler/types.ts | 2 +- .../v4-engine/generator/css-compat.ts | 3 +- .../v4-engine/generator/scan-sources.ts | 3 +- .../src/tailwindcss/v4-engine/source.ts | 3 +- .../src/tailwindcss/v4/preflight.ts | 14 +-- .../src/uni-app-x/style-asset.ts | 2 +- .../uni-app-x/style-asset/harmony-apply.ts | 3 +- .../uni-app-x/style-asset/source-parser.ts | 15 --- .../src/uni-app-x/style-asset/style-value.ts | 3 +- .../src/uni-app-x/transform.ts | 3 +- .../test/ci/architecture-contract.test.ts | 65 +++++++++++ packages/weapp-tailwindcss/test/core.test.ts | 3 +- .../test/tailwindcss/v4-import-paths.test.ts | 2 +- pnpm-lock.yaml | 9 -- 33 files changed, 316 insertions(+), 82 deletions(-) create mode 100644 .changeset/angry-wolves-look.md rename packages/{weapp-tailwindcss/src/tailwindcss/v4-engine => postcss/src/syntax}/css-import.ts (60%) create mode 100644 packages/postcss/src/syntax/index.ts create mode 100644 packages/postcss/src/syntax/parse.ts create mode 100644 packages/postcss/test/syntax-css-import.test.ts delete mode 100644 packages/weapp-tailwindcss/src/uni-app-x/style-asset/source-parser.ts diff --git a/.changeset/angry-wolves-look.md b/.changeset/angry-wolves-look.md new file mode 100644 index 0000000000..b7970c4cfa --- /dev/null +++ b/.changeset/angry-wolves-look.md @@ -0,0 +1,6 @@ +--- +"@weapp-tailwindcss/postcss": patch +"weapp-tailwindcss": patch +--- + +将 CSS 语法解析与 tokenize 收口到 @weapp-tailwindcss/postcss,主包不再直接依赖样式处理库 diff --git a/AGENTS.md b/AGENTS.md index 5f2a807884..57b7a511e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,7 @@ - `packages/weapp-tailwindcss` 的 JS 转译必须遵循 `classNameSet` 精确命中原则,禁止启发式兜底转译。 - `packages/weapp-tailwindcss` 的 bundler 适配不得依赖硬编码目录或后置 `fs.readFile` 兜底来还原源码关系;源码关系必须来自构建图、插件生命周期缓存或明确的扫描层。 - `packages/weapp-tailwindcss` 的样式注入、preflight、Tailwind 入口选择和分包样式隔离不得依赖硬编码文件名或输出路径片段;必须来自 CSS 内容、用户显式配置、构建图、loader/transform 阶段缓存或 source-candidates 元数据。 +- CSS 语法解析、tokenize、selector/value parser、AST 变换和 PostCSS 管线只允许实现在 `packages/postcss`;`packages/weapp-tailwindcss` 禁止直接依赖 `postcss-scss`、`@csstools/*`、`postcss-selector-parser`、`postcss-value-parser`、`lightningcss` 或 `postcss`,必须通过 `@weapp-tailwindcss/postcss` 消费。 - demo、Web/H5、watch 与 e2e 场景都必须遵守 Tailwind CSS 由 `weapp-tailwindcss` 生成的约束,不能为修复样式或 HMR 问题注册官方 Tailwind 生成插件。 - 运行时封装(`packages-runtime/*`)改动需重点关注 escape/unescape、merge 兼容和缓存边界。 - Release 工作流发布 npm 必须使用 trusted publishing/OIDC:发布 job 使用 Node 24 以满足 npm CLI 的 OIDC 支持要求,保留 `permissions.id-token: write` 与 provenance,禁止在发布步骤注入 `NPM_TOKEN` 或 `NODE_AUTH_TOKEN`。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index ab43f0e776..abead0b80e 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -13,6 +13,7 @@ - 构建产物类型声明; - README 中的公开用法(如有)。 - 默认保持“单一职责”包边界,不把上游业务逻辑混入基础工具包。 +- CSS 语法解析、tokenize 与 AST 变换属于 `@weapp-tailwindcss/postcss`;其他包不得再引入 `postcss-scss`、`@csstools/css-tokenizer` 等样式处理依赖,应通过该包的导出消费。 ## 工程与测试 diff --git a/packages/postcss/AGENTS.md b/packages/postcss/AGENTS.md index 7e3bb08ae9..884502c230 100644 --- a/packages/postcss/AGENTS.md +++ b/packages/postcss/AGENTS.md @@ -3,15 +3,23 @@ ## 适用范围 - 本文件适用于 `packages/postcss`。 -- 本包聚焦 CSS AST 级处理,不承担 JS/模板层的启发式纠错职责。 +- 本包是仓库内 CSS 语法解析、tokenize、AST 变换和 PostCSS 管线的唯一实现位置。 +- 本包聚焦 CSS AST 级处理,不承担 JS/模板层的启发式纠错职责,也不承担 bundler 生命周期或 Tailwind class 生成。 ## 包内结构约定 +- `src/syntax/`:CSS/SCSS 解析、`@import` specifier tokenize/quote,以及对外暴露的 syntax API。 - `src/plugins/`:插件实现与 pipeline 组装。 - `src/compat/`:版本兼容与降级逻辑。 - `src/selectorParser/`:选择器解析相关能力。 - `src/utils/`:纯工具函数,保持无副作用、可单测。 +## 所有权 + +- 新增 CSS parser/tokenizer/selector/value/compat 变换时写在本包并导出;禁止在 `packages/weapp-tailwindcss` 再实现一份。 +- `postcss-scss`、`@csstools/*`、`postcss-selector-parser`、`postcss-value-parser` 只作为本包依赖。 +- 主包可以通过本包 re-export 的 `postcss` 做编排级 parse/walk,但不能拥有这些 CSS 工具依赖。 + ## 变更原则 - 优先基于 PostCSS AST 做变换,避免对原始字符串做全局替换。 @@ -24,6 +32,7 @@ - `pnpm --filter @weapp-tailwindcss/postcss test` - `pnpm --filter @weapp-tailwindcss/postcss exec vitest run -u`(仅在确认预期变更时) - 针对单模块:`pnpm --filter @weapp-tailwindcss/postcss exec vitest run test/.test.ts` +- 语法层回归:`pnpm --filter @weapp-tailwindcss/postcss exec vitest run test/syntax-css-import.test.ts` ## 测试补充要求 diff --git a/packages/postcss/src/compat/uni-app-x.ts b/packages/postcss/src/compat/uni-app-x.ts index d8360438ab..44af35eae2 100644 --- a/packages/postcss/src/compat/uni-app-x.ts +++ b/packages/postcss/src/compat/uni-app-x.ts @@ -4,7 +4,7 @@ import type { Node, Pseudo } from 'postcss-selector-parser' import type { IStyleHandlerOptions } from '../types' import { splitCandidateTokens } from '@tailwindcss-mangle/engine' import postcss from 'postcss' -import scssSyntax from 'postcss-scss' +import { parseScssSource, stringifyScssSource } from '../syntax/parse' /** native Sass 可解析、PostCSS 阶段再还原的 important utility 标记。 */ export const UNI_APP_X_IMPORTANT_APPLY_MARKER = '__weapp_tw_important__' @@ -52,7 +52,7 @@ function rewriteApplyParams(params: string, marker: string) { export function normalizeUniAppXImportantApplyForSass(css: string) { try { // postcss.parse 不读取 syntax 选项;必须直接使用 SCSS parser 保留行内注释和插值。 - const root = scssSyntax.parse(css, { from: undefined }) + const root = parseScssSource(css) let changed = false root.walkAtRules('apply', (rule) => { const params = rewriteApplyParams(rule.params, UNI_APP_X_IMPORTANT_APPLY_MARKER) @@ -61,7 +61,7 @@ export function normalizeUniAppXImportantApplyForSass(css: string) { changed = true } }) - return changed ? root.toString(scssSyntax.stringify) : css + return changed ? stringifyScssSource(root) : css } catch { return css diff --git a/packages/postcss/src/generator-plugin/directives.ts b/packages/postcss/src/generator-plugin/directives.ts index 8e6df19421..d34ce07d52 100644 --- a/packages/postcss/src/generator-plugin/directives.ts +++ b/packages/postcss/src/generator-plugin/directives.ts @@ -1,4 +1,5 @@ import type { AtRule, Node, Root } from 'postcss' +import { parseCssImportSpecifier } from '../syntax/css-import' const TAILWIND_ROOT_DIRECTIVE_NAMES = new Set([ 'config', @@ -40,8 +41,7 @@ function createEmptyDirectiveAnalysis(): TailwindCssDirectiveAnalysis { } export function parseTailwindCssDirectiveRequest(params: string) { - const match = /^(?:url\(\s*)?(["']?)([^"')\s]+)\1\s*\)?/.exec(params.trim()) - return match?.[2] + return parseCssImportSpecifier(params)?.specifier } export function parseTailwindCssConfigRequest(params: string) { diff --git a/packages/postcss/src/index.ts b/packages/postcss/src/index.ts index 55e3e858f9..3e993e91a2 100644 --- a/packages/postcss/src/index.ts +++ b/packages/postcss/src/index.ts @@ -164,10 +164,12 @@ export { postcss } from './postcss-runtime' export type { AcceptedPlugin, AtRule, + ChildNode, Container, Declaration, Document, Helpers, + Node, Plugin, PluginCreator, Node as PostcssNode, @@ -206,6 +208,20 @@ export { type PostcssStyleTarget, type PostcssStyleTargetProfile, } from './style-targets' +export { + type CssImportSourceParam, + type CssImportSpecifier, + isTailwindCssImport, + isUniAppXStyleSourceEmpty, + parseCssImportSpecifier, + parseCssSource, + parseImportSourceParam, + parseScssSource, + parseUniAppXStyleSource, + quoteCssImportSpecifier, + scss, + stringifyScssSource, +} from './syntax' export * from './types' export { containsCssAfterMinify, diff --git a/packages/postcss/src/postcss-runtime.ts b/packages/postcss/src/postcss-runtime.ts index cdd9cb0353..7fe4daaabc 100644 --- a/packages/postcss/src/postcss-runtime.ts +++ b/packages/postcss/src/postcss-runtime.ts @@ -1,6 +1,7 @@ export type { AcceptedPlugin, AtRule, + ChildNode, Container, Declaration, Document, diff --git a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/css-import.ts b/packages/postcss/src/syntax/css-import.ts similarity index 60% rename from packages/weapp-tailwindcss/src/tailwindcss/v4-engine/css-import.ts rename to packages/postcss/src/syntax/css-import.ts index 352d5e8c55..29349ec329 100644 --- a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/css-import.ts +++ b/packages/postcss/src/syntax/css-import.ts @@ -1,15 +1,34 @@ import path from 'node:path' import { tokenize, TokenType } from '@csstools/css-tokenizer' -export function parseCssImportSpecifier(params: string) { - const tokens = tokenize({ css: params }).filter(token => +export interface CssImportSpecifier { + specifier: string + raw: string + quote: string | undefined +} + +export interface CssImportSourceParam { + none: boolean + sourcePath: string | undefined +} + +function significantTokens(params: string) { + return tokenize({ css: params }).filter(token => token[0] !== TokenType.Whitespace && token[0] !== TokenType.Comment, ) +} + +/** + * 解析 `@import` / `@use` / `@forward` 参数中的请求串。 + * 必须走 CSS tokenizer,才能覆盖 escape、注释、`url()` 和残缺引号。 + */ +export function parseCssImportSpecifier(params: string): CssImportSpecifier | undefined { + const tokens = significantTokens(params) const first = tokens[0] - if (!first) { + if (!first || first[0] === TokenType.EOF) { return undefined } - if (first[0] === TokenType.String || first[0] === TokenType.URL) { + if (first[0] === TokenType.String || first[0] === TokenType.URL || first[0] === TokenType.Ident) { return { specifier: first[4].value, raw: first[1], @@ -18,25 +37,27 @@ export function parseCssImportSpecifier(params: string) { } if ( first[0] === TokenType.Function && first[4].value.toLowerCase() === 'url' - && tokens[1]?.[0] === TokenType.String && tokens[2]?.[0] === TokenType.CloseParen + && (tokens[1]?.[0] === TokenType.String || tokens[1]?.[0] === TokenType.Ident) + && tokens[2]?.[0] === TokenType.CloseParen ) { return { specifier: tokens[1][4].value, raw: params.slice(first[2], tokens[2][3] + 1), - quote: tokens[1][1][0], + quote: tokens[1][0] === TokenType.String ? tokens[1][1][0] : undefined, } } return undefined } +/** 把文件系统路径写成 CSS 请求串;缓存和读文件仍使用原始路径。 */ export function quoteCssImportSpecifier(file: string, quote = '"') { - // 文件系统路径只在写入 CSS 请求的边界转换,缓存与文件读取仍使用原始路径。 const cssPath = path.sep === '\\' || /^[a-z]:[\\/]|^\\\\/i.test(file) ? file.replaceAll('\\', '/') : file return `${quote}${cssPath.replaceAll('\\', '\\\\').replaceAll(quote, `\\${quote}`).replaceAll('\n', '\\a ').replaceAll('\r', '\\d ')}${quote}` } +/** 判断 import 参数是否指向 Tailwind CSS 包入口。 */ export function isTailwindCssImport(params: string) { const specifier = parseCssImportSpecifier(params)?.specifier if (!specifier) { @@ -50,10 +71,9 @@ export function isTailwindCssImport(params: string) { && paths.basename(paths.dirname(specifier)) === 'tailwindcss' } -export function parseImportSourceParam(params: string) { - const tokens = tokenize({ css: params }).filter(token => - token[0] !== TokenType.Whitespace && token[0] !== TokenType.Comment, - ) +/** 解析 `@import "..." source(...)` 中的 source 参数。 */ +export function parseImportSourceParam(params: string): CssImportSourceParam | undefined { + const tokens = significantTokens(params) const index = tokens.findIndex(token => token[0] === TokenType.Function && token[4].value === 'source') const value = tokens[index + 1] if (index < 0 || tokens[index + 2]?.[0] !== TokenType.CloseParen) { diff --git a/packages/postcss/src/syntax/index.ts b/packages/postcss/src/syntax/index.ts new file mode 100644 index 0000000000..2ec80ee22f --- /dev/null +++ b/packages/postcss/src/syntax/index.ts @@ -0,0 +1,16 @@ +export { + type CssImportSourceParam, + type CssImportSpecifier, + isTailwindCssImport, + parseCssImportSpecifier, + parseImportSourceParam, + quoteCssImportSpecifier, +} from './css-import' +export { + isUniAppXStyleSourceEmpty, + parseCssSource, + parseScssSource, + parseUniAppXStyleSource, + scss, + stringifyScssSource, +} from './parse' diff --git a/packages/postcss/src/syntax/parse.ts b/packages/postcss/src/syntax/parse.ts new file mode 100644 index 0000000000..d084803041 --- /dev/null +++ b/packages/postcss/src/syntax/parse.ts @@ -0,0 +1,36 @@ +import type { Root } from 'postcss' +import postcss from 'postcss' +import scssSyntax from 'postcss-scss' + +export { default as scss } from 'postcss-scss' + +/** 解析标准 CSS;调用方负责处理非法输入。 */ +export function parseCssSource(source: string, from?: string): Root { + return postcss.parse(source, { from }) +} + +/** + * 解析 SCSS/Sass 风格源码。 + * `postcss.parse` 不读取 syntax 选项,行注释和插值必须走 SCSS parser。 + */ +export function parseScssSource(source: string, from?: string): Root { + return scssSyntax.parse(source, { from }) +} + +export function stringifyScssSource(root: Root) { + return root.toString(scssSyntax.stringify) +} + +/** Harmony 在 Sass 预处理前读取局部样式;必须识别行注释,避免将其并入选择器。 */ +export function parseUniAppXStyleSource(source: string) { + return parseScssSource(source) +} + +export function isUniAppXStyleSourceEmpty(source: string) { + try { + return parseUniAppXStyleSource(source).nodes.every(node => node.type === 'comment') + } + catch { + return false + } +} diff --git a/packages/postcss/test/syntax-css-import.test.ts b/packages/postcss/test/syntax-css-import.test.ts new file mode 100644 index 0000000000..c73c8854b1 --- /dev/null +++ b/packages/postcss/test/syntax-css-import.test.ts @@ -0,0 +1,101 @@ +import { + isTailwindCssImport, + isUniAppXStyleSourceEmpty, + parseCssImportSpecifier, + parseCssSource, + parseImportSourceParam, + parseScssSource, + parseTailwindCssDirectiveRequest, + parseUniAppXStyleSource, + quoteCssImportSpecifier, + scss, + stringifyScssSource, +} from '@/index' + +const imports = [ + '/workspace/node_modules/tailwindcss/index.css', + 'C:/workspace/node_modules/tailwindcss/index.css', + String.raw`C:\workspace\node_modules\tailwindcss\index.css`, + String.raw`\\server\share\node_modules\tailwindcss\index.css`, + String.raw`C:\workspace with spaces\node_modules\tailwindcss\index.css`, + './node_modules/tailwindcss/index.css', +] + +describe('css import syntax', () => { + it.each(imports)('serializes and decodes %s at the CSS boundary', (file) => { + const quoted = quoteCssImportSpecifier(file) + const expected = file.includes('\\') ? file.replaceAll('\\', '/') : file + expect(parseCssImportSpecifier(quoted)?.specifier).toBe(expected) + expect(isTailwindCssImport(quoted)).toBe(true) + expect(parseCssImportSpecifier(`url(${JSON.stringify(file)})`)?.specifier).toBe(file) + expect(isTailwindCssImport(`url(${JSON.stringify(file)})`)).toBe(true) + }) + + it('decodes CSS escapes, quotes, comments and source strings', () => { + expect(parseCssImportSpecifier(String.raw`/* entry */ "tailwind\63 ss" layer(utilities)`)?.specifier).toBe('tailwindcss') + expect(parseCssImportSpecifier(String.raw`url("C:\\project's files\\tailwindcss\\index.css") source(none)`)?.specifier) + .toBe(String.raw`C:\project's files\tailwindcss\index.css`) + expect(parseCssImportSpecifier('url(./tailwindcss/index.css)')?.specifier).toBe('./tailwindcss/index.css') + expect(parseImportSourceParam(String.raw`"tailwindcss" source("./my\20 content")`)) + .toEqual({ none: false, sourcePath: './my content' }) + expect(parseImportSourceParam('"tailwindcss" source(none)')).toEqual({ none: true, sourcePath: undefined }) + expect(isTailwindCssImport('"./other/index.css"')).toBe(false) + expect(parseCssImportSpecifier('url("tailwindcss"')).toBeUndefined() + expect(parseCssImportSpecifier(quoteCssImportSpecifier(`C:\\a"b\\tailwindcss\\index.css`))?.specifier) + .toBe('C:/a"b/tailwindcss/index.css') + }) + + it('keeps the directive request parser on the tokenizer implementation', () => { + expect(parseTailwindCssDirectiveRequest('url("tailwindcss") layer(theme)')).toBe('tailwindcss') + expect(parseTailwindCssDirectiveRequest('url(tailwindcss/utilities)')).toBe('tailwindcss/utilities') + expect(parseTailwindCssDirectiveRequest('"tailwindcss/theme.css"')).toBe('tailwindcss/theme.css') + expect(parseTailwindCssDirectiveRequest(String.raw`/* entry */ "tailwind\63 ss"`)).toBe('tailwindcss') + expect(parseTailwindCssDirectiveRequest('')).toBeUndefined() + expect(parseTailwindCssDirectiveRequest('url("tailwindcss"')).toBeUndefined() + }) + + it('covers ident, empty input and Windows path package entries', () => { + expect(parseCssImportSpecifier('')).toBeUndefined() + expect(parseCssImportSpecifier(' ')).toBeUndefined() + expect(parseCssImportSpecifier('tailwindcss')?.specifier).toBe('tailwindcss') + expect(isTailwindCssImport('')).toBe(false) + expect(isTailwindCssImport('layer(theme)')).toBe(false) + expect(isTailwindCssImport('"tailwindcss/theme.css"')).toBe(true) + expect(isTailwindCssImport(String.raw`"C:\\workspace\\node_modules\\tailwindcss\\index.css"`)).toBe(true) + expect(isTailwindCssImport(String.raw`"C:\\workspace\\node_modules\\other\\index.css"`)).toBe(false) + expect(quoteCssImportSpecifier('theme.css', '\'')).toBe("'theme.css'") + expect(quoteCssImportSpecifier('a\nb')).toBe('"a\\a b"') + expect(quoteCssImportSpecifier('a\rb')).toBe('"a\\d b"') + expect(parseCssImportSpecifier('url( "tailwindcss" )')?.specifier).toBe('tailwindcss') + expect(parseCssImportSpecifier('url( tailwindcss )')?.specifier).toBe('tailwindcss') + expect(parseImportSourceParam('"tailwindcss"')).toBeUndefined() + expect(parseImportSourceParam('"tailwindcss" source(')).toBeUndefined() + expect(parseImportSourceParam('"tailwindcss" source(123)')).toBeUndefined() + }) +}) + +describe('uni-app x style source syntax', () => { + it('parses standard CSS and round-trips SCSS comments', () => { + const cssRoot = parseCssSource('.a { color: red; }') + expect(cssRoot.nodes).toHaveLength(1) + const scssRoot = parseScssSource('// x\n.a { color: red; }') + expect(stringifyScssSource(scssRoot)).toContain('.a') + expect(stringifyScssSource(scssRoot)).toContain('// x') + expect(typeof scss.parse).toBe('function') + }) + + it('treats SCSS line comments as comments instead of selectors', () => { + const root = parseUniAppXStyleSource('// comment\n.author { color: red; }') + expect(root.nodes.some(node => node.type === 'comment')).toBe(true) + expect(root.nodes.some(node => node.type === 'rule' && 'selector' in node && node.selector.includes('.author'))).toBe(true) + }) + + it.each(['', '//\n', '/* comment */'])('detects empty author style for %j', (source) => { + expect(isUniAppXStyleSourceEmpty(source)).toBe(true) + }) + + it('does not treat author rules as empty', () => { + expect(isUniAppXStyleSourceEmpty('.author { color: red; }')).toBe(false) + expect(isUniAppXStyleSourceEmpty('{')).toBe(false) + }) +}) diff --git a/packages/weapp-tailwindcss/AGENTS.md b/packages/weapp-tailwindcss/AGENTS.md index d6687b487a..b342de1446 100644 --- a/packages/weapp-tailwindcss/AGENTS.md +++ b/packages/weapp-tailwindcss/AGENTS.md @@ -15,6 +15,14 @@ - `src/context/`:上下文、缓存与配置汇总。 - `test/`:单测、集成、回归、性能相关用例。 +## 样式处理边界 + +- CSS 语法解析、tokenize、selector/value parser、AST 变换和 PostCSS 管线由 `@weapp-tailwindcss/postcss` 拥有。 +- 本包只消费 `@weapp-tailwindcss/postcss` 的导出;禁止直接依赖 `postcss-scss`、`@csstools/css-tokenizer`、`postcss-selector-parser`、`postcss-value-parser`、`lightningcss` 或 `postcss`。 +- 允许通过 `@weapp-tailwindcss/postcss` 的 `postcss` re-export 做编排级 `parse` + walk(读 `@import`、组装 artifact、决定注入位置)。 +- 禁止在本包新增 CSS parser/tokenizer 或 compat 变换实现;新增变换必须放进 `packages/postcss` 再从本包调用。 +- `builtin:lightningcss-loader` 只是 Rspack loader 名称匹配,不构成引入 `lightningcss` 依赖的理由。 + ## JS 转译硬性规则 - JS 相关转译必须遵循 `classNameSet` 精确命中原则:仅转译来自 Tailwind 生成引擎与运行时刷新链路确认的类名集合。 @@ -39,6 +47,7 @@ - `pnpm --filter weapp-tailwindcss test` - `pnpm --filter weapp-tailwindcss test:dev` - 针对单文件回归:`pnpm --filter weapp-tailwindcss exec vitest run test/js/.test.ts` +- 架构边界:`pnpm --filter weapp-tailwindcss exec vitest run test/ci/architecture-contract.test.ts` ## 提交前检查 diff --git a/packages/weapp-tailwindcss/package.json b/packages/weapp-tailwindcss/package.json index 085840ac6b..8016f4bb74 100644 --- a/packages/weapp-tailwindcss/package.json +++ b/packages/weapp-tailwindcss/package.json @@ -232,7 +232,6 @@ "@babel/parser": "catalog:babel8", "@babel/traverse": "catalog:babel8", "@babel/types": "catalog:babel8", - "@csstools/css-tokenizer": "catalog:csstools", "@tailwindcss-mangle/engine": "catalog:tailwindcssMangleEngine", "@vue/compiler-dom": "catalog:vue3", "@weapp-core/escape": "~8.0.0", @@ -244,14 +243,12 @@ "debug": "catalog:buildUtilities", "fast-glob": "catalog:buildUtilities", "htmlparser2": "catalog:buildUtilities", - "lightningcss": "catalog:buildUtilities", "local-pkg": "catalog:buildUtilities", "lru-cache": "catalog:buildUtilities", "magic-string": "catalog:buildUtilities", "micromatch": "catalog:buildUtilities", "oxc-parser": "catalog:compilerUtilities", "oxc-walker": "catalog:compilerUtilities", - "postcss-scss": "catalog:postcssCompat", "semver": "catalog:compilerUtilities", "tailwindcss-config": "workspace:*", "weapp-style-injector": "workspace:*", diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/css-imports.ts b/packages/weapp-tailwindcss/src/bundlers/shared/css-imports.ts index 57a83ebff2..18579b5d64 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/css-imports.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/css-imports.ts @@ -1,7 +1,6 @@ import type { AppType } from '@/types' import path from 'node:path' -import { postcss } from '@weapp-tailwindcss/postcss' -import { parseCssImportSpecifier, quoteCssImportSpecifier } from '@/tailwindcss/v4-engine/css-import' +import { parseCssImportSpecifier, postcss, quoteCssImportSpecifier } from '@weapp-tailwindcss/postcss' const tailwindcssImportRE = /^(?:tailwindcss|weapp-tailwindcss)(?:\/.*)?$/ const tailwindcssCssImportStatementRE = /(@import\s+(?:url\(\s*)?)(["'])((?:tailwindcss|weapp-tailwindcss)(?:\/[^"']*)?\$?)(\2\s*\)?)/gi diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/output-import-shell.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/output-import-shell.ts index 1eb579cf1f..277e013932 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/output-import-shell.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/output-import-shell.ts @@ -1,6 +1,5 @@ -import type { Node } from 'postcss' -import { postcss } from '@weapp-tailwindcss/postcss' -import { parseCssImportSpecifier, quoteCssImportSpecifier } from '@/tailwindcss/v4-engine/css-import' +import type { Node } from '@weapp-tailwindcss/postcss' +import { parseCssImportSpecifier, postcss, quoteCssImportSpecifier } from '@weapp-tailwindcss/postcss' import { parseImportRequest } from './directives' import { isPureLocalCssImportWrapper } from './local-imports' diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/entry-style-graph.ts b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/entry-style-graph.ts index fe2d5fc959..d9d853849b 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/entry-style-graph.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle/entry-style-graph.ts @@ -1,8 +1,7 @@ import type { OutputBundle } from 'rollup' import path from 'node:path' -import { postcss } from '@weapp-tailwindcss/postcss' +import { parseCssImportSpecifier, postcss, quoteCssImportSpecifier } from '@weapp-tailwindcss/postcss' import { normalizeOutputPathKey } from '@/bundlers/shared/module-graph' -import { parseCssImportSpecifier, quoteCssImportSpecifier } from '@/tailwindcss/v4-engine/css-import' export function linkEntryChunkStyles(bundle: OutputBundle, options: { matchesCss: (file: string) => boolean diff --git a/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/scoped-tailwind-noise.ts b/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/scoped-tailwind-noise.ts index 929f042924..10cbe0bb0a 100644 --- a/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/scoped-tailwind-noise.ts +++ b/packages/weapp-tailwindcss/src/bundlers/vite/processed-css-assets/scoped-tailwind-noise.ts @@ -1,4 +1,4 @@ -import type { AtRule, Declaration, Rule } from 'postcss' +import type { AtRule, Declaration, Rule } from '@weapp-tailwindcss/postcss' const VUE_SCOPED_ATTR_RE = /\[data-v-[^\]]+\]/gi const VUE_SCOPED_CLASS_RE = /\.data-v-[\w-]+/gi diff --git a/packages/weapp-tailwindcss/src/compiler/semantic-css.ts b/packages/weapp-tailwindcss/src/compiler/semantic-css.ts index 92feb32826..1fb452edee 100644 --- a/packages/weapp-tailwindcss/src/compiler/semantic-css.ts +++ b/packages/weapp-tailwindcss/src/compiler/semantic-css.ts @@ -1,4 +1,4 @@ -import type { ChildNode, Root } from 'postcss' +import type { ChildNode, Root } from '@weapp-tailwindcss/postcss' import type { GenerationArtifact } from './types' export interface SemanticCssDeclaration { diff --git a/packages/weapp-tailwindcss/src/compiler/types.ts b/packages/weapp-tailwindcss/src/compiler/types.ts index be7bb9c95b..6759e1cec0 100644 --- a/packages/weapp-tailwindcss/src/compiler/types.ts +++ b/packages/weapp-tailwindcss/src/compiler/types.ts @@ -1,4 +1,4 @@ -import type { Root } from 'postcss' +import type { Root } from '@weapp-tailwindcss/postcss' export type CompilerMode = 'legacy' | 'shadow' | 'graph' diff --git a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts index 1ac41b63ef..0e729bffbc 100644 --- a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts +++ b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts @@ -2,8 +2,7 @@ import type { TailwindV4GenerateTarget, TailwindV4ResolvedSource } from '../type import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' -import { postcss } from '@weapp-tailwindcss/postcss' -import { parseCssImportSpecifier } from '../css-import' +import { parseCssImportSpecifier, postcss } from '@weapp-tailwindcss/postcss' import { createTailwindV4DefaultColorThemeCss } from '../tailwind-v4-default-colors' const require = createRequire(import.meta.url) diff --git a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/scan-sources.ts b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/scan-sources.ts index c5357cc59f..691d2d4f53 100644 --- a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/scan-sources.ts +++ b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/scan-sources.ts @@ -1,9 +1,8 @@ import type { TailwindV4GenerateOptions, TailwindV4ResolvedSource, TailwindV4SourcePattern } from '../types' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' -import { postcss } from '@weapp-tailwindcss/postcss' +import { isTailwindCssImport, parseImportSourceParam, postcss } from '@weapp-tailwindcss/postcss' import { resolveCssSourceEntries, resolveTailwindSourceEntry } from '@/tailwindcss/source-scan' -import { isTailwindCssImport, parseImportSourceParam } from '../css-import' type TailwindV4ResolvedScanSources = TailwindV4GenerateOptions['scanSources'] diff --git a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/source.ts b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/source.ts index 824d8e77b2..7af9c7e398 100644 --- a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/source.ts +++ b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/source.ts @@ -7,14 +7,13 @@ import process from 'node:process' import { resolveTailwindV4Source as resolveEngineTailwindV4Source, } from '@tailwindcss-mangle/engine' -import { postcss } from '@weapp-tailwindcss/postcss' +import { parseCssImportSpecifier, postcss, quoteCssImportSpecifier } from '@weapp-tailwindcss/postcss' import { normalizeConfigDirective } from '@/bundlers/shared/generator-css/config-directive' import { normalizeTailwindConfigDirectives, resolveCssEntrySource } from '@/bundlers/shared/generator-css/directives' import { normalizeEmptyTailwindCustomVariants } from '@/bundlers/shared/generator-css/user-css' import { resolveTailwindcssOptions } from '@/tailwindcss/runtime-options' import { filterTailwindV4CssSourceRoots } from '@/tailwindcss/v4/css-sources' import { omitUndefined } from '@/utils/object' -import { parseCssImportSpecifier, quoteCssImportSpecifier } from './css-import' const require = createRequire(import.meta.url) diff --git a/packages/weapp-tailwindcss/src/tailwindcss/v4/preflight.ts b/packages/weapp-tailwindcss/src/tailwindcss/v4/preflight.ts index ba69e71ce1..5ecc7b0320 100644 --- a/packages/weapp-tailwindcss/src/tailwindcss/v4/preflight.ts +++ b/packages/weapp-tailwindcss/src/tailwindcss/v4/preflight.ts @@ -1,17 +1,7 @@ -import { postcss } from '@weapp-tailwindcss/postcss' - -export function parseCssImportSpecifier(params: string) { - const value = params.trim() - const quoted = /^(['"])(.*?)\1/.exec(value) - if (quoted) { - return quoted[2] - } - const url = /^url\(\s*(?:(['"])(.*?)\1|([^'")\s]+))\s*\)/.exec(value) - return url?.[2] ?? url?.[3] -} +import { parseCssImportSpecifier, postcss } from '@weapp-tailwindcss/postcss' function normalizeTailwindImportSpecifier(params: string) { - return parseCssImportSpecifier(params)?.replaceAll('\\', '/') + return parseCssImportSpecifier(params)?.specifier.replaceAll('\\', '/') } export function isTailwindV4CssImportParam(params: string) { diff --git a/packages/weapp-tailwindcss/src/uni-app-x/style-asset.ts b/packages/weapp-tailwindcss/src/uni-app-x/style-asset.ts index 64812801c8..32bc913d43 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/style-asset.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/style-asset.ts @@ -1,13 +1,13 @@ import type { postcss } from '@weapp-tailwindcss/postcss' import type { OutputAsset, OutputChunk } from 'rollup' import type { HarmonyStyleInjectOptions } from './style-asset/harmony-global' +import { parseUniAppXStyleSource } from '@weapp-tailwindcss/postcss' import { expandUniAppXHarmonyApplyStyles } from './style-asset/harmony-apply' import { injectUniAppXHarmonyGlobalStyles, resolveStyleAssetFile, resolveStylePlaceholderFallbackFiles, } from './style-asset/harmony-global' -import { parseUniAppXStyleSource } from './style-asset/source-parser' import { collectChunkMapSourcesContent, collectUniAppXHarmonyApplyStyleSourcesFromSource, diff --git a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/harmony-apply.ts b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/harmony-apply.ts index 4bb05b2e78..4a119a5aac 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/harmony-apply.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/harmony-apply.ts @@ -1,5 +1,4 @@ -import { postcss } from '@weapp-tailwindcss/postcss' -import { parseUniAppXStyleSource } from './source-parser' +import { parseUniAppXStyleSource, postcss } from '@weapp-tailwindcss/postcss' const SFC_STYLE_BLOCK_RE = /(]*>)([\s\S]*?)(<\/style>)/gi diff --git a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/source-parser.ts b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/source-parser.ts deleted file mode 100644 index bae4f15819..0000000000 --- a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/source-parser.ts +++ /dev/null @@ -1,15 +0,0 @@ -import scss from 'postcss-scss' - -// Harmony 在 Sass 预处理前读取局部样式;必须识别行注释,避免将其并入选择器。 -export function parseUniAppXStyleSource(source: string) { - return scss.parse(source, { from: undefined }) -} - -export function isUniAppXStyleSourceEmpty(source: string) { - try { - return parseUniAppXStyleSource(source).nodes.every(node => node.type === 'comment') - } - catch { - return false - } -} diff --git a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts index f7a2b0e16c..1cb6db948e 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts @@ -1,9 +1,8 @@ import type { OutputChunk, SourceMap } from 'rollup' import { splitCandidateTokens } from '@tailwindcss-mangle/engine' -import { postcss } from '@weapp-tailwindcss/postcss' +import { parseUniAppXStyleSource, postcss } from '@weapp-tailwindcss/postcss' import { replaceWxml } from '@/wxml' import { resolveStyleReferencePath } from '../style-reference-path' -import { parseUniAppXStyleSource } from './source-parser' const GEN_APP_STYLES_RE = /const\s+GenAppStyles\s*=\s*\[_uM\(\[([\s\S]*?)\]\)\]/ const STYLE_ENTRY_RE = /\[\s*("((?:\\.|[^"\\])+)")\s*,\s*(_pS\(_uM\(\[[\s\S]*?\]\)\))\s*\]/g diff --git a/packages/weapp-tailwindcss/src/uni-app-x/transform.ts b/packages/weapp-tailwindcss/src/uni-app-x/transform.ts index 6158fda2eb..7e31e46eb8 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/transform.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/transform.ts @@ -3,7 +3,7 @@ import type { SourceMapInput } from 'rollup' import type { TransformResult } from 'vite' import type { CreateJsHandlerOptions, ICustomAttributesEntities, JsHandler } from '@/types' import { NodeTypes, parse as parseTemplate } from '@vue/compiler-dom' -import { normalizeUniAppXImportantApplyForSass } from '@weapp-tailwindcss/postcss' +import { isUniAppXStyleSourceEmpty, normalizeUniAppXImportantApplyForSass } from '@weapp-tailwindcss/postcss' import MagicString from 'magic-string' import { generateCode, replaceWxml } from '@/wxml' import { createAttributeMatcher } from '@/wxml/custom-attributes' @@ -15,7 +15,6 @@ import { shouldEnableComponentLocalStyle, shouldEnablePageLocalStyle, } from './local-style-matcher' -import { isUniAppXStyleSourceEmpty } from './style-asset/source-parser' interface SfcBlock { content: string diff --git a/packages/weapp-tailwindcss/test/ci/architecture-contract.test.ts b/packages/weapp-tailwindcss/test/ci/architecture-contract.test.ts index 488b03c724..d5bc962e60 100644 --- a/packages/weapp-tailwindcss/test/ci/architecture-contract.test.ts +++ b/packages/weapp-tailwindcss/test/ci/architecture-contract.test.ts @@ -4,6 +4,24 @@ import path from 'node:path' const repoRoot = path.resolve(import.meta.dirname, '../../../..') +const forbiddenCssProcessingDeps = [ + '@csstools/css-color-parser', + '@csstools/css-parser-algorithms', + '@csstools/css-tokenizer', + 'autoprefixer', + 'lightningcss', + 'postcss', + 'postcss-preset-env', + 'postcss-pxtrans', + 'postcss-rem-to-responsive-pixel', + 'postcss-rule-unit-converter', + 'postcss-scss', + 'postcss-selector-parser', + 'postcss-value-parser', +] as const + +const forbiddenCssProcessingImportRe = /from\s+['"](?:postcss-scss|@csstools\/css-tokenizer|@csstools\/css-parser-algorithms|@csstools\/css-color-parser|postcss-selector-parser|postcss-value-parser|lightningcss|postcss)['"]/ + function readPackage(relativePath: string) { return JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')) as { name: string @@ -13,6 +31,22 @@ function readPackage(relativePath: string) { } } +function collectTsFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + const files: string[] = [] + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...collectTsFiles(fullPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.ts')) { + files.push(fullPath) + } + } + return files +} + describe('架构边界契约', () => { it('keeps runtime and native packages independent from bundler packages', () => { const forbiddenForRuntime = new Set(['weapp-tailwindcss', '@weapp-tailwindcss/postcss', 'webpack', 'vite', 'rspack']) @@ -57,4 +91,35 @@ describe('架构边界契约', () => { expect(packages[2].dependencies?.['tailwind-variants']).toBe('catalog:runtimeVariants') expect(packages[3].dependencies?.['lodash.merge']).toBe('catalog:runtimeLodash') }) + + it('keeps CSS processing dependencies inside @weapp-tailwindcss/postcss', () => { + const main = readPackage('packages/weapp-tailwindcss/package.json') + const postcss = readPackage('packages/postcss/package.json') + const mainDeps = { + ...main.dependencies, + ...main.devDependencies, + } + + for (const name of forbiddenCssProcessingDeps) { + expect(mainDeps[name], `${name} must not be a weapp-tailwindcss dependency`).toBeUndefined() + } + + expect(main.dependencies?.['@weapp-tailwindcss/postcss']).toBe('workspace:*') + expect(postcss.dependencies?.['postcss-scss']).toBe('catalog:postcssCompat') + expect(postcss.dependencies?.['@csstools/css-tokenizer']).toBe('catalog:csstools') + expect(postcss.dependencies?.postcss).toBe('catalog:postcss85tilde') + }) + + it('does not import CSS processors from weapp-tailwindcss source', () => { + const srcRoot = path.join(repoRoot, 'packages/weapp-tailwindcss/src') + const hits = collectTsFiles(srcRoot) + .flatMap((file) => { + const content = fs.readFileSync(file, 'utf8') + return forbiddenCssProcessingImportRe.test(content) + ? [path.relative(repoRoot, file)] + : [] + }) + + expect(hits).toEqual([]) + }) }) diff --git a/packages/weapp-tailwindcss/test/core.test.ts b/packages/weapp-tailwindcss/test/core.test.ts index 9a91a7836f..4bba69ba17 100644 --- a/packages/weapp-tailwindcss/test/core.test.ts +++ b/packages/weapp-tailwindcss/test/core.test.ts @@ -1,5 +1,4 @@ -import scssParser from 'postcss-scss' - +import { scss as scssParser } from '@weapp-tailwindcss/postcss' import { createContext } from '@/core' describe('core', () => { diff --git a/packages/weapp-tailwindcss/test/tailwindcss/v4-import-paths.test.ts b/packages/weapp-tailwindcss/test/tailwindcss/v4-import-paths.test.ts index a6c3697ebe..d3afe90c89 100644 --- a/packages/weapp-tailwindcss/test/tailwindcss/v4-import-paths.test.ts +++ b/packages/weapp-tailwindcss/test/tailwindcss/v4-import-paths.test.ts @@ -2,7 +2,7 @@ import path from 'node:path' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import { isTailwindCssImport, parseCssImportSpecifier, parseImportSourceParam, quoteCssImportSpecifier } from '@/tailwindcss/v4-engine/css-import' +import { isTailwindCssImport, parseCssImportSpecifier, parseImportSourceParam, quoteCssImportSpecifier } from '@weapp-tailwindcss/postcss' import { createTailwindV4Engine, resolveTailwindV4Source } from '@/tailwindcss/v4-engine' import { resolveCompiledSourceRoot, resolveScanSources } from '@/tailwindcss/v4-engine/generator/scan-sources' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afc05c0a97..a65aff1897 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5038,9 +5038,6 @@ importers: '@babel/types': specifier: catalog:babel8 version: 8.0.5 - '@csstools/css-tokenizer': - specifier: catalog:csstools - version: 4.0.0 '@tailwindcss-mangle/engine': specifier: catalog:tailwindcssMangleEngine version: 0.2.0(tailwindcss@4.3.3) @@ -5074,9 +5071,6 @@ importers: htmlparser2: specifier: catalog:buildUtilities version: 12.0.0 - lightningcss: - specifier: catalog:buildUtilities - version: 1.33.0 local-pkg: specifier: catalog:buildUtilities version: 1.2.1 @@ -5095,9 +5089,6 @@ importers: oxc-walker: specifier: catalog:compilerUtilities version: 1.1.1(@oxc-project/types@0.149.0)(oxc-parser@0.150.0)(rolldown@1.2.7) - postcss-scss: - specifier: catalog:postcssCompat - version: 4.0.9(postcss@8.5.28) semver: specifier: catalog:compilerUtilities version: 7.8.5 From 71400b2be40e7fc8473a715233221f5bc353deb8 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 15 Sep 2026 17:31:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(postcss):=20=E8=BF=81=E5=87=BA=20u?= =?UTF-8?q?ni-app=20x=20=E8=BE=B9=E6=A1=86=E4=B8=8E=20v4=20theme=20CSS=20?= =?UTF-8?q?=E5=8F=98=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 hoist/create border preflight 和 preflight import / vendor keyframes 清理从主包挪到 @weapp-tailwindcss/postcss,主包只保留模板注入与生成编排。 --- .changeset/real-pens-wonder.md | 6 ++ packages/postcss/AGENTS.md | 2 +- packages/postcss/src/compat/tailwindcss-v4.ts | 1 + .../src/compat/tailwindcss-v4/theme-source.ts | 73 +++++++++++++++++++ .../postcss/src/compat/uni-app-x-border.ts | 39 ++++++++++ packages/postcss/src/index.ts | 6 ++ .../test/tailwindcss-v4-theme-source.test.ts | 24 ++++++ .../postcss/test/uni-app-x-border.test.ts | 29 ++++++++ .../v4-engine/generator/css-compat.ts | 70 +----------------- .../src/uni-app-x/border-preflight.ts | 44 +++-------- 10 files changed, 189 insertions(+), 105 deletions(-) create mode 100644 .changeset/real-pens-wonder.md create mode 100644 packages/postcss/src/compat/tailwindcss-v4/theme-source.ts create mode 100644 packages/postcss/src/compat/uni-app-x-border.ts create mode 100644 packages/postcss/test/tailwindcss-v4-theme-source.test.ts create mode 100644 packages/postcss/test/uni-app-x-border.test.ts diff --git a/.changeset/real-pens-wonder.md b/.changeset/real-pens-wonder.md new file mode 100644 index 0000000000..d54871b552 --- /dev/null +++ b/.changeset/real-pens-wonder.md @@ -0,0 +1,6 @@ +--- +"@weapp-tailwindcss/postcss": patch +"weapp-tailwindcss": patch +--- + +将 uni-app x 边框 preflight 与 v4 theme CSS 变换迁到 @weapp-tailwindcss/postcss diff --git a/packages/postcss/AGENTS.md b/packages/postcss/AGENTS.md index 884502c230..4130dfabcf 100644 --- a/packages/postcss/AGENTS.md +++ b/packages/postcss/AGENTS.md @@ -10,7 +10,7 @@ - `src/syntax/`:CSS/SCSS 解析、`@import` specifier tokenize/quote,以及对外暴露的 syntax API。 - `src/plugins/`:插件实现与 pipeline 组装。 -- `src/compat/`:版本兼容与降级逻辑。 +- `src/compat/`:版本兼容与降级逻辑,包括 uni-app x 边框 preflight 与 Tailwind v4 theme source 变换。 - `src/selectorParser/`:选择器解析相关能力。 - `src/utils/`:纯工具函数,保持无副作用、可单测。 diff --git a/packages/postcss/src/compat/tailwindcss-v4.ts b/packages/postcss/src/compat/tailwindcss-v4.ts index 9e75d5385f..e73ffb5070 100644 --- a/packages/postcss/src/compat/tailwindcss-v4.ts +++ b/packages/postcss/src/compat/tailwindcss-v4.ts @@ -1,4 +1,5 @@ export { normalizeTailwindcssV4Declaration } from './tailwindcss-v4/declarations' export { appendTailwindcssV4MiniProgramGradientRules, mergeTailwindcssV4GradientDirectionRules, normalizeTailwindcssV4GradientPosition, normalizeTailwindcssV4InfinityCalcCss, normalizeTailwindcssV4InfinityCalcValue } from './tailwindcss-v4/gradients' export { isTailwindcssV4DisplayP3Declaration, isTailwindcssV4DisplayP3Media, isTailwindcssV4DisplayP3Supports, isTailwindcssV4LinearGradientSupports, isTailwindcssV4ModernCheck } from './tailwindcss-v4/modern-syntax' +export { removeTailwindV4PreflightImports, removeUnsupportedThemeVendorKeyframes } from './tailwindcss-v4/theme-source' export { collectUsedTailwindcssV4Variables, createMissingCssVarsV4Nodes, createUsedCssVarsV4Nodes, cssVarsV4Nodes, isTailwindcssV4, isTailwindcssV4ThemeVariable, testIfRootHostForV4, usesTailwindcssV4ContentVariable } from './tailwindcss-v4/variables' diff --git a/packages/postcss/src/compat/tailwindcss-v4/theme-source.ts b/packages/postcss/src/compat/tailwindcss-v4/theme-source.ts new file mode 100644 index 0000000000..23c0e9db7d --- /dev/null +++ b/packages/postcss/src/compat/tailwindcss-v4/theme-source.ts @@ -0,0 +1,73 @@ +import type { AtRule, Container, Root } from 'postcss' +import postcss from 'postcss' +import { parseCssImportSpecifier } from '../../syntax/css-import' + +function isTailwindCssPreflightImport(params: string) { + const specifier = parseCssImportSpecifier(params)?.specifier + return specifier === 'tailwindcss/preflight.css' || specifier === 'tailwindcss/preflight' +} + +/** 从小程序入口 CSS 中移除 Tailwind v4 preflight import。 */ +export function removeTailwindV4PreflightImports(css: string) { + if (!css.includes('preflight')) { + return css + } + + let root: Root + try { + root = postcss.parse(css) + } + catch { + return css + } + + let changed = false + root.walkAtRules('import', (rule) => { + if (isTailwindCssPreflightImport(rule.params)) { + rule.remove() + changed = true + } + }) + + return changed ? root.toString() : css +} + +function hasThemeParent(rule: AtRule) { + let parent = rule.parent as Container | undefined + while (parent) { + if (parent.type === 'atrule' && (parent as AtRule).name === 'theme') { + return true + } + parent = parent.parent as Container | undefined + } + return false +} + +function isVendorPrefixedKeyframes(rule: AtRule) { + return rule.name.startsWith('-') && rule.name.endsWith('keyframes') +} + +/** 删除 `@theme` 内不被小程序接受的厂商前缀 keyframes。 */ +export function removeUnsupportedThemeVendorKeyframes(css: string) { + if (!css.includes('@theme') || !css.includes('@-')) { + return css + } + + let root: Root + try { + root = postcss.parse(css) + } + catch { + return css + } + + let changed = false + root.walkAtRules((rule) => { + if (isVendorPrefixedKeyframes(rule) && hasThemeParent(rule)) { + rule.remove() + changed = true + } + }) + + return changed ? root.toString() : css +} diff --git a/packages/postcss/src/compat/uni-app-x-border.ts b/packages/postcss/src/compat/uni-app-x-border.ts new file mode 100644 index 0000000000..1162e2c182 --- /dev/null +++ b/packages/postcss/src/compat/uni-app-x-border.ts @@ -0,0 +1,39 @@ +import type { CssPreflightOptions } from '../types' +import postcss from 'postcss' +import { createInjectPreflight } from '../preflight' + +export const UNI_APP_X_BORDER_PREFLIGHT_CLASS = 'weapp-tw-border' + +/** 框架回放组件样式后恢复基础规则的顺序,确保作者 class 可以覆盖重置。 */ +export function hoistUniAppXBorderPreflight(css: string) { + if (!css.includes(UNI_APP_X_BORDER_PREFLIGHT_CLASS)) { + return css + } + const root = postcss.parse(css) + const resets = root.nodes.filter(node => node.type === 'rule' && node.selector === `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}`) + const anchor = root.nodes.find(node => !resets.includes(node) + && node.type !== 'comment' + && !(node.type === 'atrule' && ['charset', 'import'].includes(node.name))) + if (!anchor || resets.length === 0) { + return css + } + for (const reset of resets) { + reset.remove() + root.insertBefore(anchor, reset) + } + return root.toString() +} + +/** uni-app x 移除通配符 preflight 后,用独立基础类承载用户配置的边框默认值。 */ +export function createUniAppXBorderPreflight(options?: CssPreflightOptions) { + const declarations = createInjectPreflight(options)() + .filter(({ prop }) => prop === 'border' || prop.startsWith('border-')) + if (declarations.length === 0) { + return undefined + } + const rule = postcss.rule({ selector: `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}` }) + for (const declaration of declarations) { + rule.append(postcss.decl(declaration)) + } + return rule.toString() +} diff --git a/packages/postcss/src/index.ts b/packages/postcss/src/index.ts index 3e993e91a2..ed1f76320b 100644 --- a/packages/postcss/src/index.ts +++ b/packages/postcss/src/index.ts @@ -49,11 +49,17 @@ export { } from './compat/tailwindcss-rpx' export { normalizeTailwindcssV4InfinityCalcCss } from './compat/tailwindcss-v4' export { normalizeTailwindcssV4InfinityRadiusCss } from './compat/tailwindcss-v4/infinity-radius' +export { removeTailwindV4PreflightImports, removeUnsupportedThemeVendorKeyframes } from './compat/tailwindcss-v4/theme-source' export { normalizeUniAppXImportantApplyForSass, restoreUniAppXImportantApplyMarker, UNI_APP_X_IMPORTANT_APPLY_MARKER, } from './compat/uni-app-x' +export { + createUniAppXBorderPreflight, + hoistUniAppXBorderPreflight, + UNI_APP_X_BORDER_PREFLIGHT_CLASS, +} from './compat/uni-app-x-border' export { type NormalizedWebCssCompatOptions, normalizeWebCssCompatOptions, diff --git a/packages/postcss/test/tailwindcss-v4-theme-source.test.ts b/packages/postcss/test/tailwindcss-v4-theme-source.test.ts new file mode 100644 index 0000000000..d92aad9962 --- /dev/null +++ b/packages/postcss/test/tailwindcss-v4-theme-source.test.ts @@ -0,0 +1,24 @@ +import { + removeTailwindV4PreflightImports, + removeUnsupportedThemeVendorKeyframes, +} from '@/index' + +describe('tailwind v4 theme source css', () => { + it('removes quoted, url and escaped preflight imports', () => { + expect(removeTailwindV4PreflightImports('@import "tailwindcss/theme.css";')).toBe('@import "tailwindcss/theme.css";') + expect(removeTailwindV4PreflightImports('@import "tailwindcss/preflight.css";@import "tailwindcss/theme.css";')) + .toBe('@import "tailwindcss/theme.css";') + expect(removeTailwindV4PreflightImports('@import url("tailwindcss/preflight");.btn{color:red}')) + .toBe('.btn{color:red}') + expect(removeTailwindV4PreflightImports('@import "broken')).toBe('@import "broken') + }) + + it('removes vendor-prefixed keyframes nested in theme', () => { + const css = '@theme{:root{--x:1}@-webkit-keyframes spin{to{transform:rotate(1turn)}}@keyframes spin{to{transform:rotate(1turn)}}}' + const output = removeUnsupportedThemeVendorKeyframes(css) + expect(output).not.toContain('@-webkit-keyframes') + expect(output).toContain('@keyframes spin') + expect(removeUnsupportedThemeVendorKeyframes('@keyframes spin{to{transform:rotate(1turn)}}')).toBe('@keyframes spin{to{transform:rotate(1turn)}}') + expect(removeUnsupportedThemeVendorKeyframes('@theme{:root{--x:1}')).toBe('@theme{:root{--x:1}') + }) +}) diff --git a/packages/postcss/test/uni-app-x-border.test.ts b/packages/postcss/test/uni-app-x-border.test.ts new file mode 100644 index 0000000000..c8f453da7e --- /dev/null +++ b/packages/postcss/test/uni-app-x-border.test.ts @@ -0,0 +1,29 @@ +import { + createUniAppXBorderPreflight, + hoistUniAppXBorderPreflight, + postcss, + UNI_APP_X_BORDER_PREFLIGHT_CLASS, +} from '@/index' + +const reset = `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS} { border-width: 0; }` + +describe('uni-app x border preflight css', () => { + it('restores base priority after component CSS replay without crossing imports', () => { + const source = `@import "./theme.wxss";@media (min-width:1px){.custom{border-width:3px}}.native{border-top-width:1px}.apply{border-top-width:1px}${reset}.pair{border-left-width:2px}` + const output = hoistUniAppXBorderPreflight(source) + const root = postcss.parse(output) + expect(root.nodes.map(node => node.type === 'rule' ? node.selector : node.type === 'atrule' ? node.name : node.type)).toEqual(['import', `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}`, 'media', '.native', '.apply', '.pair']) + expect(hoistUniAppXBorderPreflight(output)).toBe(output) + expect(hoistUniAppXBorderPreflight('.custom{border-width:3px}')).toBe('.custom{border-width:3px}') + }) + + it('uses only configured border defaults and honors disabling', () => { + expect(createUniAppXBorderPreflight(false)).toBeUndefined() + expect(createUniAppXBorderPreflight()).toBeUndefined() + expect(createUniAppXBorderPreflight({ border: false, 'border-width': false, padding: '0' })).toBeUndefined() + const parsed = postcss.parse(createUniAppXBorderPreflight({ border: false, 'border-width': '0', padding: '0' })!) + expect(parsed.nodes).toHaveLength(1) + expect(parsed.first).toMatchObject({ selector: `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}`, nodes: [{ prop: 'border-width', value: '0' }] }) + expect(createUniAppXBorderPreflight({ border: '0 solid', 'border-color': 'red' })).toContain('border: 0 solid;') + }) +}) diff --git a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts index 0e729bffbc..028e4dfea8 100644 --- a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts +++ b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts @@ -2,7 +2,7 @@ import type { TailwindV4GenerateTarget, TailwindV4ResolvedSource } from '../type import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' -import { parseCssImportSpecifier, postcss } from '@weapp-tailwindcss/postcss' +import { parseCssImportSpecifier, postcss, removeTailwindV4PreflightImports, removeUnsupportedThemeVendorKeyframes } from '@weapp-tailwindcss/postcss' import { createTailwindV4DefaultColorThemeCss } from '../tailwind-v4-default-colors' const require = createRequire(import.meta.url) @@ -83,74 +83,6 @@ function applyMiniProgramTailwindV4DefaultColorCss(css: string, source: Tailwind return `${css.slice(0, insertionIndex)}\n${themeCss}\n${css.slice(insertionIndex)}` } -function isTailwindCssPreflightImport(params: string) { - const specifier = parseCssImportSpecifier(params)?.specifier - return specifier === 'tailwindcss/preflight.css' || specifier === 'tailwindcss/preflight' -} - -function removeTailwindV4PreflightImports(css: string) { - if (!css.includes('preflight')) { - return css - } - - let root: postcss.Root - try { - root = postcss.parse(css) - } - catch { - return css - } - - let changed = false - root.walkAtRules('import', (rule) => { - if (isTailwindCssPreflightImport(rule.params)) { - rule.remove() - changed = true - } - }) - - return changed ? root.toString() : css -} - -function hasThemeParent(rule: postcss.AtRule) { - let parent = rule.parent as postcss.Container | undefined - while (parent) { - if (parent.type === 'atrule' && (parent as postcss.AtRule).name === 'theme') { - return true - } - parent = parent.parent as postcss.Container | undefined - } - return false -} - -function isVendorPrefixedKeyframes(rule: postcss.AtRule) { - return rule.name.startsWith('-') && rule.name.endsWith('keyframes') -} - -function removeUnsupportedThemeVendorKeyframes(css: string) { - if (!css.includes('@theme') || !css.includes('@-')) { - return css - } - - let root: postcss.Root - try { - root = postcss.parse(css) - } - catch { - return css - } - - let changed = false - root.walkAtRules((rule) => { - if (isVendorPrefixedKeyframes(rule) && hasThemeParent(rule)) { - rule.remove() - changed = true - } - }) - - return changed ? root.toString() : css -} - export function createCompatibleSource( source: TailwindV4ResolvedSource, target: TailwindV4GenerateTarget, diff --git a/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts b/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts index 2002711f4a..4ff5a8f067 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts @@ -2,43 +2,17 @@ import type { ElementNode } from '@vue/compiler-dom' import type MagicString from 'magic-string' import type { CssPreflightOptions } from '@/types' import { NodeTypes } from '@vue/compiler-dom' -import { createInjectPreflight, postcss } from '@weapp-tailwindcss/postcss' +import { + createUniAppXBorderPreflight, + UNI_APP_X_BORDER_PREFLIGHT_CLASS, +} from '@weapp-tailwindcss/postcss' -export const BORDER_PREFLIGHT_CLASS = 'weapp-tw-border' +export { + createUniAppXBorderPreflight, + hoistUniAppXBorderPreflight, +} from '@weapp-tailwindcss/postcss' -/** 框架回放组件样式后恢复基础规则的顺序,确保作者 class 可以覆盖重置。 */ -export function hoistUniAppXBorderPreflight(css: string) { - if (!css.includes(BORDER_PREFLIGHT_CLASS)) { - return css - } - const root = postcss.parse(css) - const resets = root.nodes.filter(node => node.type === 'rule' && node.selector === `.${BORDER_PREFLIGHT_CLASS}`) - const anchor = root.nodes.find(node => !resets.includes(node) - && node.type !== 'comment' - && !(node.type === 'atrule' && ['charset', 'import'].includes(node.name))) - if (!anchor || resets.length === 0) { - return css - } - for (const reset of resets) { - reset.remove() - root.insertBefore(anchor, reset) - } - return root.toString() -} - -/** uni-app x 移除通配符 preflight 后,用独立基础类承载用户配置的边框默认值。 */ -export function createUniAppXBorderPreflight(options?: CssPreflightOptions) { - const declarations = createInjectPreflight(options)() - .filter(({ prop }) => prop === 'border' || prop.startsWith('border-')) - if (declarations.length === 0) { - return undefined - } - const rule = postcss.rule({ selector: `.${BORDER_PREFLIGHT_CLASS}` }) - for (const declaration of declarations) { - rule.append(postcss.decl(declaration)) - } - return rule.toString() -} +export const BORDER_PREFLIGHT_CLASS = UNI_APP_X_BORDER_PREFLIGHT_CLASS export function resolveUniAppXBorderPreflightOptions( options: { cssPreflight?: CssPreflightOptions, cssPreflightRange?: 'all' }, From 87cbb2211be7a23ba93d7c982c26984ff90729d0 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 15 Sep 2026 17:45:31 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(postcss):=20=E8=BF=81=E5=87=BA=20l?= =?UTF-8?q?egacy=20CSS=E3=80=81=E7=A9=BA=E5=9D=97=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=92=8C=20Harmony=20style=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 selector/unit 兼容、@apply 清理、空 CSS 块扫描以及 class style value 编译从主包挪到 @weapp-tailwindcss/postcss。Vite marker 去重仍留在主包。 --- .changeset/funny-deers-shine.md | 6 + packages/postcss/AGENTS.md | 2 +- .../postcss/src/compat/legacy-css/apply.ts | 28 ++ .../postcss/src/compat/legacy-css/dedupe.ts | 164 +++++++ .../postcss/src/compat/legacy-css/index.ts | 4 + .../src/compat/legacy-css/selectors.ts | 255 +++++++++++ .../postcss/src/compat/legacy-css/units.ts | 67 +++ .../compat/mini-program-css/empty-blocks.ts | 175 ++++++++ .../src/compat/mini-program-css/index.ts | 4 + .../src/compat/uni-app-x-style-value.ts | 126 ++++++ packages/postcss/src/index.ts | 16 + packages/postcss/test/empty-blocks.test.ts | 32 ++ packages/postcss/test/legacy-css.test.ts | 69 +++ .../test/uni-app-x-style-value.test.ts | 31 ++ .../src/bundlers/shared/final-css-cleanup.ts | 179 +------- .../shared/generator-css/legacy-compat.ts | 28 +- .../shared/generator-css/legacy-selectors.ts | 414 +----------------- .../shared/generator-css/legacy-units.ts | 68 +-- .../src/uni-app-x/style-asset/style-value.ts | 99 +---- 19 files changed, 1006 insertions(+), 761 deletions(-) create mode 100644 .changeset/funny-deers-shine.md create mode 100644 packages/postcss/src/compat/legacy-css/apply.ts create mode 100644 packages/postcss/src/compat/legacy-css/dedupe.ts create mode 100644 packages/postcss/src/compat/legacy-css/index.ts create mode 100644 packages/postcss/src/compat/legacy-css/selectors.ts create mode 100644 packages/postcss/src/compat/legacy-css/units.ts create mode 100644 packages/postcss/src/compat/mini-program-css/empty-blocks.ts create mode 100644 packages/postcss/src/compat/uni-app-x-style-value.ts create mode 100644 packages/postcss/test/empty-blocks.test.ts create mode 100644 packages/postcss/test/legacy-css.test.ts create mode 100644 packages/postcss/test/uni-app-x-style-value.test.ts diff --git a/.changeset/funny-deers-shine.md b/.changeset/funny-deers-shine.md new file mode 100644 index 0000000000..98d73c05fb --- /dev/null +++ b/.changeset/funny-deers-shine.md @@ -0,0 +1,6 @@ +--- +"@weapp-tailwindcss/postcss": patch +"weapp-tailwindcss": patch +--- + +将 legacy selector/unit、空块清理和 Harmony CSS style value 变换迁到 @weapp-tailwindcss/postcss diff --git a/packages/postcss/AGENTS.md b/packages/postcss/AGENTS.md index 4130dfabcf..5c62f2c7ab 100644 --- a/packages/postcss/AGENTS.md +++ b/packages/postcss/AGENTS.md @@ -10,7 +10,7 @@ - `src/syntax/`:CSS/SCSS 解析、`@import` specifier tokenize/quote,以及对外暴露的 syntax API。 - `src/plugins/`:插件实现与 pipeline 组装。 -- `src/compat/`:版本兼容与降级逻辑,包括 uni-app x 边框 preflight 与 Tailwind v4 theme source 变换。 +- `src/compat/`:版本兼容与降级逻辑,包括 uni-app x 边框 preflight、style value、legacy selector/unit 与 Tailwind v4 theme source 变换。 - `src/selectorParser/`:选择器解析相关能力。 - `src/utils/`:纯工具函数,保持无副作用、可单测。 diff --git a/packages/postcss/src/compat/legacy-css/apply.ts b/packages/postcss/src/compat/legacy-css/apply.ts new file mode 100644 index 0000000000..b005c899c9 --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/apply.ts @@ -0,0 +1,28 @@ +import postcss from 'postcss' + +/** 删除兼容源中的 `@apply` 规则及其空包装 at-rule。 */ +export function removeTailwindApplyRules(rawSource: string) { + try { + const root = postcss.parse(rawSource) + let removed = false + root.walkAtRules('apply', (rule) => { + const parent = rule.parent + if (parent?.type === 'rule') { + parent.remove() + } + else { + rule.remove() + } + removed = true + }) + root.walkAtRules((rule) => { + if (rule.nodes && rule.nodes.length === 0) { + rule.remove() + } + }) + return removed ? root.toString() : rawSource + } + catch { + return rawSource + } +} diff --git a/packages/postcss/src/compat/legacy-css/dedupe.ts b/packages/postcss/src/compat/legacy-css/dedupe.ts new file mode 100644 index 0000000000..b365cd6f8e --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/dedupe.ts @@ -0,0 +1,164 @@ +import type { Node, Rule } from 'postcss' +import postcss from 'postcss' +import { + collectGeneratedSelectors, + getRuleCompatSelectorKeys, + hasUtilityClassSelector, + isCustomPropertyOnlyRule, + isPseudoContentInitRule, +} from './selectors' + +function collectGeneratedDeclarationPropsBySelector(generatedCss: string, selectors: Set) { + const propsBySelector = new Map>() + try { + const generatedRoot = postcss.parse(generatedCss) + generatedRoot.walkRules((rule) => { + const matchedSelectors = getRuleCompatSelectorKeys(rule).filter(selector => selectors.has(selector)) + if (matchedSelectors.length === 0) { + return + } + const props = new Set() + rule.walkDecls((decl) => { + props.add(decl.prop) + }) + for (const selector of matchedSelectors) { + const existing = propsBySelector.get(selector) + if (existing) { + for (const prop of props) { + existing.add(prop) + } + } + else { + propsBySelector.set(selector, new Set(props)) + } + } + }) + } + catch { + return propsBySelector + } + return propsBySelector +} + +function isRuleCoveredByGeneratedProps( + rule: Rule, + generatedDeclarationPropsBySelector: Map>, +) { + const nodeSelectors = getRuleCompatSelectorKeys(rule) + if (nodeSelectors.length === 0) { + return false + } + const props = new Set() + rule.walkDecls((decl) => { + props.add(decl.prop) + }) + if (props.size === 0) { + return false + } + for (const selector of nodeSelectors) { + const generatedProps = generatedDeclarationPropsBySelector.get(selector) + if (!generatedProps) { + continue + } + if ([...props].every(prop => generatedProps.has(prop))) { + return true + } + } + return false +} + +export function removeGeneratedSelectorCompatCss(css: string, generatedCss: string) { + const generatedSelectors = collectGeneratedSelectors(generatedCss) + if (generatedSelectors.size === 0) { + return css + } + + try { + const root = postcss.parse(css) + let removed = false + root.walkRules((rule) => { + if (isPseudoContentInitRule(rule)) { + rule.remove() + removed = true + return + } + if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { + return + } + if (getRuleCompatSelectorKeys(rule).some(selector => generatedSelectors.has(selector))) { + rule.remove() + removed = true + } + }) + root.walkAtRules((atRule) => { + if (atRule.nodes && atRule.nodes.length === 0) { + atRule.remove() + } + }) + return removed ? root.toString() : css + } + catch { + return css + } +} + +export function collectDedupedPostTransformCompatCss(css: string, generatedCss: string) { + const generatedSelectors = collectGeneratedSelectors(generatedCss) + if (generatedSelectors.size === 0) { + return css + } + const generatedDeclarationPropsBySelector = collectGeneratedDeclarationPropsBySelector(generatedCss, generatedSelectors) + + const preservedNodes: Node[] = [] + try { + const root = postcss.parse(css) + root.each((node) => { + if (node.type === 'rule') { + const nodeSelectors = getRuleCompatSelectorKeys(node) + const duplicated = nodeSelectors.some(selector => generatedSelectors.has(selector)) + if (!duplicated) { + preservedNodes.push(node.clone()) + return + } + if (isRuleCoveredByGeneratedProps(node, generatedDeclarationPropsBySelector)) { + return + } + if (isCustomPropertyOnlyRule(node) && !isPseudoContentInitRule(node) && !hasUtilityClassSelector(node.selector)) { + const declarationProps = new Set() + node.walkDecls((decl) => { + declarationProps.add(decl.prop) + }) + for (const selector of nodeSelectors) { + const generatedProps = generatedDeclarationPropsBySelector.get(selector) + if (!generatedProps) { + continue + } + for (const prop of generatedProps) { + declarationProps.delete(prop) + } + } + const nextRule = node.clone() + nextRule.walkDecls((decl) => { + if (!declarationProps.has(decl.prop)) { + decl.remove() + } + }) + if (nextRule.nodes.length > 0) { + preservedNodes.push(nextRule) + } + } + return + } + preservedNodes.push(node.clone()) + }) + if (preservedNodes.length === root.nodes.length) { + return css + } + const nextRoot = postcss.root() + nextRoot.append(preservedNodes) + return nextRoot.toString() + } + catch { + return css + } +} diff --git a/packages/postcss/src/compat/legacy-css/index.ts b/packages/postcss/src/compat/legacy-css/index.ts new file mode 100644 index 0000000000..3ac4c3645c --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/index.ts @@ -0,0 +1,4 @@ +export { removeTailwindApplyRules } from './apply' +export { collectDedupedPostTransformCompatCss, removeGeneratedSelectorCompatCss } from './dedupe' +export { collectGeneratedSelectors, normalizeCompatSelectors } from './selectors' +export { inheritLegacyUnitConvertedDeclarations } from './units' diff --git a/packages/postcss/src/compat/legacy-css/selectors.ts b/packages/postcss/src/compat/legacy-css/selectors.ts new file mode 100644 index 0000000000..aebdd6b326 --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/selectors.ts @@ -0,0 +1,255 @@ +import type { Rule } from 'postcss' +import { escape } from '@weapp-core/escape' +import postcss from 'postcss' + +const CLASS_SELECTOR_RE = /(?:^|[^\w-])\.[_a-z\u00A0-\uFFFF\\-]/i +const MINI_PROGRAM_THEME_SCOPE_SELECTORS = new Set([':host', 'page', '.tw-root', 'wx-root-portal-content']) +const SPECIFICITY_PLACEHOLDER_RE = /:not\(#(?:\\#|n)\)/g +const SELECTOR_CACHE_LIMIT = 64 +const LEGACY_PSEUDO_ELEMENTS = ['before', 'after', 'first-letter', 'first-line'] as const +const generatedSelectorCache = new Map>() + +function setGeneratedSelectorCache(css: string, selectors: Set) { + if (generatedSelectorCache.size >= SELECTOR_CACHE_LIMIT) { + const firstKey = generatedSelectorCache.keys().next().value + if (firstKey !== undefined) { + generatedSelectorCache.delete(firstKey) + } + } + generatedSelectorCache.set(css, selectors) +} + +function normalizeCompatSelector(selector: string) { + return selector + .replace(SPECIFICITY_PLACEHOLDER_RE, '') + .replace(/\s+/g, ' ') + .trim() +} + +function isLegacyPseudoElementAt(selector: string, index: number) { + for (const name of LEGACY_PSEUDO_ELEMENTS) { + if (!selector.startsWith(name, index)) { + continue + } + const next = selector[index + name.length] + if (next === undefined || !/[\w-]/.test(next)) { + return name + } + } + return undefined +} + +function normalizeLegacyPseudoElements(selector: string) { + let result = '' + let quote: string | undefined + let bracketDepth = 0 + let index = 0 + while (index < selector.length) { + const char = selector[index] + if (char === '\\') { + result += selector.slice(index, index + 2) + index += 2 + continue + } + if (quote !== undefined) { + result += char + if (char === quote) { + quote = undefined + } + index += 1 + continue + } + if (char === '"' || char === '\'') { + quote = char + result += char + index += 1 + continue + } + if (char === '[') { + bracketDepth++ + result += char + index += 1 + continue + } + if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1) + result += char + index += 1 + continue + } + if (bracketDepth === 0 && char === ':' && selector[index + 1] === ':') { + result += '::' + index += 2 + continue + } + if (bracketDepth === 0 && char === ':') { + const name = isLegacyPseudoElementAt(selector, index + 1) + if (name) { + result += `::${name}` + index += name.length + 1 + continue + } + } + result += char + index += 1 + } + return result +} + +function isClassSelectorTerminator(char: string) { + return /[\s>+~#,.:()[\]]/.test(char) +} + +function unescapeSimpleCssIdent(value: string) { + return value.replaceAll(/\\(.)/g, '$1') +} + +function escapeCompatSelectorClasses(selector: string) { + let result = '' + let index = 0 + let changed = false + while (index < selector.length) { + const char = selector[index] + if (char !== '.') { + result += char + index += 1 + continue + } + + let end = index + 1 + let className = '' + while (end < selector.length) { + const current = selector[end] + if (current === undefined) { + break + } + if (current === '\\' && end + 1 < selector.length) { + const escaped = selector[end + 1] + if (escaped === undefined) { + break + } + className += current + escaped + end += 2 + continue + } + if (isClassSelectorTerminator(current)) { + break + } + className += current + end += 1 + } + + if (className.includes('\\')) { + result += `.${escape(unescapeSimpleCssIdent(className))}` + changed = true + } + else { + result += `.${className}` + } + index = end + } + return changed ? result : selector +} + +export function normalizeCompatSelectors(selector: string) { + const normalized = normalizeCompatSelector(selector) + if (!normalized) { + return [] + } + const selectors = new Set([normalized]) + const escaped = normalizeCompatSelector(escapeCompatSelectorClasses(normalized)) + if (escaped) { + selectors.add(escaped) + } + return [...selectors] +} + +function normalizeCssSelector(selector: string) { + return normalizeLegacyPseudoElements(selector).trim().replace(/\s+/g, '') +} + +function getCompatSelectorKeys(selector: string) { + return normalizeCompatSelectors(selector).map(normalizeCssSelector) +} + +export function getRuleCompatSelectorKeys(rule: Rule) { + return (rule.selectors?.length ? rule.selectors : [rule.selector]) + .flatMap(selector => getCompatSelectorKeys(selector)) +} + +function hasClassSelector(selector: string) { + return CLASS_SELECTOR_RE.test(selector) +} + +function getNormalizedSelectorList(selector: string) { + return selector.split(',').map(normalizeCssSelector).filter(Boolean) +} + +function isMiniProgramThemeScopeSelector(selector: string) { + const selectors = getNormalizedSelectorList(selector) + return selectors.length > 0 + && selectors.every(item => MINI_PROGRAM_THEME_SCOPE_SELECTORS.has(item)) +} + +export function hasUtilityClassSelector(selector: string) { + return hasClassSelector(selector) && !isMiniProgramThemeScopeSelector(selector) +} + +export function isCustomPropertyOnlyRule(rule: Rule) { + let hasDeclaration = false + let allCustomProperties = true + + rule.each((node) => { + if (node.type !== 'decl') { + return + } + hasDeclaration = true + if (!node.prop.startsWith('--')) { + allCustomProperties = false + } + }) + + return hasDeclaration && allCustomProperties +} + +export function isPseudoContentInitRule(rule: Rule) { + let hasDeclaration = false + let onlyContentVariable = true + + rule.each((node) => { + if (node.type !== 'decl') { + return + } + hasDeclaration = true + if (node.prop !== '--tw-content') { + onlyContentVariable = false + } + }) + + return hasDeclaration && onlyContentVariable +} + +export function collectGeneratedSelectors(css: string) { + const cached = generatedSelectorCache.get(css) + if (cached) { + return cached + } + + const selectors = new Set() + try { + const root = postcss.parse(css) + root.walkRules((rule) => { + if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { + return + } + for (const selector of getRuleCompatSelectorKeys(rule)) { + selectors.add(selector) + } + }) + } + catch { + return selectors + } + setGeneratedSelectorCache(css, selectors) + return selectors +} diff --git a/packages/postcss/src/compat/legacy-css/units.ts b/packages/postcss/src/compat/legacy-css/units.ts new file mode 100644 index 0000000000..f91237373e --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/units.ts @@ -0,0 +1,67 @@ +import postcss from 'postcss' +import { normalizeCompatSelectors } from './selectors' + +const CSS_LENGTH_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)(?:px|rem)\b/i +const RPX_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)rpx\b/i + +function createLegacyDeclarationValueMap(css: string) { + const values = new Map() + const root = postcss.parse(css) + root.walkRules((rule) => { + if (!rule.selectors || rule.selectors.length === 0) { + return + } + for (const selector of rule.selectors) { + const normalizedSelectors = normalizeCompatSelectors(selector) + rule.walkDecls((decl) => { + if (RPX_UNIT_RE.test(decl.value)) { + for (const normalizedSelector of normalizedSelectors) { + values.set(`${normalizedSelector}\n${decl.prop}`, decl.value) + } + } + }) + } + }) + return values +} + +export function inheritLegacyUnitConvertedDeclarations(css: string, legacyCss: string) { + try { + const legacyValues = createLegacyDeclarationValueMap(legacyCss) + if (legacyValues.size === 0) { + return css + } + + const root = postcss.parse(css) + let changed = false + root.walkRules((rule) => { + if (!rule.selectors || rule.selectors.length === 0) { + return + } + const selectors = rule.selectors + .flatMap(selector => normalizeCompatSelectors(selector)) + if (selectors.length === 0) { + return + } + + rule.walkDecls((decl) => { + if (!CSS_LENGTH_UNIT_RE.test(decl.value)) { + return + } + for (const selector of selectors) { + const legacyValue = legacyValues.get(`${selector}\n${decl.prop}`) + if (legacyValue && legacyValue !== decl.value) { + decl.value = legacyValue + changed = true + return + } + } + }) + }) + + return changed ? root.toString() : css + } + catch { + return css + } +} diff --git a/packages/postcss/src/compat/mini-program-css/empty-blocks.ts b/packages/postcss/src/compat/mini-program-css/empty-blocks.ts new file mode 100644 index 0000000000..2b847e448c --- /dev/null +++ b/packages/postcss/src/compat/mini-program-css/empty-blocks.ts @@ -0,0 +1,175 @@ +import postcss from 'postcss' +import { repairTrailingUnclosedTailwindSourceMedia } from './directives' +import { removeEmptyAtRules, removeEmptyRules } from './root-cleanups' + +function isCssWhitespace(code: number) { + return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 +} + +function isCssWordChar(code: number) { + return (code >= 48 && code <= 57) + || (code >= 65 && code <= 90) + || (code >= 97 && code <= 122) + || code === 95 +} + +// 先用原生正则排除绝大多数不含空块的 CSS,避免每次 HMR 都逐字符扫描完整产物。 +const EMPTY_CSS_BLOCK_RE = /\{(?:[\t\n\f\r ]|\/\*(?:[^*]|\*(?!\/))*\*\/)*\}/ + +function findCssPreludeStart(css: string, start: number, end: number) { + let cursor = start + while (cursor < end) { + while (cursor < end && isCssWhitespace(css.charCodeAt(cursor))) { + cursor++ + } + if (css.charCodeAt(cursor) !== 47 || css.charCodeAt(cursor + 1) !== 42) { + return cursor + } + const commentEnd = css.indexOf('*/', cursor + 2) + if (commentEnd < 0 || commentEnd + 2 > end) { + return end + } + cursor = commentEnd + 2 + } + return end +} + +function isKeyframesAtRule(css: string, start: number, end: number) { + let cursor = start + 1 + if (css.charCodeAt(cursor) === 45) { + cursor++ + const prefixStart = cursor + while (cursor < end && isCssWordChar(css.charCodeAt(cursor))) { + cursor++ + } + if (cursor === prefixStart || css.charCodeAt(cursor) !== 45) { + return false + } + cursor++ + } + if (cursor + 9 > end || css.slice(cursor, cursor + 9).toLowerCase() !== 'keyframes') { + return false + } + const next = css.charCodeAt(cursor + 9) + return !isCssWordChar(next) +} + +export function hasEmptyCssBlockCandidate(css: string) { + if (!EMPTY_CSS_BLOCK_RE.test(css)) { + return false + } + const blocks: Array<{ + hasContent: boolean + isKeyframesContainer: boolean + isKeyframeStep: boolean + }> = [] + let parenthesisDepth = 0 + let quote = 0 + let squareBracketDepth = 0 + let statementStart = 0 + for (let index = 0; index < css.length; index++) { + const code = css.charCodeAt(index) + if (quote !== 0) { + if (code === 92) { + index++ + } + else if (code === quote) { + quote = 0 + } + continue + } + if (code === 34 || code === 39) { + quote = code + if (blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + continue + } + if (code === 92) { + if (blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + index++ + continue + } + if (code === 47 && css.charCodeAt(index + 1) === 42) { + const commentEnd = css.indexOf('*/', index + 2) + if (commentEnd < 0) { + return false + } + index = commentEnd + 1 + continue + } + if (code === 40) { + parenthesisDepth++ + continue + } + if (code === 41 && parenthesisDepth > 0) { + parenthesisDepth-- + continue + } + if (code === 91) { + squareBracketDepth++ + continue + } + if (code === 93 && squareBracketDepth > 0) { + squareBracketDepth-- + continue + } + if (code === 123 && parenthesisDepth === 0 && squareBracketDepth === 0) { + const preludeStart = findCssPreludeStart(css, statementStart, index) + const isAtRule = css.charCodeAt(preludeStart) === 64 + const isKeyframesContainer = isAtRule && isKeyframesAtRule(css, preludeStart, index) + blocks.push({ + hasContent: false, + isKeyframesContainer, + isKeyframeStep: !isAtRule && blocks[blocks.length - 1]?.isKeyframesContainer === true, + }) + statementStart = index + 1 + continue + } + if (code === 125 && parenthesisDepth === 0 && squareBracketDepth === 0) { + const block = blocks.pop() + if (!block) { + continue + } + if (!block.hasContent && !block.isKeyframeStep) { + return true + } + if (blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + statementStart = index + 1 + continue + } + if (code === 59 && parenthesisDepth === 0 && squareBracketDepth === 0) { + statementStart = index + 1 + continue + } + if (!isCssWhitespace(code) && blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + } + return false +} + +/** 在小程序样式进入最终产物图时递归清理无语义的空 CSS 块。 */ +export function finalizeMiniProgramCssStructure(css: string) { + const repaired = repairTrailingUnclosedTailwindSourceMedia(css) + if (!hasEmptyCssBlockCandidate(repaired)) { + return repaired + } + try { + const root = postcss.parse(repaired) + let removed = 0 + let passRemoved = 0 + do { + passRemoved = removeEmptyRules(root) + removeEmptyAtRules(root) + removed += passRemoved + } while (passRemoved > 0) + return removed > 0 ? root.toString() : repaired + } + catch { + return repaired + } +} diff --git a/packages/postcss/src/compat/mini-program-css/index.ts b/packages/postcss/src/compat/mini-program-css/index.ts index 6124323f36..9c0e866117 100644 --- a/packages/postcss/src/compat/mini-program-css/index.ts +++ b/packages/postcss/src/compat/mini-program-css/index.ts @@ -6,6 +6,10 @@ export { } from './at-rules' export { consumeCascadeLayers } from './cascade-layers' export { repairTrailingUnclosedTailwindSourceMedia } from './directives' +export { + finalizeMiniProgramCssStructure, + hasEmptyCssBlockCandidate, +} from './empty-blocks' export { finalizeMiniProgramCss, type FinalizeMiniProgramCssOptions, diff --git a/packages/postcss/src/compat/uni-app-x-style-value.ts b/packages/postcss/src/compat/uni-app-x-style-value.ts new file mode 100644 index 0000000000..2242b9296e --- /dev/null +++ b/packages/postcss/src/compat/uni-app-x-style-value.ts @@ -0,0 +1,126 @@ +import type { AtRule } from 'postcss' +import { splitCandidateTokens } from '@tailwindcss-mangle/engine' +import { escape } from '@weapp-core/escape' +import postcss from 'postcss' +import { parseUniAppXStyleSource } from '../syntax/parse' + +const CLASS_SELECTOR_PREFIX_RE = /^\.((?:\\[^\n\r\f]|[\w-])+)(?=$|[.:#[])/ +const STRING_STYLE_PROPERTIES = new Set(['lineHeight']) + +type StyleDeclarations = Record +export type CssClassStyleValue = Record> + +function toCamelCase(prop: string) { + return prop.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()) +} + +function normalizeValue(prop: string, value: string) { + const trimmed = value.trim() + if (!STRING_STYLE_PROPERTIES.has(toCamelCase(prop)) && /^-?\d+(?:\.\d+)?px$/.test(trimmed)) { + return Number(trimmed.slice(0, -2)) + } + return trimmed.replace(/\s*,\s*/g, ',') +} + +function unescapeCssClassSelector(className: string) { + return className.replace(/\\([^\n\r\f0-9a-f])/gi, '$1') +} + +function assignClassStyleValue( + result: CssClassStyleValue, + className: string, + declarations: StyleDeclarations, +) { + const unescapedClassName = unescapeCssClassSelector(className) + result[className] = { '': declarations } + result[unescapedClassName] = { '': declarations } + result[escape(unescapedClassName)] = { '': declarations } +} + +/** 把 CSS 规则编译成 Harmony/UTS 可消费的 class -> 声明对象。 */ +export function cssToClassStyleValue(source: string): CssClassStyleValue | undefined { + let root: postcss.Root + try { + root = postcss.parse(source) + } + catch { + return + } + const result: CssClassStyleValue = {} + root.walkRules((rule) => { + const selectors = rule.selectors ?? [] + for (const selector of selectors) { + const match = selector.trim().match(CLASS_SELECTOR_PREFIX_RE) + if (!match?.[1]) { + continue + } + const declarations: StyleDeclarations = {} + rule.walkDecls((decl) => { + declarations[toCamelCase(decl.prop)] = normalizeValue(decl.prop, decl.value) + }) + if (Object.keys(declarations).length > 0) { + assignClassStyleValue(result, match[1], declarations) + } + } + }) + return Object.keys(result).length > 0 ? result : undefined +} + +/** 从 SCSS/CSS 源码收集 `@apply` 工具类。 */ +export function collectCssApplyUtilities(source: string) { + const utilities = new Set() + let root: postcss.Root + try { + root = parseUniAppXStyleSource(source) + } + catch { + return utilities + } + root.walkAtRules('apply', (rule) => { + for (const utility of splitCandidateTokens(rule.params)) { + utilities.add(utility) + } + }) + return utilities +} + +/** 把 `@apply` 规则展开成已有 utility 声明。 */ +export function expandCssApplySourcesToStyleValue( + source: string, + utilityStyles: CssClassStyleValue, +): CssClassStyleValue | undefined { + let root: postcss.Root + try { + root = parseUniAppXStyleSource(source) + } + catch { + return + } + const result: CssClassStyleValue = {} + root.walkRules((rule) => { + const applyRules = rule.nodes?.filter((node): node is AtRule => node.type === 'atrule' && node.name === 'apply') ?? [] + if (applyRules.length === 0) { + return + } + const selectors = rule.selectors ?? [rule.selector] + for (const selector of selectors) { + const className = selector.trim().match(CLASS_SELECTOR_PREFIX_RE)?.[1] + if (!className) { + continue + } + const declarations: StyleDeclarations = {} + for (const applyRule of applyRules) { + for (const utility of splitCandidateTokens(applyRule.params)) { + const utilityDeclarations = utilityStyles[utility]?.[''] ?? utilityStyles[escape(utility)]?.[''] + if (utilityDeclarations) { + Object.assign(declarations, utilityDeclarations) + } + } + } + if (Object.keys(declarations).length > 0) { + assignClassStyleValue(result, className, declarations) + } + } + }) + return Object.keys(result).length > 0 ? result : undefined +} diff --git a/packages/postcss/src/index.ts b/packages/postcss/src/index.ts index ed1f76320b..0bba3481a7 100644 --- a/packages/postcss/src/index.ts +++ b/packages/postcss/src/index.ts @@ -15,12 +15,22 @@ export { protectDynamicColorMixAlpha, protectDynamicVarFallbacks, } from './compat/color-mix' +export { + collectDedupedPostTransformCompatCss, + collectGeneratedSelectors, + inheritLegacyUnitConvertedDeclarations, + normalizeCompatSelectors, + removeGeneratedSelectorCompatCss, + removeTailwindApplyRules, +} from './compat/legacy-css' export { transformLynxCssCompat } from './compat/lynx-css' export { consumeCascadeLayers, finalizeMiniProgramCss, type FinalizeMiniProgramCssOptions, finalizeMiniProgramCssRoot, + finalizeMiniProgramCssStructure, + hasEmptyCssBlockCandidate, hasMiniProgramCssSpecificityPlaceholders, hoistTailwindPreflightBase, normalizeMiniProgramGeneratedCssForPostcss, @@ -60,6 +70,12 @@ export { hoistUniAppXBorderPreflight, UNI_APP_X_BORDER_PREFLIGHT_CLASS, } from './compat/uni-app-x-border' +export { + collectCssApplyUtilities, + type CssClassStyleValue, + cssToClassStyleValue, + expandCssApplySourcesToStyleValue, +} from './compat/uni-app-x-style-value' export { type NormalizedWebCssCompatOptions, normalizeWebCssCompatOptions, diff --git a/packages/postcss/test/empty-blocks.test.ts b/packages/postcss/test/empty-blocks.test.ts new file mode 100644 index 0000000000..0b7de945dc --- /dev/null +++ b/packages/postcss/test/empty-blocks.test.ts @@ -0,0 +1,32 @@ +import { finalizeMiniProgramCssStructure, hasEmptyCssBlockCandidate } from '@/index' + +describe('final mini-program css cleanup', () => { + it('detects empty selector and at-rule blocks with a linear precheck', () => { + expect(hasEmptyCssBlockCandidate(':is(page,.tw-root,wx-root-portal-content){}')).toBe(true) + expect(hasEmptyCssBlockCandidate('@media screen { /* token */ .keep { color: red } }')).toBe(false) + expect(hasEmptyCssBlockCandidate('@media screen { @supports (display: grid) { /* removed */ } }')).toBe(true) + expect(hasEmptyCssBlockCandidate('@supports (background: url(data:image/svg+xml;utf8,test)) {}')).toBe(true) + expect(hasEmptyCssBlockCandidate('@keyframes spin { 0% {} to { transform: rotate(1turn); } }')).toBe(false) + expect(hasEmptyCssBlockCandidate('/* prefix */ @-webkit-keyframes spin { 0% {} }')).toBe(false) + expect(hasEmptyCssBlockCandidate('@custom "value;{}"; .keep { color: red }')).toBe(false) + }) + + it('recursively removes empty selector rules and block at-rules', () => { + const source = [ + '@media (prefers-color-scheme: light) {}', + '@media screen { @supports (display: grid) { /* removed declarations */ } }', + '@supports (display: flex) { /* removed declarations */ }', + ':is(page,.tw-root,wx-root-portal-content) {}', + '@media print { .removed { /* removed declarations */ } }', + '.keep { color: red; }', + ].join('\n') + + expect(finalizeMiniProgramCssStructure(source)).toBe('.keep { color: red; }') + }) + + it('returns malformed css unchanged when parsing fails', () => { + const source = '@media (prefers-color-scheme: dark) {}\n.broken {' + + expect(finalizeMiniProgramCssStructure(source)).toBe(source) + }) +}) diff --git a/packages/postcss/test/legacy-css.test.ts b/packages/postcss/test/legacy-css.test.ts new file mode 100644 index 0000000000..c522e12f26 --- /dev/null +++ b/packages/postcss/test/legacy-css.test.ts @@ -0,0 +1,69 @@ +import { + collectDedupedPostTransformCompatCss, + collectGeneratedSelectors, + inheritLegacyUnitConvertedDeclarations, + normalizeCompatSelectors, + removeGeneratedSelectorCompatCss, + removeTailwindApplyRules, +} from '@/index' + +describe('legacy css compatibility helpers', () => { + it('normalizes escaped selectors and ignores theme-only custom property rules', () => { + expect(normalizeCompatSelectors('.w-\\[100px\\]:not(#\\#)')).toEqual([ + '.w-\\[100px\\]', + '.w-_b100px_B', + ]) + expect(normalizeCompatSelectors(' ')).toEqual([]) + + const selectors = collectGeneratedSelectors([ + ':host,page{--color-red-500:red}', + '.w-_b100px_B{width:100px}', + '::before{--tw-content:""}', + ].join('\n')) + + expect(selectors.has('.w-_b100px_B')).toBe(true) + expect(selectors.has(':host')).toBe(false) + expect(selectors.has('::before')).toBe(true) + expect(collectGeneratedSelectors('.broken{').size).toBe(0) + }) + + it('removes compat selectors already generated while preserving custom properties', () => { + const css = removeGeneratedSelectorCompatCss([ + '.w-\\[100px\\]{width:100px}', + '.keep{color:red}', + ':root{--token:1}', + '::before{--tw-content:""}', + ].join('\n'), '.w-_b100px_B{width:100px}') + + expect(css).not.toContain('.w-\\[100px\\]') + expect(css).toContain('.keep') + expect(css).toContain('--token') + expect(css).not.toContain('--tw-content') + }) + + it('dedupes post-transform compat rules with legacy pseudo-element selectors', () => { + const css = collectDedupedPostTransformCompatCss( + '.before_ccontent-_b_aindependent_subpackage_mpx-tailwindcss-v4_a_B:before{--tw-content:"independent subpackage mpx-tailwindcss-v4";content:var(--tw-content)}', + '.before_ccontent-_b_aindependent_subpackage_mpx-tailwindcss-v4_a_B::before{--tw-content:\'independent subpackage mpx-tailwindcss-v4\';content:var(--tw-content)}', + ) + + expect(css).toBe('') + }) + + it('inherits rpx declarations from legacy css onto matching px/rem rules', () => { + const css = inheritLegacyUnitConvertedDeclarations( + '.w-_b100px_B{width:100px}.keep{color:red}', + '.w-\\[100px\\]{width:200rpx}', + ) + expect(css).toContain('width:200rpx') + expect(css).toContain('.keep{color:red}') + expect(inheritLegacyUnitConvertedDeclarations('.keep{color:red}', '.broken{')).toBe('.keep{color:red}') + }) + + it('removes @apply rules and empty wrapper at-rules', () => { + expect(removeTailwindApplyRules('@media screen { .card { @apply flex; } } .keep{color:red}')).toBe('.keep{color:red}') + expect(removeTailwindApplyRules('@apply flex; .keep{color:red}')).toBe('.keep{color:red}') + expect(removeTailwindApplyRules('.keep{color:red}')).toBe('.keep{color:red}') + expect(removeTailwindApplyRules('.broken{')).toBe('.broken{') + }) +}) diff --git a/packages/postcss/test/uni-app-x-style-value.test.ts b/packages/postcss/test/uni-app-x-style-value.test.ts new file mode 100644 index 0000000000..7d55fc5090 --- /dev/null +++ b/packages/postcss/test/uni-app-x-style-value.test.ts @@ -0,0 +1,31 @@ +import { + collectCssApplyUtilities, + cssToClassStyleValue, + expandCssApplySourcesToStyleValue, +} from '@/index' + +describe('uni-app x css class style value', () => { + it('compiles class rules into camelCase declarations with escaped aliases', () => { + const utilityStyles = cssToClassStyleValue([ + '.flex{display:flex}', + '.w-\\[12px\\]{width:12px}', + '.leading-_b26px_B{--tw-leading:26px;line-height:26px}', + ].join(''))! + expect(cssToClassStyleValue('.broken{')).toBeUndefined() + expect(utilityStyles.flex['']).toMatchObject({ display: 'flex' }) + expect(utilityStyles['w-[12px]']['']).toMatchObject({ width: 12 }) + expect(utilityStyles['leading-_b26px_B']['']).toMatchObject({ + '-TwLeading': 26, + lineHeight: '26px', + }) + }) + + it('expands apply sources and collects utilities from scss', () => { + const utilityStyles = cssToClassStyleValue('.flex{display:flex}.w-\\[12px\\]{width:12px}')! + const applied = expandCssApplySourcesToStyleValue('.card{@apply flex w-[12px]}', utilityStyles) + expect(applied?.card['']).toMatchObject({ display: 'flex', width: 12 }) + expect(expandCssApplySourcesToStyleValue('.broken{', utilityStyles)).toBeUndefined() + expect([...collectCssApplyUtilities('.a{@apply flex block}')]).toEqual(['flex', 'block']) + expect(collectCssApplyUtilities('.broken{').size).toBe(0) + }) +}) diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts b/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts index 8e72469382..c7e2c688d1 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts @@ -1,175 +1,4 @@ -import { postcss, removeEmptyAtRules, removeEmptyRules, repairTrailingUnclosedTailwindSourceMedia } from '@weapp-tailwindcss/postcss' - -function isCssWhitespace(code: number) { - return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 -} - -function isCssWordChar(code: number) { - return (code >= 48 && code <= 57) - || (code >= 65 && code <= 90) - || (code >= 97 && code <= 122) - || code === 95 -} - -// 先用原生正则排除绝大多数不含空块的 CSS,避免每次 HMR 都逐字符扫描完整产物。 -const EMPTY_CSS_BLOCK_RE = /\{(?:[\t\n\f\r ]|\/\*(?:[^*]|\*(?!\/))*\*\/)*\}/ - -function findCssPreludeStart(css: string, start: number, end: number) { - let cursor = start - while (cursor < end) { - while (cursor < end && isCssWhitespace(css.charCodeAt(cursor))) { - cursor++ - } - if (css.charCodeAt(cursor) !== 47 || css.charCodeAt(cursor + 1) !== 42) { - return cursor - } - const commentEnd = css.indexOf('*/', cursor + 2) - if (commentEnd < 0 || commentEnd + 2 > end) { - return end - } - cursor = commentEnd + 2 - } - return end -} - -function isKeyframesAtRule(css: string, start: number, end: number) { - let cursor = start + 1 - if (css.charCodeAt(cursor) === 45) { - cursor++ - const prefixStart = cursor - while (cursor < end && isCssWordChar(css.charCodeAt(cursor))) { - cursor++ - } - if (cursor === prefixStart || css.charCodeAt(cursor) !== 45) { - return false - } - cursor++ - } - if (cursor + 9 > end || css.slice(cursor, cursor + 9).toLowerCase() !== 'keyframes') { - return false - } - const next = css.charCodeAt(cursor + 9) - return !isCssWordChar(next) -} - -export function hasEmptyCssBlockCandidate(css: string) { - if (!EMPTY_CSS_BLOCK_RE.test(css)) { - return false - } - const blocks: Array<{ - hasContent: boolean - isKeyframesContainer: boolean - isKeyframeStep: boolean - }> = [] - let parenthesisDepth = 0 - let quote = 0 - let squareBracketDepth = 0 - let statementStart = 0 - for (let index = 0; index < css.length; index++) { - const code = css.charCodeAt(index) - if (quote !== 0) { - if (code === 92) { - index++ - } - else if (code === quote) { - quote = 0 - } - continue - } - if (code === 34 || code === 39) { - quote = code - if (blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - continue - } - if (code === 92) { - if (blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - index++ - continue - } - if (code === 47 && css.charCodeAt(index + 1) === 42) { - const commentEnd = css.indexOf('*/', index + 2) - if (commentEnd < 0) { - return false - } - index = commentEnd + 1 - continue - } - if (code === 40) { - parenthesisDepth++ - continue - } - if (code === 41 && parenthesisDepth > 0) { - parenthesisDepth-- - continue - } - if (code === 91) { - squareBracketDepth++ - continue - } - if (code === 93 && squareBracketDepth > 0) { - squareBracketDepth-- - continue - } - if (code === 123 && parenthesisDepth === 0 && squareBracketDepth === 0) { - const preludeStart = findCssPreludeStart(css, statementStart, index) - const isAtRule = css.charCodeAt(preludeStart) === 64 - const isKeyframesContainer = isAtRule && isKeyframesAtRule(css, preludeStart, index) - blocks.push({ - hasContent: false, - isKeyframesContainer, - isKeyframeStep: !isAtRule && blocks[blocks.length - 1]?.isKeyframesContainer === true, - }) - statementStart = index + 1 - continue - } - if (code === 125 && parenthesisDepth === 0 && squareBracketDepth === 0) { - const block = blocks.pop() - if (!block) { - continue - } - if (!block.hasContent && !block.isKeyframeStep) { - return true - } - if (blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - statementStart = index + 1 - continue - } - if (code === 59 && parenthesisDepth === 0 && squareBracketDepth === 0) { - statementStart = index + 1 - continue - } - if (!isCssWhitespace(code) && blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - } - return false -} - -/** - * 在小程序样式进入最终产物图时递归清理无语义的空 CSS 块。 - */ -export function finalizeMiniProgramCssStructure(css: string) { - const repaired = repairTrailingUnclosedTailwindSourceMedia(css) - if (!hasEmptyCssBlockCandidate(repaired)) { - return repaired - } - try { - const root = postcss.parse(repaired) - let removed = 0 - let passRemoved = 0 - do { - passRemoved = removeEmptyRules(root) + removeEmptyAtRules(root) - removed += passRemoved - } while (passRemoved > 0) - return removed > 0 ? root.toString() : repaired - } - catch { - return repaired - } -} +export { + finalizeMiniProgramCssStructure, + hasEmptyCssBlockCandidate, +} from '@weapp-tailwindcss/postcss' diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts index 45880d6086..0ed0affad2 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts @@ -2,7 +2,7 @@ import type { IStyleHandlerOptions } from '@weapp-tailwindcss/postcss/types' import type { TailwindResolvedSource } from '@/generator' import type { InternalUserDefinedOptions } from '@/types' import { readFileSync } from 'node:fs' -import { filterExistingCssRules, postcss } from '@weapp-tailwindcss/postcss' +import { filterExistingCssRules, postcss, removeTailwindApplyRules } from '@weapp-tailwindcss/postcss' import { removeUnsupportedMiniProgramAtRules } from '../css-cleanup' import { removeTailwindSourceDirectives, resolveCssEntrySource } from './directives' import { collectDedupedPostTransformCompatCss, collectGeneratedSelectors, removeDuplicatedViteMarkers, removeGeneratedSelectorCompatCss } from './legacy-selectors' @@ -144,31 +144,7 @@ function closeTrailingUnclosedBlocks(source: string) { } } -export function removeTailwindApplyRules(rawSource: string) { - try { - const root = postcss.parse(rawSource) - let removed = false - root.walkAtRules('apply', (rule) => { - const parent = rule.parent - if (parent?.type === 'rule') { - parent.remove() - } - else { - rule.remove() - } - removed = true - }) - root.walkAtRules((rule) => { - if (rule.nodes && rule.nodes.length === 0) { - rule.remove() - } - }) - return removed ? root.toString() : rawSource - } - catch { - return rawSource - } -} +export { removeTailwindApplyRules } function resolveLegacyCompatCssSource(rawSource: string) { const cached = legacyCompatSourceCache.get(rawSource) diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts index 1a6fbe1363..8a92280695 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts @@ -1,413 +1,11 @@ -import { postcss } from '@weapp-tailwindcss/postcss' -import { replaceWxml } from '@/wxml' import { VITE_MARKER_RE } from './markers' -const CLASS_SELECTOR_RE = /(?:^|[^\w-])\.[_a-z\u00A0-\uFFFF\\-]/i -const MINI_PROGRAM_THEME_SCOPE_SELECTORS = new Set([':host', 'page', '.tw-root', 'wx-root-portal-content']) -const SPECIFICITY_PLACEHOLDER_RE = /:not\(#(?:\\#|n)\)/g -const SELECTOR_CACHE_LIMIT = 64 -const LEGACY_PSEUDO_ELEMENTS = ['before', 'after', 'first-letter', 'first-line'] as const -const generatedSelectorCache = new Map>() - -function setGeneratedSelectorCache(css: string, selectors: Set) { - if (generatedSelectorCache.size >= SELECTOR_CACHE_LIMIT) { - const firstKey = generatedSelectorCache.keys().next().value - if (firstKey !== undefined) { - generatedSelectorCache.delete(firstKey) - } - } - generatedSelectorCache.set(css, selectors) -} - -function normalizeCompatSelector(selector: string) { - return selector - .replace(SPECIFICITY_PLACEHOLDER_RE, '') - .replace(/\s+/g, ' ') - .trim() -} - -function isLegacyPseudoElementAt(selector: string, index: number) { - for (const name of LEGACY_PSEUDO_ELEMENTS) { - if (!selector.startsWith(name, index)) { - continue - } - const next = selector[index + name.length] - if (next === undefined || !/[\w-]/.test(next)) { - return name - } - } - return undefined -} - -function normalizeLegacyPseudoElements(selector: string) { - let result = '' - let quote: string | undefined - let bracketDepth = 0 - let index = 0 - while (index < selector.length) { - const char = selector[index] - if (char === '\\') { - result += selector.slice(index, index + 2) - index += 2 - continue - } - if (quote !== undefined) { - result += char - if (char === quote) { - quote = undefined - } - index += 1 - continue - } - if (char === '"' || char === '\'') { - quote = char - result += char - index += 1 - continue - } - if (char === '[') { - bracketDepth++ - result += char - index += 1 - continue - } - if (char === ']') { - bracketDepth = Math.max(0, bracketDepth - 1) - result += char - index += 1 - continue - } - if (bracketDepth === 0 && char === ':' && selector[index + 1] === ':') { - result += '::' - index += 2 - continue - } - if (bracketDepth === 0 && char === ':') { - const name = isLegacyPseudoElementAt(selector, index + 1) - if (name) { - result += `::${name}` - index += name.length + 1 - continue - } - } - result += char - index += 1 - } - return result -} - -function isClassSelectorTerminator(char: string) { - return /[\s>+~#,.:()[\]]/.test(char) -} - -function unescapeSimpleCssIdent(value: string) { - return value.replaceAll(/\\(.)/g, '$1') -} - -function escapeCompatSelectorClasses(selector: string) { - let result = '' - let index = 0 - let changed = false - while (index < selector.length) { - const char = selector[index] - if (char !== '.') { - result += char - index += 1 - continue - } - - let end = index + 1 - let className = '' - while (end < selector.length) { - const current = selector[end] - if (current === undefined) { - break - } - if (current === '\\' && end + 1 < selector.length) { - const escaped = selector[end + 1] - if (escaped === undefined) { - break - } - className += current + escaped - end += 2 - continue - } - if (isClassSelectorTerminator(current)) { - break - } - className += current - end += 1 - } - - if (className.includes('\\')) { - result += `.${replaceWxml(unescapeSimpleCssIdent(className))}` - changed = true - } - else { - result += `.${className}` - } - index = end - } - return changed ? result : selector -} - -export function normalizeCompatSelectors(selector: string) { - const normalized = normalizeCompatSelector(selector) - if (!normalized) { - return [] - } - const selectors = new Set([normalized]) - const escaped = normalizeCompatSelector(escapeCompatSelectorClasses(normalized)) - if (escaped) { - selectors.add(escaped) - } - return [...selectors] -} - -function normalizeCssSelector(selector: string) { - return normalizeLegacyPseudoElements(selector).trim().replace(/\s+/g, '') -} - -function getCompatSelectorKeys(selector: string) { - return normalizeCompatSelectors(selector).map(normalizeCssSelector) -} - -function getRuleCompatSelectorKeys(rule: postcss.Rule) { - return (rule.selectors?.length ? rule.selectors : [rule.selector]) - .flatMap(selector => getCompatSelectorKeys(selector)) -} - -function hasClassSelector(selector: string) { - return CLASS_SELECTOR_RE.test(selector) -} - -function getNormalizedSelectorList(selector: string) { - return selector.split(',').map(normalizeCssSelector).filter(Boolean) -} - -function isMiniProgramThemeScopeSelector(selector: string) { - const selectors = getNormalizedSelectorList(selector) - return selectors.length > 0 - && selectors.every(item => MINI_PROGRAM_THEME_SCOPE_SELECTORS.has(item)) -} - -function hasUtilityClassSelector(selector: string) { - return hasClassSelector(selector) && !isMiniProgramThemeScopeSelector(selector) -} - -function isCustomPropertyOnlyRule(rule: postcss.Rule) { - let hasDeclaration = false - let allCustomProperties = true - - rule.each((node) => { - if (node.type !== 'decl') { - return - } - hasDeclaration = true - if (!node.prop.startsWith('--')) { - allCustomProperties = false - } - }) - - return hasDeclaration && allCustomProperties -} - -function isPseudoContentInitRule(rule: postcss.Rule) { - let hasDeclaration = false - let onlyContentVariable = true - - rule.each((node) => { - if (node.type !== 'decl') { - return - } - hasDeclaration = true - if (node.prop !== '--tw-content') { - onlyContentVariable = false - } - }) - - return hasDeclaration && onlyContentVariable -} - -export function collectGeneratedSelectors(css: string) { - const cached = generatedSelectorCache.get(css) - if (cached) { - return cached - } - - const selectors = new Set() - try { - const root = postcss.parse(css) - root.walkRules((rule) => { - if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { - return - } - for (const selector of getRuleCompatSelectorKeys(rule)) { - selectors.add(selector) - } - }) - } - catch { - return selectors - } - setGeneratedSelectorCache(css, selectors) - return selectors -} - -function collectGeneratedDeclarationPropsBySelector(generatedCss: string, selectors: Set) { - const propsBySelector = new Map>() - try { - const generatedRoot = postcss.parse(generatedCss) - generatedRoot.walkRules((rule) => { - const matchedSelectors = getRuleCompatSelectorKeys(rule).filter(selector => selectors.has(selector)) - if (matchedSelectors.length === 0) { - return - } - const props = new Set() - rule.walkDecls((decl) => { - props.add(decl.prop) - }) - for (const selector of matchedSelectors) { - const existing = propsBySelector.get(selector) - if (existing) { - for (const prop of props) { - existing.add(prop) - } - } - else { - propsBySelector.set(selector, new Set(props)) - } - } - }) - } - catch { - return propsBySelector - } - return propsBySelector -} - -function isRuleCoveredByGeneratedProps( - rule: postcss.Rule, - generatedDeclarationPropsBySelector: Map>, -) { - const nodeSelectors = getRuleCompatSelectorKeys(rule) - if (nodeSelectors.length === 0) { - return false - } - const props = new Set() - rule.walkDecls((decl) => { - props.add(decl.prop) - }) - if (props.size === 0) { - return false - } - for (const selector of nodeSelectors) { - const generatedProps = generatedDeclarationPropsBySelector.get(selector) - if (!generatedProps) { - continue - } - if ([...props].every(prop => generatedProps.has(prop))) { - return true - } - } - return false -} - -export function removeGeneratedSelectorCompatCss(css: string, generatedCss: string) { - const generatedSelectors = collectGeneratedSelectors(generatedCss) - if (generatedSelectors.size === 0) { - return css - } - - try { - const root = postcss.parse(css) - let removed = false - root.walkRules((rule) => { - if (isPseudoContentInitRule(rule)) { - rule.remove() - removed = true - return - } - if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { - return - } - if (getRuleCompatSelectorKeys(rule).some(selector => generatedSelectors.has(selector))) { - rule.remove() - removed = true - } - }) - root.walkAtRules((atRule) => { - if (atRule.nodes && atRule.nodes.length === 0) { - atRule.remove() - } - }) - return removed ? root.toString() : css - } - catch { - return css - } -} - -export function collectDedupedPostTransformCompatCss(css: string, generatedCss: string) { - const generatedSelectors = collectGeneratedSelectors(generatedCss) - if (generatedSelectors.size === 0) { - return css - } - const generatedDeclarationPropsBySelector = collectGeneratedDeclarationPropsBySelector(generatedCss, generatedSelectors) - - const preservedNodes: postcss.Node[] = [] - try { - const root = postcss.parse(css) - root.each((node) => { - if (node.type === 'rule') { - const nodeSelectors = getRuleCompatSelectorKeys(node) - const duplicated = nodeSelectors.some(selector => generatedSelectors.has(selector)) - if (!duplicated) { - preservedNodes.push(node.clone()) - return - } - if (isRuleCoveredByGeneratedProps(node, generatedDeclarationPropsBySelector)) { - return - } - if (isCustomPropertyOnlyRule(node) && !isPseudoContentInitRule(node) && !hasUtilityClassSelector(node.selector)) { - const declarationProps = new Set() - node.walkDecls((decl) => { - declarationProps.add(decl.prop) - }) - for (const selector of nodeSelectors) { - const generatedProps = generatedDeclarationPropsBySelector.get(selector) - if (!generatedProps) { - continue - } - for (const prop of generatedProps) { - declarationProps.delete(prop) - } - } - const nextRule = node.clone() - nextRule.walkDecls((decl) => { - if (!declarationProps.has(decl.prop)) { - decl.remove() - } - }) - if (nextRule.nodes.length > 0) { - preservedNodes.push(nextRule) - } - } - return - } - preservedNodes.push(node.clone()) - }) - if (preservedNodes.length === root.nodes.length) { - return css - } - const nextRoot = postcss.root() - nextRoot.append(preservedNodes) - return nextRoot.toString() - } - catch { - return css - } -} +export { + collectDedupedPostTransformCompatCss, + collectGeneratedSelectors, + normalizeCompatSelectors, + removeGeneratedSelectorCompatCss, +} from '@weapp-tailwindcss/postcss' export function removeDuplicatedViteMarkers(css: string, baseCss: string) { if (!VITE_MARKER_RE.test(baseCss)) { diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts index 7126d0d6c5..c115070a4d 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts @@ -1,67 +1 @@ -import { postcss } from '@weapp-tailwindcss/postcss' -import { normalizeCompatSelectors } from './legacy-selectors' - -const CSS_LENGTH_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)(?:px|rem)\b/i -const RPX_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)rpx\b/i - -function createLegacyDeclarationValueMap(css: string) { - const values = new Map() - const root = postcss.parse(css) - root.walkRules((rule) => { - if (!rule.selectors || rule.selectors.length === 0) { - return - } - for (const selector of rule.selectors) { - const normalizedSelectors = normalizeCompatSelectors(selector) - rule.walkDecls((decl) => { - if (RPX_UNIT_RE.test(decl.value)) { - for (const normalizedSelector of normalizedSelectors) { - values.set(`${normalizedSelector}\n${decl.prop}`, decl.value) - } - } - }) - } - }) - return values -} - -export function inheritLegacyUnitConvertedDeclarations(css: string, legacyCss: string) { - try { - const legacyValues = createLegacyDeclarationValueMap(legacyCss) - if (legacyValues.size === 0) { - return css - } - - const root = postcss.parse(css) - let changed = false - root.walkRules((rule) => { - if (!rule.selectors || rule.selectors.length === 0) { - return - } - const selectors = rule.selectors - .flatMap(selector => normalizeCompatSelectors(selector)) - if (selectors.length === 0) { - return - } - - rule.walkDecls((decl) => { - if (!CSS_LENGTH_UNIT_RE.test(decl.value)) { - return - } - for (const selector of selectors) { - const legacyValue = legacyValues.get(`${selector}\n${decl.prop}`) - if (legacyValue && legacyValue !== decl.value) { - decl.value = legacyValue - changed = true - return - } - } - }) - }) - - return changed ? root.toString() : css - } - catch { - return css - } -} +export { inheritLegacyUnitConvertedDeclarations } from '@weapp-tailwindcss/postcss' diff --git a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts index 1cb6db948e..989cbdbcf5 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts @@ -1,7 +1,13 @@ +import type { Root } from '@weapp-tailwindcss/postcss' import type { OutputChunk, SourceMap } from 'rollup' import { splitCandidateTokens } from '@tailwindcss-mangle/engine' -import { parseUniAppXStyleSource, postcss } from '@weapp-tailwindcss/postcss' -import { replaceWxml } from '@/wxml' +import { + collectCssApplyUtilities, + cssToClassStyleValue, + expandCssApplySourcesToStyleValue, + parseUniAppXStyleSource, + +} from '@weapp-tailwindcss/postcss' import { resolveStyleReferencePath } from '../style-reference-path' const GEN_APP_STYLES_RE = /const\s+GenAppStyles\s*=\s*\[_uM\(\[([\s\S]*?)\]\)\]/ @@ -9,7 +15,6 @@ const STYLE_ENTRY_RE = /\[\s*("((?:\\.|[^"\\])+)")\s*,\s*(_pS\(_uM\(\[[\s\S]*?\] const STRING_LITERAL_RE = /(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g const SFC_STYLE_BLOCK_RE = /]*>([\s\S]*?)<\/style>/gi const STYLE_EXPORT_PREFIX_RE = /^\s*export\s+default\s+/ -const CLASS_SELECTOR_PREFIX_RE = /^\.((?:\\[^\n\r\f]|[\w-])+)(?=$|[.:#[])/ const STRING_STYLE_PROPERTIES = new Set(['lineHeight']) type StyleDeclarations = Record @@ -60,10 +65,6 @@ function normalizeStyleValue(prop: string, value: string | number) { return normalizeValue(prop, value) } -function unescapeCssClassSelector(className: string) { - return className.replace(/\\([^\n\r\f0-9a-f])/gi, '$1') -} - export function parseStyleExport(source: string): StyleValue | undefined { const json = source.replace(STYLE_EXPORT_PREFIX_RE, '').trim() if (!json) { @@ -187,41 +188,10 @@ export function createUtsStyleArrayFromAppStyles(code: string, appSource?: strin return createUtsStyleArray([...used].map(className => entries.get(className)!).filter(Boolean)) } -function cssToStyleExport(source: string): StyleValue | undefined { - let root: postcss.Root - try { - root = postcss.parse(source) - } - catch { - return - } - const result: StyleValue = {} - root.walkRules((rule) => { - const selectors = rule.selectors ?? [] - for (const selector of selectors) { - const match = selector.trim().match(CLASS_SELECTOR_PREFIX_RE) - if (!match?.[1]) { - continue - } - const declarations: Record = {} - rule.walkDecls((decl) => { - declarations[toCamelCase(decl.prop)] = normalizeValue(decl.prop, decl.value) - }) - if (Object.keys(declarations).length > 0) { - result[match[1]] = { '': declarations } - const className = unescapeCssClassSelector(match[1]) - result[className] = { '': declarations } - result[replaceWxml(className)] = { '': declarations } - } - } - }) - return Object.keys(result).length > 0 ? result : undefined -} - export function cssSourceToStyleValue(source: string) { return STYLE_EXPORT_PREFIX_RE.test(source) ? parseStyleExport(source) - : cssToStyleExport(source) + : cssToClassStyleValue(source) } export function mergeStyleValues(...items: Array) { @@ -249,48 +219,18 @@ export function createStyleValueFromApplySources(sources: string[], utilityStyle ? [...source.matchAll(SFC_STYLE_BLOCK_RE)].map(styleBlock => styleBlock[1] ?? '') : [source] for (const styleSource of styleSources) { - let root: postcss.Root - try { - root = parseUniAppXStyleSource(styleSource) - } - catch { + const applied = expandCssApplySourcesToStyleValue(styleSource, utilityStyles) + if (!applied) { continue } - root.walkRules((rule) => { - const applyRules = rule.nodes?.filter((node): node is postcss.AtRule => node.type === 'atrule' && node.name === 'apply') ?? [] - if (applyRules.length === 0) { - return - } - const selectors = rule.selectors ?? [rule.selector] - for (const selector of selectors) { - const className = selector.trim().match(CLASS_SELECTOR_PREFIX_RE)?.[1] - if (!className) { - continue - } - const declarations: Record = {} - for (const applyRule of applyRules) { - for (const utility of splitCandidateTokens(applyRule.params)) { - const utilityDeclarations = utilityStyles[utility]?.[''] ?? utilityStyles[replaceWxml(utility)]?.[''] - if (utilityDeclarations) { - Object.assign(declarations, utilityDeclarations) - } - } - } - if (Object.keys(declarations).length > 0) { - const unescapedClassName = unescapeCssClassSelector(className) - result[className] = { '': declarations } - result[unescapedClassName] = { '': declarations } - result[replaceWxml(unescapedClassName)] = { '': declarations } - } - } - }) + Object.assign(result, applied) } } return Object.keys(result).length > 0 ? result : undefined } function resolveReferencePaths(styleSource: string, sourceId?: string) { - let root: postcss.Root + let root: Root try { root = parseUniAppXStyleSource(styleSource) } @@ -333,18 +273,9 @@ export function collectUniAppXHarmonyApplyUtilitiesFromSources(sources: Iterable const utilities = new Set() for (const source of sources) { for (const styleSource of collectUniAppXHarmonyApplyStyleSourcesFromSource(source)) { - let root: postcss.Root - try { - root = parseUniAppXStyleSource(styleSource) - } - catch { - continue + for (const utility of collectCssApplyUtilities(styleSource)) { + utilities.add(utility) } - root.walkAtRules('apply', (rule) => { - for (const utility of splitCandidateTokens(rule.params)) { - utilities.add(utility) - } - }) } } return utilities From 7ba4438cedd975ffbe64e173a1275e81628f00ce Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Tue, 15 Sep 2026 18:22:30 +0800 Subject: [PATCH 4/4] =?UTF-8?q?perf(postcss):=20=E7=BB=99=E7=AE=80?= =?UTF-8?q?=E5=8D=95=20CSS=20import=20specifier=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BF=AB=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uni-app Vite 构建在大量无 escape 的 @import 上不必每次走 tokenizer。 复杂输入(注释、escape、残缺引号)仍回退到 @csstools/css-tokenizer。 --- packages/postcss/src/syntax/css-import.ts | 66 ++++++++++++++++++- .../postcss/test/syntax-css-import.test.ts | 18 +++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/postcss/src/syntax/css-import.ts b/packages/postcss/src/syntax/css-import.ts index 29349ec329..8cbd2e4e81 100644 --- a/packages/postcss/src/syntax/css-import.ts +++ b/packages/postcss/src/syntax/css-import.ts @@ -18,11 +18,75 @@ function significantTokens(params: string) { ) } +function isCssWhitespace(char: string | undefined) { + return char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '\f' +} + +function skipCssWhitespace(params: string, index: number) { + while (index < params.length && isCssWhitespace(params[index])) { + index++ + } + return index +} + +function parseSimpleQuotedSpecifier(params: string, start: number): CssImportSpecifier | undefined { + const quote = params[start] + if (quote !== '"' && quote !== '\'') { + return undefined + } + let index = start + 1 + while (index < params.length) { + const char = params[index] + if (char === '\\' || char === '\n' || char === '\r') { + return undefined + } + if (char === quote) { + return { + specifier: params.slice(start + 1, index), + raw: params.slice(start, index + 1), + quote, + } + } + index++ + } + return undefined +} + +function parseSimpleImportSpecifier(params: string): CssImportSpecifier | undefined { + let index = skipCssWhitespace(params, 0) + const quoted = parseSimpleQuotedSpecifier(params, index) + if (quoted) { + return quoted + } + if (params.slice(index, index + 4).toLowerCase() !== 'url(') { + return undefined + } + const urlStart = index + index = skipCssWhitespace(params, index + 4) + const inner = parseSimpleQuotedSpecifier(params, index) + if (!inner) { + return undefined + } + index = skipCssWhitespace(params, index + inner.raw.length) + if (params[index] !== ')') { + return undefined + } + return { + specifier: inner.specifier, + raw: params.slice(urlStart, index + 1), + quote: inner.quote, + } +} + /** * 解析 `@import` / `@use` / `@forward` 参数中的请求串。 - * 必须走 CSS tokenizer,才能覆盖 escape、注释、`url()` 和残缺引号。 + * 无 escape/注释的引号和 `url("...")` 走快路径;复杂输入才使用 CSS tokenizer。 */ export function parseCssImportSpecifier(params: string): CssImportSpecifier | undefined { + const simple = parseSimpleImportSpecifier(params) + if (simple) { + return simple + } const tokens = significantTokens(params) const first = tokens[0] if (!first || first[0] === TokenType.EOF) { diff --git a/packages/postcss/test/syntax-css-import.test.ts b/packages/postcss/test/syntax-css-import.test.ts index c73c8854b1..935fb5c19d 100644 --- a/packages/postcss/test/syntax-css-import.test.ts +++ b/packages/postcss/test/syntax-css-import.test.ts @@ -54,6 +54,24 @@ describe('css import syntax', () => { expect(parseTailwindCssDirectiveRequest('url("tailwindcss"')).toBeUndefined() }) + it('uses a quoted fast path that still preserves trailing layer/source params', () => { + expect(parseCssImportSpecifier(' "tailwindcss" layer(theme)')).toEqual({ + specifier: 'tailwindcss', + raw: '"tailwindcss"', + quote: '"', + }) + expect(parseCssImportSpecifier('url( "./local.css" ) source(none)')).toEqual({ + specifier: './local.css', + raw: 'url( "./local.css" )', + quote: '"', + }) + expect(parseCssImportSpecifier('url(\'./theme.css\')')).toEqual({ + specifier: './theme.css', + raw: 'url(\'./theme.css\')', + quote: '\'', + }) + }) + it('covers ident, empty input and Windows path package entries', () => { expect(parseCssImportSpecifier('')).toBeUndefined() expect(parseCssImportSpecifier(' ')).toBeUndefined()