diff --git a/docs-vitepress/api/compile.md b/docs-vitepress/api/compile.md
index fb28db5b57..73a0538da0 100644
--- a/docs-vitepress/api/compile.md
+++ b/docs-vitepress/api/compile.md
@@ -1678,34 +1678,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/guide/advance/utility-first-css.md b/docs-vitepress/guide/advance/utility-first-css.md
index eee16bb821..fa37d5c44c 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` 时,`wx:class` 对象字面量中的 key 会和静态 `class` 使用相同的转义规则。插件会在扫描模板时将转义后的 key 转换为小程序可用的标识符,无法转换为合法标识符时会输出编译错误。
+
+未使用 `@mpxjs/unocss-plugin` 时,`wx:class` 对象字面量的 key 仅支持合法标识符以及包含空格或 `-` 的类名,其他特殊字符会输出编译错误。
### 原子类分包输出 {#subpackage}
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..cbbbbefd33
--- /dev/null
+++ b/packages/unocss-plugin/__tests__/dynamic-class.test.js
@@ -0,0 +1,121 @@
+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, loc) => errors.push({ error, loc }))
+ 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('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: [],
+ hasUnoCSS: true,
+ warn: jest.fn(),
+ error: error => templateErrors.push(error)
+ })
+ const { output, classes } = await transformTemplate(compiler.serialize(parsed.root), pluginErrors)
+
+ expect(templateErrors).toEqual([])
+ expect(pluginErrors).toEqual([])
+ expect(plugin.options).not.toHaveProperty('escapeMap')
+ 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('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([
+ {
+ error: 'Classname [qwe@da] contains unsupported character [@].',
+ loc: {
+ className: 'qwe@da',
+ start: 13,
+ end: 23
+ }
+ },
+ {
+ error: 'Classname [*asd] contains unsupported character [*].',
+ loc: {
+ className: '*asd',
+ start: 13,
+ end: 23
+ }
+ }
+ ])
+ })
+
+ 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([
+ {
+ error: 'Dynamic classname [custom😀red] can not be escaped as a valid identifier, which is not supported.',
+ loc: {
+ className: 'custom😀red',
+ objectKey: true,
+ start: 16,
+ end: 54
+ }
+ },
+ {
+ error: 'Dynamic classname [12] can not be escaped as a valid identifier, which is not supported.',
+ loc: {
+ className: '12',
+ objectKey: true,
+ start: 16,
+ end: 54
+ }
+ }
+ ])
+ })
+})
diff --git a/packages/unocss-plugin/__tests__/plugin.test.js b/packages/unocss-plugin/__tests__/plugin.test.js
index c9eb436467..3d214c7ec2 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'
// const { presetLegacyCompat } = require('@unocss/preset-legacy-compat')
@@ -39,12 +38,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()
diff --git a/packages/unocss-plugin/lib/index.js b/packages/unocss-plugin/lib/index.js
index e25d264b11..b677801bd1 100644
--- a/packages/unocss-plugin/lib/index.js
+++ b/packages/unocss-plugin/lib/index.js
@@ -1,10 +1,13 @@
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 escapeClassObjectKey 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 sourceLocation from '@mpxjs/webpack-plugin/lib/utils/source-location.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 +16,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'
@@ -37,9 +40,73 @@ import { UnoCSSWebpackPlugin } from './web-plugin/index.js'
const { isWeb, isReact } = env
const { has } = set
+const { createCodeFrame, offsetToLoc, readSource } = sourceLocation
const PLUGIN_NAME = 'MpxUnocssPlugin'
+/**
+ * 在原始模板源码中定位类名。
+ * 对象 key 基于 AST 偏移定位,避免误匹配源码中其他位置的相同文本。
+ *
+ * @param {string} source
+ * @param {string} className
+ * @param {boolean} objectKey
+ * @returns {{start: number, end: number}|undefined}
+ */
+function findOriginalClassLoc (source, className, objectKey) {
+ let result
+ parseClasses(source).some(({ result: classValue, start }) => {
+ if (!objectKey) {
+ const index = classValue.indexOf(className)
+ if (index > -1) {
+ result = {
+ start: start + index,
+ end: start + index + className.length
+ }
+ return true
+ }
+ return false
+ }
+ const mustacheReg = /{{([\s\S]*?)}}/g
+ let match
+ while (match = mustacheReg.exec(classValue)) {
+ const rawExp = match[1]
+ const exp = rawExp.trim()
+ const expStart = start + match.index + 2 + rawExp.indexOf(exp)
+ const key = parseClassExpression(exp).objectKeys.find(key => String(key.result) === className)
+ if (key) {
+ const rawKey = exp.slice(key.start, key.end + 1)
+ const valueStart = rawKey.indexOf(className)
+ result = {
+ start: expStart + key.start + Math.max(valueStart, 0),
+ end: expStart + key.start + (valueStart > -1 ? valueStart + className.length : rawKey.length)
+ }
+ return true
+ }
+ }
+ return false
+ })
+ return result
+}
+
+/**
+ * 创建包含源码位置的 UnoCSS 编译错误。
+ *
+ * @param {string} msg
+ * @param {{file?: string, source?: string, start?: number, end?: number}} options
+ * @returns {Error}
+ */
+function createUnocssError (msg, { file, source, start, end } = {}) {
+ let location = file
+ let frame = ''
+ if (source && start != null) {
+ const loc = offsetToLoc(source, start, end)
+ location += `:${loc.start.line}:${loc.start.column}`
+ frame = createCodeFrame(source, loc)
+ }
+ return new Error(`[Mpx Unocss error]${location ? `[${location}]` : ''}: ${msg}${frame ? `\n\n${frame}` : ''}`)
+}
+
function filterFile (file, scan) {
const { include = [], exclude = [] } = scan
for (const rule of exclude) {
@@ -90,7 +157,6 @@ function normalizeOptions (options) {
'src/**/*'
]
},
- escapeMap = {},
// 公共的配置
root = process.cwd(),
config,
@@ -114,28 +180,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 +188,6 @@ function normalizeOptions (options) {
styleIsolation,
minCount,
scan,
- escapeMap,
root,
config,
configFiles,
@@ -192,9 +235,8 @@ class MpxUnocssPlugin {
}
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 +278,7 @@ class MpxUnocssPlugin {
getTemplateParser (uno) {
// process classes
const transformAlias = buildAliasTransformer(uno.config.alias)
- const transformClasses = (source, classNameHandler = c => c) => {
+ const transformClasses = (source, classNameHandler, unknownClassChars, loc) => {
// pre process
source = transformAlias(source)
if (this.options.transformGroups) {
@@ -244,25 +286,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 = {
+ value: new Set(),
+ loc
+ }
+ unknownClassChars.set(className, chars)
+ }
+ chars.value.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 }) => {
+ parseClasses(content).forEach(({ result, start: attrStart, end: attrEnd }) => {
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, { start: attrStart, end: attrEnd })
expSource.replace(start, end, result)
})
+ objectKeys.forEach(({ result, start, end }) => {
+ if (typeof result !== 'string') {
+ error && error(`Dynamic classname [${result}] can not be escaped as a valid identifier, which is not supported.`, { className: String(result), objectKey: true, start: attrStart, end: attrEnd })
+ return
+ }
+ const className = transformClasses(result, classNameHandler, unknownClassChars, { objectKey: true, start: attrStart, end: attrEnd })
+ const propertyName = escapeClassObjectKey(className)
+ if (!isValidIdentifierStr(propertyName)) {
+ error && error(`Dynamic classname [${result}] can not be escaped as a valid identifier, which is not supported.`, { className: result, objectKey: true, start: attrStart, end: attrEnd })
+ } else {
+ expSource.replace(start, end, propertyName)
+ }
+ })
return expSource.source()
- }, str => transformClasses(str, classNameHandler))
+ }, str => transformClasses(str, classNameHandler, unknownClassChars, { start: attrStart, end: attrEnd }))
if (replaced) {
val = stringifyAttr(val)
- source.replace(start - 1, end + 1, val)
+ source.replace(attrStart - 1, attrEnd + 1, val)
}
})
+ await Promise.all(Array.from(unknownClassChars).map(async ([className, { value, loc }]) => {
+ if (!await uno.parseToken(className)) {
+ value.forEach((char) => {
+ error && error(`Classname [${className}] contains unsupported character [${char}].`, Object.assign({ className }, loc))
+ })
+ }
+ }))
// process comments
const commentConfig = {}
parseComments(content).forEach(({ result, start, end }) => {
@@ -319,8 +396,8 @@ class MpxUnocssPlugin {
name: PLUGIN_NAME,
stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONS
}, async (assets) => {
- const error = (msg) => {
- compilation.errors.push(new Error(msg))
+ const error = (msg, options) => {
+ compilation.errors.push(createUnocssError(msg, options))
}
// const warn = (msg) => {
// compilation.warnings.push(new Error(msg))
@@ -379,6 +456,24 @@ class MpxUnocssPlugin {
const processTemplate = async (file, source) => {
const packageName = getPackageName(file)
const filename = file.slice(0, -templateExt.length)
+ const content = source.source()
+ let resourcePath
+ const assetModules = assetsModulesMap.get(file)
+ // 一个模板产物可能关联多个模块,优先选择 type=template 的模块
+ has(assetModules, (module) => {
+ if (module.resource) {
+ const request = parseRequest(module.resource)
+ if (!resourcePath) {
+ resourcePath = toPosix(request.resourcePath)
+ }
+ if (request.queryObj.type === 'template') {
+ resourcePath = toPosix(request.resourcePath)
+ return true
+ }
+ }
+ return false
+ })
+ const resourceSource = readSource(resourcePath, compiler.inputFileSystem)
const currentClassesMap = packageClassesMaps[packageName] = packageClassesMaps[packageName] || {}
// process classes
@@ -392,9 +487,21 @@ class MpxUnocssPlugin {
} else if (!mainClassesMap[className]) {
currentClassesMap[className] = true
}
- return mpEscape(cssEscape(className), this.options.escapeMap)
+ return className
+ }
+ const getErrorOptions = (loc) => {
+ const originalLoc = resourceSource && findOriginalClassLoc(resourceSource, loc.className, loc.objectKey)
+ if (originalLoc) {
+ return Object.assign({
+ file: resourcePath,
+ source: resourceSource
+ }, originalLoc)
+ }
+ return Object.assign({ file, source: content }, loc)
}
- const { newsource, commentConfig } = parseTemplate(source, classNameHandler)
+ const { newsource, commentConfig } = await parseTemplate(source, classNameHandler, (msg, loc) => {
+ error(msg, getErrorOptions(loc))
+ })
commentConfigMap[filename] = commentConfig
assets[file] = newsource
}
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..20df874275
--- /dev/null
+++ b/packages/unocss-plugin/lib/parse-class-expression.js
@@ -0,0 +1,57 @@
+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}>
+ * }}
+ */
+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
+ })
+ }
+ },
+ 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/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 aea438fa5e..74b2e8f9b7 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.0.0",
"@unocss/core": "^66.0.0",
diff --git a/packages/webpack-plugin/lib/global.d.ts b/packages/webpack-plugin/lib/global.d.ts
index c629c8feeb..16d47695fb 100644
--- a/packages/webpack-plugin/lib/global.d.ts
+++ b/packages/webpack-plugin/lib/global.d.ts
@@ -79,6 +79,11 @@ declare global {
*/
dynamicEntryInfo: Record
+ /**
+ * 是否使用 UnoCSS
+ */
+ hasUnoCSS?: boolean
+
/**
* 记录 entryModule 与 entryNode 的对应关系,用于体积分析
*/
diff --git a/packages/webpack-plugin/lib/template-compiler/compiler.js b/packages/webpack-plugin/lib/template-compiler/compiler.js
index 5eadabc738..db25531146 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 hasUnoCSS
let runtimeCompile
let rulesRunner
let customBuiltInComponentsOpt
@@ -637,6 +638,7 @@ function parse (template, options) {
hasScoped = options.hasScoped
hasVirtualHost = options.hasVirtualHost
isCustomText = options.isCustomText
+ hasUnoCSS = options.hasUnoCSS
filePath = options.filePath
i18n = options.i18n
runtimeCompile = options.runtimeCompile
@@ -2414,9 +2416,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 (!hasUnoCSS) {
+ dynamicClassExp = transDynamicClassExpr(dynamicClassExp, {
+ error: error$1
+ })
+ }
addAttrs(el, [{
name: targetType,
// swan中externalClass是通过编译时静态实现,因此需要保留原有的staticClass形式避免externalClass失效
@@ -3518,9 +3523,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 (!hasUnoCSS) {
+ 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 78919dd473..11e10cb8e0 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 hasUnoCSS = mpx.hasUnoCSS
const decodeHTMLText = mpx.decodeHTMLText
const globalSrcMode = mpx.srcMode
const localSrcMode = queryObj.mode
@@ -69,6 +70,7 @@ module.exports = function (raw) {
defs,
decodeHTMLText,
externalClasses,
+ hasUnoCSS,
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..c79f1505d3 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 escapeClassObjectKey = 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' ? escapeClassObjectKey(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..d8300ddf8c
--- /dev/null
+++ b/packages/webpack-plugin/lib/utils/escape-class-object-key.js
@@ -0,0 +1,7 @@
+const KEY_ESCAPE_SUFFIX = 'MpxEscape'
+
+module.exports = function escapeClassObjectKey (str) {
+ const result = str.replace(/-/g, '_da_').replace(/\s+/g, '_sp_')
+ if (result !== str) return result + KEY_ESCAPE_SUFFIX
+ return str
+}
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..cb12c65078
--- /dev/null
+++ b/packages/webpack-plugin/test/template-compiler/trans-dynamic-class-expr.spec.js
@@ -0,0 +1,41 @@
+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 containing other invalid identifier characters', () => {
+ const error = jest.fn()
+ const result = transDynamicClassExpr("({ 'hover:bg-red-100': flag, 'custom@red': flag, 'foo*bar': flag, 1: flag })", { error })
+
+ expect(result).toContain("'hover:bg-red-100':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 when UnoCSS is enabled', () => {
+ const errors = []
+ const parsed = compiler.parse('', {
+ mode: 'wx',
+ srcMode: 'wx',
+ defs: {},
+ usingComponentsInfo: {},
+ externalClasses: [],
+ hasUnoCSS: true,
+ warn: jest.fn(),
+ error: error => errors.push(error)
+ })
+ const output = compiler.serialize(parsed.root)
+
+ expect(output).toContain('"custom@red": flag')
+ expect(errors).toEqual([])
+ })
+})