diff --git a/docs-vitepress/api/compile.md b/docs-vitepress/api/compile.md
index 2653d6b867..502e9c2816 100644
--- a/docs-vitepress/api/compile.md
+++ b/docs-vitepress/api/compile.md
@@ -1685,6 +1685,8 @@ module.exports = defineConfig({
配置需要扫描的文件目录
+只有命中 `scan` 规则的模板会由 UnoCSS 扫描和转换。未命中规则的模板不会处理 UnoCSS 特殊类名;其中 `wx:class` 对象字面量的 key 仍需满足小程序 WXS 的标识符限制。
+
```js
// vue.config.js
const { defineConfig } = require('@vue/cli-service')
@@ -1701,34 +1703,6 @@ module.exports = defineConfig({
})
```
-### escapeMap
-
-`object`
-
-针对原子类中出现的`[` `(` `,`等特殊字符,在web中会通过转义字符`\`进行转义,由于小程序环境下不支持css选择器中出现`\`转义字符,我们内置支持了一套不带`\`的转义规则对这些特殊字符进行转义,同时替换模版和css文件中的类名,内建的默认转义规则,可自定义转译规则
-```js
-// vue.config.js
-const { defineConfig } = require('@vue/cli-service')
-module.exports = defineConfig({
- pluginOptions: {
- mpx: {
- unocss: {
- escapeMap: {
- ':': '_d_',
- }
- }
- }
- }
-})
-```
-```css
-
-```
-将会转化为
-```css
- .dark .dark_d_text-green-400{--un-text-opacity:1;color:rgba(74,222,128,var(--un-text-opacity));}
-```
-
### root
`string = process.cwd()`
diff --git a/docs-vitepress/articles/2.9-release-alter.md b/docs-vitepress/articles/2.9-release-alter.md
index 31826b7463..23696890b1 100644
--- a/docs-vitepress/articles/2.9-release-alter.md
+++ b/docs-vitepress/articles/2.9-release-alter.md
@@ -84,7 +84,7 @@ const escapeMap = {
}
```
-与此同时,用户也可以通过传递 `@mpxjs/unocss-plugin` 的 [escapeMap](../api/compile.md#escapemap) 配置项来覆盖内建的转义规则。
+Mpx 2.9 发布时支持通过 `@mpxjs/unocss-plugin` 的 `escapeMap` 配置项覆盖内建转义规则。当前版本已移除该配置,不再支持自定义转义规则;升级后请删除项目中的 `escapeMap` 配置。
#### 原子类分包输出 {#atomic-class-subpackage}
@@ -489,4 +489,3 @@ function render(){
Github:https://github.com/didi/mpx
官网/文档:https://mpxjs.cn/
-
diff --git a/docs-vitepress/articles/2.9-release.md b/docs-vitepress/articles/2.9-release.md
index 6b41c0d908..12472e8fc8 100644
--- a/docs-vitepress/articles/2.9-release.md
+++ b/docs-vitepress/articles/2.9-release.md
@@ -84,7 +84,7 @@ const escapeMap = {
}
```
-与此同时,用户也可以通过传递 `@mpxjs/unocss-plugin` 的 [escapeMap](../api/compile.md#escapemap) 配置项来覆盖内建的转义规则。
+Mpx 2.9 发布时支持通过 `@mpxjs/unocss-plugin` 的 `escapeMap` 配置项覆盖内建转义规则。当前版本已移除该配置,不再支持自定义转义规则;升级后请删除项目中的 `escapeMap` 配置。
#### 原子类分包输出 {#atomic-class-subpackage}
@@ -490,4 +490,3 @@ function render(){
Github:https://github.com/didi/mpx
官网/文档:https://mpxjs.cn/
-
diff --git a/docs-vitepress/guide/advance/utility-first-css.md b/docs-vitepress/guide/advance/utility-first-css.md
index eee16bb821..1646a75465 100644
--- a/docs-vitepress/guide/advance/utility-first-css.md
+++ b/docs-vitepress/guide/advance/utility-first-css.md
@@ -299,7 +299,7 @@ plugins.push(new MpxUnocssPlugin())
基于`unocss`的原子类支持`value auto-infer`(值自动推导),可以在模版中根据相关规则书写灵活的自定义值原子类,如`p-5px bg-[hsl(211.7,81.9%,69.6%)]`等,针对原子类中出现的`[` `(` `,`等特殊字符,在web中会通过转义字符`\`进行转义,由于小程序环境下不支持css选择器中出现`\`转义字符,我们内置支持了一套不带`\`的转义规则对这些特殊字符进行转义,同时替换模版和css文件中的类名,内建的默认转义规则如下:
```js
-const escapeMap = {
+const classEscapeMap = {
'(': '_pl_',
')': '_pr_',
'[': '_bl_',
@@ -316,13 +316,15 @@ const escapeMap = {
'\'': '_q_',
'"': '_dq_',
'+': '_a_',
- $: '_si_',
- // unknown用于兜底不在上述范围中未知的转义字符
- unknown: '_u_'
+ $: '_si_'
}
```
-与此同时,用户也可以通过传递`@mpxjs/unocss-plugin`的[`escapeMap`配置项](../../api/compile.md#escapemap)来覆盖内建的转义规则。
+该转义规则同时用于编译产物与运行时处理,为保证两端结果一致,不支持自定义。对于映射表以外的特殊字符,能够被 UnoCSS 规则正常处理的类名会使用内建兜底规则转义;未被 UnoCSS 处理的类名会输出编译错误。
+
+模板命中 `@mpxjs/unocss-plugin` 的 `scan` 规则时,`wx:class` 对象字面量中的 key 会和静态 `class` 使用相同的转义规则。插件会在扫描模板时将转义后的 key 转换为小程序可用的标识符,无法转换为合法标识符时会输出编译错误。
+
+未使用 `@mpxjs/unocss-plugin` 或模板未命中 `scan` 规则时,`wx:class` 对象字面量的 key 仅支持合法标识符以及包含空格或 `-` 的类名,其他无法转换为合法标识符的特殊字符会输出编译错误。如需在这类模板中使用 UnoCSS 特殊类名,需要通过 `scan.include` 将对应文件加入扫描范围。
### 原子类分包输出 {#subpackage}
@@ -368,4 +370,3 @@ const escapeMap = {
-
diff --git a/packages/unocss-base/utils/index.js b/packages/unocss-base/utils/index.js
index 73b9cd7ccc..8bb1805814 100644
--- a/packages/unocss-base/utils/index.js
+++ b/packages/unocss-base/utils/index.js
@@ -17,7 +17,11 @@ const ruleCallback = ([match], { generator }) => {
}
const ruleFallback = (match, generator) => {
- generator.blocked.add(match)
+ if (generator.blockTokens) {
+ generator.blockTokens([match])
+ } else {
+ generator.blocked.add(match)
+ }
return EMPTY
}
diff --git a/packages/unocss-plugin/AGENTS.md b/packages/unocss-plugin/AGENTS.md
index bd6ee96a94..a92f188eeb 100644
--- a/packages/unocss-plugin/AGENTS.md
+++ b/packages/unocss-plugin/AGENTS.md
@@ -13,7 +13,7 @@ Mpx 与 UnoCSS 的集成插件:在小程序构建中扫描 wxml/mpx 模板提
- 装配 unocss generator(基于 `@unocss/core` + `@unocss/config`)。
- 通过 `MpxWebpackPlugin` 钩子在小程序产物 emit 阶段扫描 wxml 资产,调用 [parser.js](lib/parser.js) 提取 class,生成新增的 wxss 资产。
- 集成 `transformerDirectives` / `transformerVariantGroup`,在样式 transform 阶段调用 [transform.js](lib/transform.js)。
-- [lib/parser.js](lib/parser.js):`parseClasses` / `parseStrings` / `parseMustache` / `stringifyAttr` / `parseComments` / `parseCommentConfig`,从模板/字符串/注释中提取 class 与配置。
+- [lib/parser.js](lib/parser.js):`parseClasses` / `parseClassExpression` / `parseMustache` / `stringifyAttr` / `parseComments` / `parseCommentConfig`,从模板、表达式与注释中提取 class 和配置。
- [lib/transform.js](lib/transform.js):`transformStyle` / `buildAliasTransformer` / `transformGroups` / `mpEscape` / `cssRequiresTransform`,处理 unocss → 小程序 wxss 的转义(class 名转义、伪类、组合器等)。
- [lib/source.js](lib/source.js):`getReplaceSource` / `getConcatSource` / `getRawSource`,统一封装 webpack `Source` 对象的创建。
- [lib/platform.js](lib/platform.js):各小程序平台的 preflights / 选择器映射表(被主插件按 `mpx_mode` 取用)。
diff --git a/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap b/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap
index dcf6cc62ba..25bf682731 100644
--- a/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap
+++ b/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap
@@ -17,14 +17,14 @@ exports[`test plugin test-template 3`] = `
.text-12px{font-size:12px;}"
`;
-exports[`test plugin test-template 4`] = `""`;
+exports[`test plugin test-template 4`] = `""`;
exports[`test plugin test-template 5`] = `
[
"translate-[-50%]",
+ "bg-#fff/10",
"text-12px",
"text-16px",
- "bg-#fff/10",
]
`;
diff --git a/packages/unocss-plugin/__tests__/dynamic-class.test.js b/packages/unocss-plugin/__tests__/dynamic-class.test.js
new file mode 100644
index 0000000000..9fe978a9d0
--- /dev/null
+++ b/packages/unocss-plugin/__tests__/dynamic-class.test.js
@@ -0,0 +1,117 @@
+import { jest } from '@jest/globals'
+import compiler from '@mpxjs/webpack-plugin/lib/template-compiler/compiler.js'
+import { createGenerator } from '@unocss/core'
+import MpxUnocssPlugin from '../lib/index.js'
+import { parseClassExpression } from '../lib/parser.js'
+import { getRawSource } from '../lib/source.js'
+
+describe('dynamic class object keys', () => {
+ const plugin = new MpxUnocssPlugin({ config: {} })
+
+ async function transformTemplate (content, errors, rules = []) {
+ const uno = await createGenerator({ rules })
+ const parseTemplate = plugin.getTemplateParser(uno)
+ const classes = []
+ const { newsource } = await parseTemplate(getRawSource(content), (className) => {
+ if (className) classes.push(className)
+ return className
+ }, error => errors.push(error))
+ return {
+ output: newsource.source(),
+ classes
+ }
+ }
+
+ test('parses strings and nested non-computed object keys by syntax', () => {
+ const result = parseClassExpression("({ \"foo'bar\": flag, [dynamic]: 'computed', nested: { 'hover:bg-red-100': flag }, active: flag ? 'text-red-500' : \"text-gray-500\" })")
+
+ expect(result.objectKeys.map(key => key.result)).toEqual(["foo'bar", 'nested', 'hover:bg-red-100', 'active'])
+ expect(result.strings.map(string => string.result)).toEqual(['computed', 'text-red-500', 'text-gray-500'])
+ })
+
+ test('preserves the original condition variable for escaped shorthand properties', async () => {
+ const errors = []
+ const { output } = await transformTemplate('', errors)
+
+ expect(output).toMatch(/_si_active:\s*\$active/)
+ expect(output).not.toMatch(/\{\s*_si_active\s*[,}]/)
+ expect(errors).toEqual([])
+ })
+
+ test('uses the same escaping for static and dynamic class names', async () => {
+ const templateErrors = []
+ const pluginErrors = []
+ const parsed = compiler.parse('', {
+ mode: 'wx',
+ srcMode: 'wx',
+ defs: {},
+ usingComponentsInfo: {},
+ externalClasses: [],
+ isUnoCSSScanFile: true,
+ warn: jest.fn(),
+ error: error => templateErrors.push(error)
+ })
+ const compiledTemplate = compiler.serialize(parsed.root)
+ const { output, classes } = await transformTemplate(compiledTemplate, pluginErrors)
+
+ expect(templateErrors).toEqual([])
+ expect(pluginErrors).toEqual([])
+ expect(plugin.options).not.toHaveProperty('escapeMap')
+ expect(compiledTemplate).toContain('"hover:bg-red-100": flag')
+ expect(classes).toEqual(expect.arrayContaining(['text-24rpx', 'hover:bg-blue-100', 'hover:bg-red-100']))
+ expect(output).toContain('"text-24rpx hover_c_bg-blue-100"')
+ expect(output).toMatch(/hover_c_bg_da_red_da_100MpxEscape:\s*flag/)
+ })
+
+ test('matches template resource paths against scan rules', () => {
+ const scanPlugin = new MpxUnocssPlugin({
+ root: '/project',
+ config: {},
+ scan: {
+ include: ['src/**/*'],
+ exclude: ['src/excluded/**/*']
+ }
+ })
+
+ expect(scanPlugin.isUnoCSSScanFile('/project/src/pages/index.mpx')).toBe(true)
+ expect(scanPlugin.isUnoCSSScanFile('/project/src/excluded/index.mpx')).toBe(false)
+ expect(scanPlugin.isUnoCSSScanFile('/project/packages/component.mpx')).toBe(false)
+ })
+
+ test('allows configured classes containing special characters', async () => {
+ const errors = []
+ const { output } = await transformTemplate(
+ '',
+ errors,
+ [
+ [/^custom@(red|blue)$/, () => ({ color: 'red' })]
+ ]
+ )
+
+ expect(output).toContain('class="custom_u_blue"')
+ expect(output).toMatch(/custom_u_red:\s*flag/)
+ expect(errors).toEqual([])
+ })
+
+ test('reports unhandled static class names containing unsupported characters', async () => {
+ const errors = []
+ const { output } = await transformTemplate('', errors)
+
+ expect(output).toContain('class="qwe_u_da _u_asd"')
+ expect(errors).toEqual([
+ 'Classname [qwe@da] contains unsupported character [@].',
+ 'Classname [*asd] contains unsupported character [*].'
+ ])
+ })
+
+ test('reports class object keys that can not become valid identifiers', async () => {
+ const errors = []
+ const { output } = await transformTemplate('', errors)
+
+ expect(output).toContain("'custom😀red': flag")
+ expect(errors).toEqual([
+ 'Dynamic classname [custom😀red] can not be escaped as a valid identifier, which is not supported.',
+ 'Dynamic classname [12] can not be escaped as a valid identifier, which is not supported.'
+ ])
+ })
+})
diff --git a/packages/unocss-plugin/__tests__/plugin.test.js b/packages/unocss-plugin/__tests__/plugin.test.js
index 6738b92c0f..ad1535ff20 100644
--- a/packages/unocss-plugin/__tests__/plugin.test.js
+++ b/packages/unocss-plugin/__tests__/plugin.test.js
@@ -1,7 +1,6 @@
import MpxUnocssPlugin from '../lib/index.js'
import { getRawSource } from '../lib/source.js'
-import { createGenerator, e as cssEscape } from '@unocss/core'
-import { mpEscape } from '../lib/transform.js'
+import { createGenerator } from '@unocss/core'
import presetMpx from '@mpxjs/unocss-base/lib/index.js'
import { getClassMap } from '@mpxjs/webpack-plugin/lib/react/style-helper.js'
import { jest } from '@jest/globals'
@@ -41,12 +40,12 @@ describe('test plugin', () => {
}) {
const source = getRawSource(content)
const classmap = {}
- const { newsource } = parseTemplate(source, (className) => {
+ const { newsource } = await parseTemplate(source, (className) => {
if (!className) {
return className
}
classmap[className] = true
- return mpEscape(cssEscape(className), plugin.options.escapeMap)
+ return className
})
// 测试模板是否转义
expect(newsource.source()).toMatchSnapshot()
@@ -72,7 +71,7 @@ describe('test plugin', () => {
expect(result.css).toContain('.box-border{box-sizing:border-box;}')
expect(result.css).toContain('.box-content{box-sizing:content-box;}')
- expect(uno.blocked).not.toContain('box-content')
+ expect(uno.isBlocked('box-content')).toBe(false)
} finally {
if (targetMode === undefined) {
delete process.env.MPX_CURRENT_TARGET_MODE
@@ -87,8 +86,10 @@ describe('test plugin', () => {
process.env.MPX_CURRENT_TARGET_MODE = 'ios'
try {
+ const classList = ['transition-opacity', 'duration-300', 'ease-in-out', 'delay-150', 'transition', 'transition-1', 'transition-all', 'transition-all-1', 'transition-all-foo', 'transition-colors', 'transition-[opacity,transform]']
const uno = await createReactGenerator()
- const result = await uno.generate(['transition-opacity', 'duration-300', 'ease-in-out', 'delay-150', 'transition', 'transition-1', 'transition-all', 'transition-all-1', 'transition-all-foo', 'transition-colors', 'transition-[opacity,transform]'], { preflights: false })
+ const result = await uno.generate(classList, { preflights: false })
+ const blocked = classList.filter(className => uno.isBlocked(className))
expect(result.css).toContain('transition-property:opacity;')
expect(result.css).toContain('transition-duration:300ms;')
@@ -96,8 +97,8 @@ describe('test plugin', () => {
expect(result.css).toContain('transition-delay:150ms;')
expect(result.css).toContain('transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;')
expect(result.css).toContain('transition-property:opacity,transform;')
- expect([...uno.blocked]).toEqual(expect.arrayContaining(['transition', 'transition-1', 'transition-all', 'transition-all-1']))
- expect([...uno.blocked]).toEqual(expect.not.arrayContaining(['transition-all-foo', 'transition-opacity', 'duration-300', 'ease-in-out', 'delay-150', 'transition-colors', 'transition-[opacity,transform]']))
+ expect(blocked).toEqual(expect.arrayContaining(['transition', 'transition-1', 'transition-all', 'transition-all-1']))
+ expect(blocked).toEqual(expect.not.arrayContaining(['transition-all-foo', 'transition-opacity', 'duration-300', 'ease-in-out', 'delay-150', 'transition-colors', 'transition-[opacity,transform]']))
} finally {
if (targetMode === undefined) {
delete process.env.MPX_CURRENT_TARGET_MODE
diff --git a/packages/unocss-plugin/__tests__/rn-plugin.test.js b/packages/unocss-plugin/__tests__/rn-plugin.test.js
index dd2c540318..8357d92642 100644
--- a/packages/unocss-plugin/__tests__/rn-plugin.test.js
+++ b/packages/unocss-plugin/__tests__/rn-plugin.test.js
@@ -4,6 +4,11 @@ describe('react native plugin', () => {
test('generates class map from UnoCSS layers', async () => {
let compilationCallback
let optimizeAssets
+ const blockedTokens = []
+ const isBlocked = token => {
+ blockedTokens.push(token)
+ return false
+ }
const compiler = {
options: {
module: {
@@ -24,7 +29,7 @@ describe('react native plugin', () => {
__unoCtx: {
transformCache: new Map(),
uno: {
- blocked: new Set(),
+ isBlocked,
generate: async () => ({
layers: ['default'],
getLayers: layers => layers.includes('default') ? '.text-red-500{color:red;}' : ''
@@ -61,6 +66,7 @@ describe('react native plugin', () => {
await optimizeAssets()
expect(compilation.assets['app.js'].source()).toContain('["text-red-500"]: function(_f){return {\'color\':"red"};}')
+ expect(blockedTokens).toEqual(['text-red-500'])
expect(compilation.errors).toEqual([])
})
})
diff --git a/packages/unocss-plugin/lib/index.js b/packages/unocss-plugin/lib/index.js
index e25d264b11..3c216677f1 100644
--- a/packages/unocss-plugin/lib/index.js
+++ b/packages/unocss-plugin/lib/index.js
@@ -1,10 +1,12 @@
import MpxWebpackPlugin from '@mpxjs/webpack-plugin'
import mpxConfig from '@mpxjs/webpack-plugin/lib/config.js'
import env from '@mpxjs/webpack-plugin/lib/utils/env.js'
+import escapeObjectKey from '@mpxjs/webpack-plugin/lib/utils/escape-class-object-key.js'
import fixRelative from '@mpxjs/webpack-plugin/lib/utils/fix-relative.js'
import parseRequest from '@mpxjs/webpack-plugin/lib/utils/parse-request.js'
import set from '@mpxjs/webpack-plugin/lib/utils/set.js'
import toPosix from '@mpxjs/webpack-plugin/lib/utils/to-posix.js'
+import isValidIdentifierStr from '@mpxjs/webpack-plugin/lib/utils/is-valid-identifier-str.js'
import { loadConfig } from '@unocss/config'
import { createGenerator, e as cssEscape } from '@unocss/core'
import transformerDirectives from '@unocss/transformer-directives'
@@ -13,10 +15,10 @@ import { minimatch } from 'minimatch'
import * as path from 'path'
import {
parseClasses,
+ parseClassExpression,
parseCommentConfig,
parseComments,
parseMustache,
- parseStrings,
stringifyAttr
} from './parser.js'
import platformPreflightsMap from './platform.js'
@@ -90,7 +92,6 @@ function normalizeOptions (options) {
'src/**/*'
]
},
- escapeMap = {},
// 公共的配置
root = process.cwd(),
config,
@@ -114,28 +115,6 @@ function normalizeOptions (options) {
...webOptions
}
- escapeMap = {
- '(': '_pl_',
- ')': '_pr_',
- '[': '_bl_',
- ']': '_br_',
- '{': '_cl_',
- '}': '_cr_',
- '#': '_h_',
- '!': '_i_',
- '/': '_s_',
- '.': '_d_',
- ':': '_c_',
- ',': '_2c_',
- '%': '_p_',
- '\'': '_q_',
- '"': '_dq_',
- '+': '_a_',
- $: '_si_',
- unknown: '_u_',
- ...escapeMap
- }
-
scan.include = normalizeRules(scan.include, root)
scan.exclude = normalizeRules(scan.exclude, root)
@@ -144,7 +123,6 @@ function normalizeOptions (options) {
styleIsolation,
minCount,
scan,
- escapeMap,
root,
config,
configFiles,
@@ -189,12 +167,12 @@ function getPlugin (compiler, curPlugin) {
class MpxUnocssPlugin {
constructor (options = {}) {
this.options = normalizeOptions(options)
+ this.isUnoCSSScanFile = file => filterFile(toPosix(file), this.options.scan)
}
async generateStyle (uno, classes = [], options = {}) {
- const tokens = new Set(classes)
- const result = await uno.generate(tokens, options)
- return mpEscape(result.css, this.options.escapeMap)
+ const result = await uno.generate(new Set(classes), options)
+ return mpEscape(result.css)
}
getSafeListClasses (safelist) {
@@ -236,7 +214,7 @@ class MpxUnocssPlugin {
getTemplateParser (uno) {
// process classes
const transformAlias = buildAliasTransformer(uno.config.alias)
- const transformClasses = (source, classNameHandler = c => c) => {
+ const transformClasses = (source, classNameHandler, unknownClassChars) => {
// pre process
source = transformAlias(source)
if (this.options.transformGroups) {
@@ -244,25 +222,60 @@ class MpxUnocssPlugin {
}
const content = source.source()
// escape & fill classesMap
- return content.split(/\s+/).map(classNameHandler).join(' ')
+ return content.split(/\s+/).map((className) => {
+ return mpEscape(cssEscape(classNameHandler(className)), (char) => {
+ let chars = unknownClassChars.get(className)
+ if (!chars) {
+ chars = new Set()
+ unknownClassChars.set(className, chars)
+ }
+ chars.add(char)
+ })
+ }).join(' ')
}
- return (source, classNameHandler) => {
+ return async (source, classNameHandler = c => c, error) => {
+ // 单个模板内先去重,再由 UnoCSS 判断包含未知字符的类名是否有效
+ const unknownClassChars = new Map()
source = getReplaceSource(source)
const content = source.original().source()
parseClasses(content).forEach(({ result, start, end }) => {
let { replaced, val } = parseMustache(result, (exp) => {
const expSource = getReplaceSource(exp)
- parseStrings(exp).forEach(({ result, start, end }) => {
- result = transformClasses(result, classNameHandler)
+ const { strings, objectKeys } = parseClassExpression(exp)
+ strings.forEach(({ result, start, end }) => {
+ result = transformClasses(result, classNameHandler, unknownClassChars)
expSource.replace(start, end, result)
})
+ objectKeys.forEach(({ result, start, end, shorthand }) => {
+ if (typeof result !== 'string') {
+ error && error(`Dynamic classname [${result}] can not be escaped as a valid identifier, which is not supported.`)
+ return
+ }
+ const className = transformClasses(result, classNameHandler, unknownClassChars)
+ const propertyName = escapeObjectKey(className)
+ if (!isValidIdentifierStr(propertyName)) {
+ error && error(`Dynamic classname [${result}] can not be escaped as a valid identifier, which is not supported.`)
+ } else {
+ const replacement = shorthand && propertyName !== result
+ ? `${propertyName}: ${result}`
+ : propertyName
+ expSource.replace(start, end, replacement)
+ }
+ })
return expSource.source()
- }, str => transformClasses(str, classNameHandler))
+ }, str => transformClasses(str, classNameHandler, unknownClassChars))
if (replaced) {
val = stringifyAttr(val)
source.replace(start - 1, end + 1, val)
}
})
+ await Promise.all(Array.from(unknownClassChars).map(async ([className, chars]) => {
+ if (!await uno.parseToken(className)) {
+ chars.forEach((char) => {
+ error && error(`Classname [${className}] contains unsupported character [${char}].`)
+ })
+ }
+ }))
// process comments
const commentConfig = {}
parseComments(content).forEach(({ result, start, end }) => {
@@ -314,6 +327,7 @@ class MpxUnocssPlugin {
}, (compilation) => {
const { __mpx__: mpx } = compilation
mpx.hasUnoCSS = true
+ mpx.isUnoCSSScanFile = this.isUnoCSSScanFile
if (isWeb(mode) || isReact(mode)) return
compilation.hooks.processAssets.tapPromise({
name: PLUGIN_NAME,
@@ -392,9 +406,9 @@ class MpxUnocssPlugin {
} else if (!mainClassesMap[className]) {
currentClassesMap[className] = true
}
- return mpEscape(cssEscape(className), this.options.escapeMap)
+ return className
}
- const { newsource, commentConfig } = parseTemplate(source, classNameHandler)
+ const { newsource, commentConfig } = await parseTemplate(source, classNameHandler, error)
commentConfigMap[filename] = commentConfig
assets[file] = newsource
}
@@ -404,8 +418,8 @@ class MpxUnocssPlugin {
const assetModules = assetsModulesMap.get(file)
if (assetModules && has(assetModules, (module) => {
if (module.resource) {
- const resourcePath = toPosix(parseRequest(module.resource).resourcePath)
- return filterFile(resourcePath, this.options.scan)
+ const resourcePath = parseRequest(module.resource).resourcePath
+ return this.isUnoCSSScanFile(resourcePath)
}
return false
})) {
@@ -450,8 +464,8 @@ class MpxUnocssPlugin {
// isolated模式下无需全局样式注入
dynamicEntryInfo.main && dynamicEntryInfo.main.entries.forEach(({ entryType, filename, resource }) => {
if (entryType === 'page' || entryType === 'component') {
- const resourcePath = toPosix(parseRequest(resource).resourcePath)
- if (filterFile(resourcePath, this.options.scan)) {
+ const resourcePath = parseRequest(resource).resourcePath
+ if (this.isUnoCSSScanFile(resourcePath)) {
const entryStyleFile = filename + styleExt
const mainRelativePath = fixRelative(toPosix(path.relative(path.dirname(entryStyleFile), mainUnoFile)), mode)
const entryStyleSource = getConcatSource(`@import ${JSON.stringify(mainRelativePath)};\n`)
@@ -504,8 +518,8 @@ class MpxUnocssPlugin {
if (this.options.styleIsolation === 'isolated') {
// isolated模式下无需全局样式注入
if (entryType === 'page' || entryType === 'component') {
- const resourcePath = toPosix(parseRequest(resource).resourcePath)
- if (filterFile(resourcePath, this.options.scan)) {
+ const resourcePath = parseRequest(resource).resourcePath
+ if (this.isUnoCSSScanFile(resourcePath)) {
const entryStyleFile = filename + styleExt
const entryStyleSource = getConcatSource('')
// 独立分包中的页面和组件无需引入mainUnoFile
diff --git a/packages/unocss-plugin/lib/parse-class-expression.js b/packages/unocss-plugin/lib/parse-class-expression.js
new file mode 100644
index 0000000000..c19d588603
--- /dev/null
+++ b/packages/unocss-plugin/lib/parse-class-expression.js
@@ -0,0 +1,58 @@
+import parser from '@babel/parser'
+import traverseModule from '@babel/traverse'
+import types from '@babel/types'
+
+const traverse = traverseModule.default
+
+/**
+ * 解析 class 表达式中的普通字符串和非计算对象 key,并保留其源码偏移。
+ *
+ * @param {string} expr
+ * @returns {{
+ * strings: Array<{result: string, start: number, end: number}>,
+ * objectKeys: Array<{result: unknown, start: number, end: number, shorthand: boolean}>
+ * }}
+ */
+export default function parseClassExpression (expr) {
+ const result = {
+ strings: [],
+ objectKeys: []
+ }
+ if (!expr) return result
+ try {
+ const expression = parser.parseExpression(expr, {
+ plugins: [
+ 'objectRestSpread'
+ ]
+ })
+ const ast = types.file(types.program([types.expressionStatement(expression)]))
+ traverse(ast, {
+ ObjectProperty (path) {
+ const property = path.node
+ if (!property.computed) {
+ result.objectKeys.push({
+ result: types.isIdentifier(property.key) ? property.key.name : property.key.value,
+ start: property.key.start,
+ end: property.key.end - 1,
+ shorthand: property.shorthand
+ })
+ }
+ },
+ StringLiteral (path) {
+ const node = path.node
+ const propertyPath = path.findParent(path => path.isObjectProperty())
+ if (propertyPath) {
+ const key = propertyPath.node.key
+ if (node.start >= key.start && node.end <= key.end) return
+ }
+ result.strings.push({
+ result: node.value,
+ start: node.start + 1,
+ end: node.end - 2
+ })
+ }
+ })
+ } catch (e) {
+ }
+ return result
+}
diff --git a/packages/unocss-plugin/lib/parser.js b/packages/unocss-plugin/lib/parser.js
index 3966a9ce99..ec3390be3e 100644
--- a/packages/unocss-plugin/lib/parser.js
+++ b/packages/unocss-plugin/lib/parser.js
@@ -1,4 +1,5 @@
import { parseMustache, stringifyAttr } from '@mpxjs/webpack-plugin/lib/template-compiler/compiler.js'
+import parseClassExpression from './parse-class-expression.js'
function parseClasses (content) {
const output = []
@@ -59,28 +60,9 @@ function parseCommentConfig (content) {
return result
}
-function parseStrings (content) {
- const output = []
- if (!content) { return output }
- const regex = /'[^']*'|"[^"]*"/gm
- let match
- while (match = regex.exec(content)) {
- const raw = match[0]
- const value = raw.slice(1, -1)
- const end = regex.lastIndex - 2
- const start = regex.lastIndex - 1 - value.length
- output.push({
- result: value,
- start,
- end
- })
- }
- return output
-}
-
export {
parseClasses,
- parseStrings,
+ parseClassExpression,
parseComments,
parseCommentConfig,
parseMustache,
diff --git a/packages/unocss-plugin/lib/rn-plugin/index.js b/packages/unocss-plugin/lib/rn-plugin/index.js
index 4e394c0a18..1051cb233d 100644
--- a/packages/unocss-plugin/lib/rn-plugin/index.js
+++ b/packages/unocss-plugin/lib/rn-plugin/index.js
@@ -60,8 +60,9 @@ function WebpackPlugin (configOrPath, defaults) {
}
}
const result = await uno.generate(tokens, { minify: true })
- if (uno.blocked.size) {
- compilation.errors.push(`[Mpx Unocss]: all those '${[...uno.blocked].join(', ')}' class utilities is not supported in react native mode`)
+ const blocked = [...tokens].filter(token => uno.isBlocked(token))
+ if (blocked.length) {
+ compilation.errors.push(`[Mpx Unocss]: all those '${blocked.join(', ')}' class utilities is not supported in react native mode`)
}
const getLayersClassMap = (layers) => {
diff --git a/packages/unocss-plugin/lib/transform.js b/packages/unocss-plugin/lib/transform.js
index ea574581fe..acfea81ca9 100644
--- a/packages/unocss-plugin/lib/transform.js
+++ b/packages/unocss-plugin/lib/transform.js
@@ -2,12 +2,32 @@ import MagicString from 'magic-string'
import transformerDirectives from '@unocss/transformer-directives' // default
import { getReplaceSource } from './source.js'
const escapedReg = /\\(.)/g
+const mpEscapeMap = {
+ '(': '_pl_',
+ ')': '_pr_',
+ '[': '_bl_',
+ ']': '_br_',
+ '{': '_cl_',
+ '}': '_cr_',
+ '#': '_h_',
+ '!': '_i_',
+ '/': '_s_',
+ '.': '_d_',
+ ':': '_c_',
+ ',': '_2c_',
+ '%': '_p_',
+ '\'': '_q_',
+ '"': '_dq_',
+ '+': '_a_',
+ $: '_si_'
+}
-function mpEscape (str, escapeMap = {}) {
+function mpEscape (str, onUnknown) {
return str.replace(escapedReg, (_, p1) => {
- if (escapeMap[p1]) return escapeMap[p1]
+ if (mpEscapeMap[p1]) return mpEscapeMap[p1]
+ onUnknown && onUnknown(p1)
// unknown escaped
- return escapeMap.unknown
+ return '_u_'
})
}
diff --git a/packages/unocss-plugin/package.json b/packages/unocss-plugin/package.json
index 14bdff8e5b..bc718be1da 100644
--- a/packages/unocss-plugin/package.json
+++ b/packages/unocss-plugin/package.json
@@ -19,6 +19,9 @@
},
"dependencies": {
"@ampproject/remapping": "^2.2.1",
+ "@babel/parser": "^7.16.2",
+ "@babel/traverse": "^7.16.0",
+ "@babel/types": "^7.16.0",
"@rollup/pluginutils": "^5.0.2",
"@unocss/config": "66.7.5",
"@unocss/core": "66.7.5",
diff --git a/packages/webpack-plugin/lib/global.d.ts b/packages/webpack-plugin/lib/global.d.ts
index c629c8feeb..aa7eb7c3f0 100644
--- a/packages/webpack-plugin/lib/global.d.ts
+++ b/packages/webpack-plugin/lib/global.d.ts
@@ -79,6 +79,16 @@ declare global {
*/
dynamicEntryInfo: Record
+ /**
+ * 是否使用 UnoCSS
+ */
+ hasUnoCSS?: boolean
+
+ /**
+ * 当前资源是否命中 UnoCSS scan 规则
+ */
+ isUnoCSSScanFile?: (file: string) => boolean
+
/**
* 记录 entryModule 与 entryNode 的对应关系,用于体积分析
*/
diff --git a/packages/webpack-plugin/lib/template-compiler/compiler.js b/packages/webpack-plugin/lib/template-compiler/compiler.js
index 8a6c5fb2f0..257a2f1d99 100644
--- a/packages/webpack-plugin/lib/template-compiler/compiler.js
+++ b/packages/webpack-plugin/lib/template-compiler/compiler.js
@@ -107,6 +107,7 @@ let isNative
let hasScoped
let hasVirtualHost
let isCustomText
+let isUnoCSSScanFile
let runtimeCompile
let rulesRunner
let customBuiltInComponentsOpt
@@ -640,6 +641,7 @@ function parse (template, options) {
hasScoped = options.hasScoped
hasVirtualHost = options.hasVirtualHost
isCustomText = options.isCustomText
+ isUnoCSSScanFile = options.isUnoCSSScanFile
filePath = options.filePath
i18n = options.i18n
runtimeCompile = options.runtimeCompile
@@ -2420,9 +2422,12 @@ function processClass (el, meta) {
staticClass = staticClass.replace(/\s+/g, ' ')
if (dynamicClass) {
const staticClassExp = parseMustacheWithContext(staticClass).result
- const dynamicClassExp = transDynamicClassExpr(parseMustacheWithContext(dynamicClass).result, {
- error: error$1
- })
+ let dynamicClassExp = parseMustacheWithContext(dynamicClass).result
+ if (!isUnoCSSScanFile) {
+ dynamicClassExp = transDynamicClassExpr(dynamicClassExp, {
+ error: error$1
+ })
+ }
addAttrs(el, [{
name: targetType,
// swan中externalClass是通过编译时静态实现,因此需要保留原有的staticClass形式避免externalClass失效
@@ -3499,9 +3504,12 @@ function processClassDynamic (el) {
staticClass = staticClass.replace(/\s+/g, ' ')
if (dynamicClass) {
const staticClassExp = parseMustacheWithContext(staticClass).result
- const dynamicClassExp = transDynamicClassExpr(parseMustacheWithContext(dynamicClass).result, {
- error: error$1
- })
+ let dynamicClassExp = parseMustacheWithContext(dynamicClass).result
+ if (!isUnoCSSScanFile) {
+ dynamicClassExp = transDynamicClassExpr(dynamicClassExp, {
+ error: error$1
+ })
+ }
addAttrs(el, [{
name: targetType,
value: `{{[${staticClassExp},${dynamicClassExp}]}}`
diff --git a/packages/webpack-plugin/lib/template-compiler/index.js b/packages/webpack-plugin/lib/template-compiler/index.js
index 55d8ac50e2..a388f442f6 100644
--- a/packages/webpack-plugin/lib/template-compiler/index.js
+++ b/packages/webpack-plugin/lib/template-compiler/index.js
@@ -18,6 +18,7 @@ module.exports = function (raw) {
const defs = mpx.defs
const i18n = mpx.i18n
const externalClasses = mpx.externalClasses
+ const isUnoCSSScanFile = !!(mpx.hasUnoCSS && mpx.isUnoCSSScanFile && mpx.isUnoCSSScanFile(resourcePath))
const decodeHTMLText = mpx.decodeHTMLText
const globalSrcMode = mpx.srcMode
const localSrcMode = queryObj.srcMode
@@ -69,6 +70,7 @@ module.exports = function (raw) {
defs,
decodeHTMLText,
externalClasses,
+ isUnoCSSScanFile,
hasScoped,
moduleId,
usingComponentsInfo,
diff --git a/packages/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr.js b/packages/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr.js
index ac61d261ed..cfe6c32a16 100644
--- a/packages/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr.js
+++ b/packages/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr.js
@@ -3,40 +3,7 @@ const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const isValidIdentifierStr = require('../utils/is-valid-identifier-str')
-const escapeReg = /[()[\]{}#!.:,%'"+$]/g
-const escapeMap = {
- '(': '_pl_',
- ')': '_pr_',
- '[': '_bl_',
- ']': '_br_',
- '{': '_cl_',
- '}': '_cr_',
- '#': '_h_',
- '!': '_i_',
- '/': '_s_',
- '.': '_d_',
- ':': '_c_',
- ',': '_2c_',
- '%': '_p_',
- "'": '_q_',
- '"': '_dq_',
- '+': '_a_',
- $: '_si_'
-}
-
-function mpEscape (str) {
- return str.replace(escapeReg, function (match) {
- if (escapeMap[match]) return escapeMap[match]
- // unknown escaped
- return '_u_'
- })
-}
-
-function keyEscape (str) {
- let result = str.replace(/-/g, '_da_').replace(/\s+/g, '_sp_')
- if (result !== str) result += 'MpxEscape'
- return result
-}
+const escapeObjectKey = require('../utils/escape-class-object-key')
module.exports = function transDynamicClassExpr (expr, { error } = {}) {
try {
@@ -50,7 +17,7 @@ module.exports = function transDynamicClassExpr (expr, { error } = {}) {
path.node.properties.forEach((property) => {
if (t.isObjectProperty(property) && !property.computed) {
const rawPropertyName = property.key.name || property.key.value
- const propertyName = keyEscape(mpEscape(rawPropertyName))
+ const propertyName = typeof rawPropertyName === 'string' ? escapeObjectKey(rawPropertyName) : ''
if (!isValidIdentifierStr(propertyName)) {
error && error(`Dynamic classname [${rawPropertyName}] can not be escaped as a valid identifier, which is not supported.`)
} else {
diff --git a/packages/webpack-plugin/lib/utils/escape-class-object-key.js b/packages/webpack-plugin/lib/utils/escape-class-object-key.js
new file mode 100644
index 0000000000..3504e138b2
--- /dev/null
+++ b/packages/webpack-plugin/lib/utils/escape-class-object-key.js
@@ -0,0 +1,9 @@
+const KEY_ESCAPE_SUFFIX = 'MpxEscape'
+
+function escapeObjectKey (str) {
+ const result = str.replace(/-/g, '_da_').replace(/\s+/g, '_sp_')
+ if (result !== str) return result + KEY_ESCAPE_SUFFIX
+ return str
+}
+
+module.exports = escapeObjectKey
diff --git a/packages/webpack-plugin/test/template-compiler/trans-dynamic-class-expr.spec.js b/packages/webpack-plugin/test/template-compiler/trans-dynamic-class-expr.spec.js
new file mode 100644
index 0000000000..0a91469459
--- /dev/null
+++ b/packages/webpack-plugin/test/template-compiler/trans-dynamic-class-expr.spec.js
@@ -0,0 +1,61 @@
+const compiler = require('../../lib/template-compiler/compiler')
+const transDynamicClassExpr = require('../../lib/template-compiler/trans-dynamic-class-expr')
+
+describe('dynamic class expression transform', () => {
+ test('only escapes spaces and dashes in object keys', () => {
+ const error = jest.fn()
+ const result = transDynamicClassExpr("({ active: flag, 'foo-bar baz': flag })", { error })
+
+ expect(result).toBe('{active:flag,foo_da_bar_sp_bazMpxEscape:flag}')
+ expect(error).not.toHaveBeenCalled()
+ })
+
+ test('reports object keys that can not become valid identifiers', () => {
+ const error = jest.fn()
+ const result = transDynamicClassExpr("({ 'hover:bg-red-100': flag, 'foo$bar': flag, 'custom@red': flag, 'foo*bar': flag, 1: flag })", { error })
+
+ expect(result).toContain("'hover:bg-red-100':flag")
+ expect(result).toContain('foo$bar:flag')
+ expect(result).toContain("'custom@red':flag")
+ expect(result).toContain("'foo*bar':flag")
+ expect(result).toContain('1:flag')
+ expect(error).toHaveBeenCalledTimes(4)
+ })
+
+ test('skips dynamic class expression transform for files matching UnoCSS scan', () => {
+ const errors = []
+ const parsed = compiler.parse('', {
+ mode: 'wx',
+ srcMode: 'wx',
+ defs: {},
+ usingComponentsInfo: {},
+ externalClasses: [],
+ isUnoCSSScanFile: true,
+ warn: jest.fn(),
+ error: error => errors.push(error)
+ })
+ const output = compiler.serialize(parsed.root)
+
+ expect(output).toContain('"foo-bar": flag')
+ expect(output).toMatch(/"hover:bg-red-100":\s*flag/)
+ expect(errors).toEqual([])
+ })
+
+ test('transforms dynamic class expressions for files not matching UnoCSS scan', () => {
+ const errors = []
+ const parsed = compiler.parse('', {
+ mode: 'wx',
+ srcMode: 'wx',
+ defs: {},
+ usingComponentsInfo: {},
+ externalClasses: [],
+ isUnoCSSScanFile: false,
+ warn: jest.fn(),
+ error: error => errors.push(error)
+ })
+ const output = compiler.serialize(parsed.root)
+
+ expect(output).toMatch(/"hover:bg-red-100":\s*flag/)
+ expect(errors).toHaveLength(1)
+ })
+})
diff --git a/packages/webpack-plugin/test/template-compiler/unocss-scan.spec.js b/packages/webpack-plugin/test/template-compiler/unocss-scan.spec.js
new file mode 100644
index 0000000000..2546360c8b
--- /dev/null
+++ b/packages/webpack-plugin/test/template-compiler/unocss-scan.spec.js
@@ -0,0 +1,71 @@
+const compiler = require('../../lib/template-compiler/compiler')
+const templateLoader = require('../../lib/template-compiler')
+
+describe('template compiler UnoCSS scan matching', () => {
+ let parse
+
+ beforeEach(() => {
+ parse = jest.spyOn(compiler, 'parse').mockReturnValue({
+ root: {},
+ meta: {
+ wxsModuleMap: {}
+ }
+ })
+ jest.spyOn(compiler, 'serialize').mockReturnValue('')
+ jest.spyOn(compiler, 'genNode').mockReturnValue('')
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ function compileTemplate (resource, isUnoCSSScanFile = jest.fn(file => file.startsWith('/project/src/') && !file.startsWith('/project/src/excluded/'))) {
+ const mpx = {
+ projectRoot: '/project',
+ mode: 'wx',
+ srcMode: 'wx',
+ defs: {},
+ externalClasses: [],
+ hasUnoCSS: true,
+ wxsContentMap: {},
+ optimizeRenderRules: [],
+ forceProxyEventRules: [],
+ autoVirtualHostRules: [],
+ checkUsingComponentsRules: [],
+ getModuleId: jest.fn(() => 'module-id')
+ }
+ if (isUnoCSSScanFile) mpx.isUnoCSSScanFile = isUnoCSSScanFile
+ const loaderContext = {
+ resource,
+ cacheable: jest.fn(),
+ getMpx: () => mpx,
+ emitError: jest.fn(),
+ emitWarning: jest.fn(),
+ emitFile: jest.fn()
+ }
+
+ templateLoader.call(loaderContext, '')
+
+ return {
+ isUnoCSSScanFile,
+ options: parse.mock.calls[0][1]
+ }
+ }
+
+ test.each([
+ ['/project/src/pages/index.mpx?type=template', true],
+ ['/project/src/excluded/index.mpx?type=template', false],
+ ['/project/packages/component.mpx?type=template', false]
+ ])('passes scan result for resource %s to compiler', (resource, expected) => {
+ const { isUnoCSSScanFile, options } = compileTemplate(resource)
+
+ expect(isUnoCSSScanFile).toHaveBeenCalledWith(resource.split('?')[0])
+ expect(options.isUnoCSSScanFile).toBe(expected)
+ })
+
+ test('defaults to false when UnoCSS does not expose a scan matcher', () => {
+ const { options } = compileTemplate('/project/src/pages/index.mpx?type=template', null)
+
+ expect(options.isUnoCSSScanFile).toBe(false)
+ })
+})