Skip to content

Commit a835a94

Browse files
committed
fix: improve uni-app x uvue compatibility
1 parent 03553c8 commit a835a94

File tree

22 files changed

+766
-21
lines changed

22 files changed

+766
-21
lines changed

.changeset/blue-spoons-unite.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@weapp-tailwindcss/postcss': patch
3+
'weapp-tailwindcss': patch
4+
---
5+
6+
修复 `uni-app x``uvue/nvue` 样式目标会输出宿主不支持 CSS 的问题。
7+
8+
-`uvue` 目标下过滤非 class selector,避免继续输出 `space-x-*``space-y-*` 这类组合器选择器。
9+
-`uvue` 目标下过滤不兼容声明,例如 `display: block``display: inline-flex``display: grid``grid-template-columns``gap``min-height: 100vh`
10+
- 新增 `uniAppX.uvueUnsupported` 配置,支持 `error | warn | silent`,默认 `warn`
11+
- 当策略为 `warn` 时,跳过不兼容 utility 并输出包含 class 名与来源文件的警告,避免 HBuilderX 因非法 CSS 直接报错。
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { Result as PostcssResult, Rule } from 'postcss'
2+
import type { IStyleHandlerOptions, UniAppXUnsupportedMode } from '../types'
3+
import selectorParser from 'postcss-selector-parser'
4+
5+
const ALLOWED_DISPLAY_VALUES = new Set(['flex', 'none'])
6+
const FALLBACK_CLASS_RE = /\.((?:\\.|[\w-])+)/g
7+
const IMPORTANT_SUFFIX_RE = /\s*!important$/i
8+
9+
function isUniAppXUvueTarget(
10+
options?: Pick<IStyleHandlerOptions, 'uniAppX' | 'uniAppXCssTarget'>,
11+
) {
12+
return Boolean(options?.uniAppX) && options?.uniAppXCssTarget === 'uvue'
13+
}
14+
15+
function normalizeUnsupportedMode(mode?: UniAppXUnsupportedMode): UniAppXUnsupportedMode {
16+
return mode ?? 'warn'
17+
}
18+
19+
function normalizeValue(value: string) {
20+
return value.trim().toLowerCase().replace(IMPORTANT_SUFFIX_RE, '')
21+
}
22+
23+
function getSourceFile(rule: Rule, result: PostcssResult) {
24+
return rule.source?.input.from ?? result.opts.from ?? 'unknown source'
25+
}
26+
27+
function collectUtilityClassNames(rule: Rule) {
28+
const classNames = new Set<string>()
29+
30+
for (const selector of rule.selectors ?? []) {
31+
try {
32+
const ast = selectorParser().astSync(selector)
33+
ast.walkClasses((node) => {
34+
if (node.value) {
35+
classNames.add(node.value)
36+
}
37+
})
38+
}
39+
catch {
40+
for (const match of selector.matchAll(FALLBACK_CLASS_RE)) {
41+
if (match[1]) {
42+
classNames.add(match[1].replaceAll('\\', ''))
43+
}
44+
}
45+
}
46+
}
47+
48+
return [...classNames]
49+
}
50+
51+
function hasOnlyClassSelectors(rule: Rule) {
52+
const selectors = rule.selectors ?? []
53+
if (selectors.length === 0) {
54+
return false
55+
}
56+
57+
return selectors.every((selector) => {
58+
try {
59+
const ast = selectorParser().astSync(selector)
60+
return ast.nodes.every(node => node.nodes.length > 0 && node.nodes.every(child => child.type === 'class'))
61+
}
62+
catch {
63+
return false
64+
}
65+
})
66+
}
67+
68+
function getUnsupportedDeclarationReason(prop: string, value: string) {
69+
const normalizedProp = prop.trim().toLowerCase()
70+
const normalizedValue = normalizeValue(value)
71+
72+
if (normalizedProp === 'display' && !ALLOWED_DISPLAY_VALUES.has(normalizedValue)) {
73+
return `${normalizedProp}: ${value}`
74+
}
75+
76+
if (normalizedProp === 'min-height' && normalizedValue === '100vh') {
77+
return `${normalizedProp}: ${value}`
78+
}
79+
80+
if (
81+
normalizedProp === 'grid-template-columns'
82+
|| normalizedProp === 'grid-template-rows'
83+
|| normalizedProp === 'grid-auto-columns'
84+
|| normalizedProp === 'grid-auto-rows'
85+
|| normalizedProp === 'grid-auto-flow'
86+
) {
87+
return `${normalizedProp}: ${value}`
88+
}
89+
90+
if (normalizedProp === 'gap' || normalizedProp === 'row-gap' || normalizedProp === 'column-gap') {
91+
return `${normalizedProp}: ${value}`
92+
}
93+
}
94+
95+
function reportUnsupportedRule(
96+
rule: Rule,
97+
result: PostcssResult,
98+
mode: UniAppXUnsupportedMode,
99+
warningCache: Set<string>,
100+
reason: string,
101+
) {
102+
if (mode === 'silent') {
103+
return
104+
}
105+
106+
const classNames = collectUtilityClassNames(rule)
107+
const classLabel = classNames.length > 0 ? classNames.join(', ') : rule.selector
108+
const source = getSourceFile(rule, result)
109+
const message = `uni-app x uvue unsupported utility: ${classLabel} (${reason}) in ${source}`
110+
111+
if (mode === 'error') {
112+
throw rule.error(message)
113+
}
114+
115+
if (warningCache.has(message)) {
116+
return
117+
}
118+
119+
warningCache.add(message)
120+
rule.warn(result, message)
121+
}
122+
123+
export function applyUniAppXUvueCompatibility(
124+
result: PostcssResult,
125+
options?: Pick<IStyleHandlerOptions, 'uniAppX' | 'uniAppXCssTarget' | 'uniAppXUnsupported'>,
126+
) {
127+
if (!isUniAppXUvueTarget(options)) {
128+
return result
129+
}
130+
131+
const mode = normalizeUnsupportedMode(options?.uniAppXUnsupported)
132+
const warningCache = new Set<string>()
133+
134+
result.root.walkRules((rule) => {
135+
if (!hasOnlyClassSelectors(rule)) {
136+
reportUnsupportedRule(rule, result, mode, warningCache, 'selector must be class-only')
137+
rule.remove()
138+
return
139+
}
140+
141+
rule.walkDecls((decl) => {
142+
const reason = getUnsupportedDeclarationReason(decl.prop, decl.value)
143+
if (!reason) {
144+
return
145+
}
146+
147+
reportUnsupportedRule(rule, result, mode, warningCache, reason)
148+
decl.remove()
149+
})
150+
151+
if ((rule.nodes?.length ?? 0) === 0) {
152+
rule.remove()
153+
}
154+
})
155+
156+
result.root.walkAtRules((atRule) => {
157+
if ((atRule.nodes?.length ?? 0) === 0) {
158+
atRule.remove()
159+
}
160+
})
161+
162+
const nextResult = result.root.toResult(result.opts)
163+
nextResult.messages.push(...result.messages)
164+
return nextResult
165+
}

packages/postcss/src/defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function getDefaultOptions(options?: Partial<IStyleHandlerOptions>): Part
2626
},
2727
// 支付宝小程序不支持,所以默认关闭
2828
cssRemoveProperty: true,
29+
uniAppXUnsupported: 'warn',
2930
// cssRemoveAtSupports: true,
3031
// cssRemoveAtMedia: true,
3132
cssSelectorReplacement: {

packages/postcss/src/handler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { IStyleHandlerOptions, StyleHandler } from './types'
33
import { defuOverrideArray } from '@weapp-tailwindcss/shared'
44
import { applyUniAppXBaseCompatibility } from './compat/uni-app-x'
5+
import { applyUniAppXUvueCompatibility } from './compat/uni-app-x-uvue'
56
import { getDefaultOptions } from './defaults'
67
import { createOptionsResolver } from './options-resolver'
78
import { createInjectPreflight } from './preflight'
@@ -32,7 +33,10 @@ export function createStyleHandler(options?: Partial<IStyleHandlerOptions>): Sty
3233
return processor.process(
3334
rawSource,
3435
processOptions,
35-
).async().then(result => applyUniAppXBaseCompatibility(result, resolvedOptions))
36+
).async().then((result) => {
37+
const baseCompatible = applyUniAppXBaseCompatibility(result, resolvedOptions)
38+
return applyUniAppXUvueCompatibility(baseCompatible, resolvedOptions)
39+
})
3640
}) as StyleHandler
3741

3842
handler.getPipeline = (opt?: Partial<IStyleHandlerOptions>) => {

packages/postcss/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// 统一导出入口,供外部调用端按需引用核心能力
22
export * from './handler'
3+
export { default as postcssHtmlTransform, type IOptions as PostcssHtmlTransformOptions } from './html-transform'
34
export {
45
createStylePipeline,
56
type PipelineNodeContext,

packages/postcss/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export interface IPropValue {
1717
value: string
1818
}
1919

20+
export type UniAppXCssTarget = 'uvue'
21+
22+
export type UniAppXUnsupportedMode = 'error' | 'warn' | 'silent'
23+
2024
export type CssPreflightOptions
2125
= | {
2226
[key: string]: string | number | boolean
@@ -65,6 +69,8 @@ export type IStyleHandlerOptions = {
6569
media?: boolean
6670
}
6771
uniAppX?: boolean
72+
uniAppXCssTarget?: UniAppXCssTarget
73+
uniAppXUnsupported?: UniAppXUnsupportedMode
6874
majorVersion?: number
6975
} & RequiredStyleHandlerOptions
7076

@@ -85,6 +91,8 @@ export interface UserDefinedPostcssOptions {
8591
cssRemoveHoverPseudoClass?: boolean
8692
cssRemoveProperty?: boolean
8793
uniAppX?: boolean
94+
uniAppXCssTarget?: UniAppXCssTarget
95+
uniAppXUnsupported?: UniAppXUnsupportedMode
8896
}
8997

9098
export type {

packages/postcss/test/uni-app-x.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,109 @@ describe('uni-app-x', () => {
8484
expect(css).not.toContain('--tw-text-opacity:')
8585
expect(css).toContain('.border-_b_h999_B')
8686
})
87+
88+
it('filters unsupported uvue selectors and declarations with warnings', async () => {
89+
const styleHandler = createStyleHandler({
90+
uniAppX: true,
91+
uniAppXCssTarget: 'uvue',
92+
uniAppXUnsupported: 'warn',
93+
})
94+
const result = await styleHandler(
95+
`
96+
.space-y-4 > view + view {
97+
margin-top: 1rem;
98+
}
99+
.block {
100+
display: block;
101+
}
102+
.inline-flex {
103+
display: inline-flex;
104+
}
105+
.grid {
106+
display: grid;
107+
}
108+
.grid-cols-2 {
109+
grid-template-columns: repeat(2, minmax(0, 1fr));
110+
}
111+
.gap-4 {
112+
gap: 1rem;
113+
}
114+
.min-h-screen {
115+
min-height: 100vh;
116+
}
117+
.flex {
118+
display: flex;
119+
}
120+
`,
121+
{
122+
isMainChunk: true,
123+
postcssOptions: {
124+
options: {
125+
from: '/src/App.uvue',
126+
},
127+
},
128+
},
129+
)
130+
131+
expect(result.css).not.toContain('.space-y-4')
132+
expect(result.css).not.toContain('display: block')
133+
expect(result.css).not.toContain('display: inline-flex')
134+
expect(result.css).not.toContain('display: grid')
135+
expect(result.css).not.toContain('grid-template-columns')
136+
expect(result.css).not.toContain('gap: 1rem')
137+
expect(result.css).not.toContain('min-height: 100vh')
138+
expect(result.css).toContain('.flex')
139+
expect(result.css).toContain('display: flex')
140+
141+
const warningTexts = result.warnings().map(item => item.text)
142+
expect(warningTexts).toEqual(expect.arrayContaining([
143+
expect.stringContaining('space-y-4'),
144+
expect.stringContaining('block'),
145+
expect.stringContaining('inline-flex'),
146+
expect.stringContaining('grid'),
147+
expect.stringContaining('grid-cols-2'),
148+
expect.stringContaining('gap-4'),
149+
expect.stringContaining('min-h-screen'),
150+
]))
151+
expect(warningTexts.every(item => item.includes('/src/App.uvue'))).toBe(true)
152+
})
153+
154+
it('throws for unsupported uvue utility when mode is error', async () => {
155+
const styleHandler = createStyleHandler({
156+
uniAppX: true,
157+
uniAppXCssTarget: 'uvue',
158+
uniAppXUnsupported: 'error',
159+
})
160+
161+
await expect(styleHandler('.block { display: block; }', {
162+
isMainChunk: true,
163+
postcssOptions: {
164+
options: {
165+
from: '/src/pages/index.uvue',
166+
},
167+
},
168+
})).rejects.toThrow(/uni-app x uvue unsupported utility: block/)
169+
})
170+
171+
it('keeps original behaviour for non-uvue uni-app-x targets', async () => {
172+
const styleHandler = createStyleHandler({
173+
uniAppX: true,
174+
})
175+
const result = await styleHandler(
176+
`
177+
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
178+
margin-top: 1rem;
179+
}
180+
.block {
181+
display: block;
182+
}
183+
`,
184+
{
185+
isMainChunk: true,
186+
},
187+
)
188+
189+
expect(result.css).toContain('.space-y-4>view+view')
190+
expect(result.css).toContain('display: block')
191+
})
87192
})

packages/weapp-tailwindcss/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,32 @@
7474

7575
## [配置项参考](https://tw.icebreaker.top/docs/api/interfaces/UserDefinedOptions)
7676

77+
### uni-app x uvue 兼容提示
78+
79+
从当前版本开始,`uni-app x``uvue/nvue` 样式目标会额外过滤宿主不支持的 CSS selector 与 utility 声明,避免把非法 CSS 直接注入到 `App.uvue` 或页面样式中。
80+
81+
可通过 `uniAppX.uvueUnsupported` 控制行为:
82+
83+
- `warn`:默认值。跳过不兼容 utility,并输出 `uni-app x uvue unsupported utility` 警告。
84+
- `error`:遇到不兼容 utility 直接报错,适合 CI 或严格校验场景。
85+
- `silent`:跳过不兼容 utility,但不输出提示。
86+
87+
其中 `space-x-*` / `space-y-*` 不再继续输出非法兄弟组合器选择器,而是在 `uvue` 模板转换阶段对静态直接子节点展开为额外 class,并通过 `@apply ml-* / mt-*` 注入兼容样式。若同一静态容器 class 中同时出现 `space-x-reverse` / `space-y-reverse`,则会展开为 `mr-*` / `mb-*`
88+
89+
示例:
90+
91+
```ts
92+
import { uniAppX } from 'weapp-tailwindcss/presets'
93+
94+
export default uniAppX({
95+
base: __dirname,
96+
rem2rpx: true,
97+
uniAppX: {
98+
uvueUnsupported: 'warn',
99+
},
100+
})
101+
```
102+
77103
## Contribute
78104

79105
我们邀请你来贡献和帮助改进 `weapp-tailwindcss` 💚💚💚

0 commit comments

Comments
 (0)