From 45c2fa51747b9438adb82e124397d27227e10ca6 Mon Sep 17 00:00:00 2001 From: mackwang Date: Wed, 10 Jun 2026 14:20:40 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(unocss-plugin):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20wx:class=20=E5=AF=B9=E8=B1=A1=E5=86=99=E6=B3=95=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E7=B1=BB=E5=90=8D=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 unocss-plugin 无法处理 wx:class="{{ { 'ml-17rpx': flag } }}" 对象写法的问题。 模板编译阶段 transDynamicClassExpr 会将 wx:class 对象 key 中的特殊字符 编码为合法 identifier(如 ml-17rpx → ml_da_17rpxMpxEscape),导致 unocss-plugin 扫描编译产物时 parseStrings 无法识别,对应 CSS 不会被生成。 新增 parseMpxEscapeKeys,通过正则扫描 MpxEscape 结尾的 identifier key, 用 unescapeKey 还原原始类名后交给 transformClasses 处理,再重新编码写回 wxml。 支持的写法: - wx:class="{{ { 'ml-17rpx': flag } }}" — 普通类名 - wx:class="{{ { 'h-100%': true } }}" — 含特殊字符的类名 - wx:class="{{ { 'ml-17rpx h-100%': flag } }}" — key 中空格分隔的多个类名 - wx:class="{{ { '*card': flag } }}" — alias 展开 同步重构 trans-dynamic-class-expr.js: - mpEscape → escapeClassName,keyEscape → escapeKey,新增对应的 unescape 函数 - 硬编码字符串提取为具名常量(KEY_ESCAPE_SUFFIX/DASH/SPACE) - escapeReg 改为从 classNameEscapeMap 动态生成,保持 map 和 reg 同步 - classNameDecodeReg 按 token 长度降序排列,避免短 token 优先匹配 Co-Authored-By: Claude Opus 4.6 --- packages/unocss-plugin/lib/index.js | 6 ++ packages/unocss-plugin/lib/parser.js | 24 +++++++ .../trans-dynamic-class-expr.js | 67 ++++++++++++++++--- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/packages/unocss-plugin/lib/index.js b/packages/unocss-plugin/lib/index.js index 74054bbad2..2a2fcc2ffd 100644 --- a/packages/unocss-plugin/lib/index.js +++ b/packages/unocss-plugin/lib/index.js @@ -14,11 +14,13 @@ const transformerVariantGroup = require('@unocss/transformer-variant-group') const { parseClasses, parseStrings, + parseMpxEscapeKeys, parseMustache, stringifyAttr, parseComments, parseCommentConfig } = require('./parser') +const { escapeKey, escapeClassName } = require('@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr') const { getReplaceSource, getConcatSource, getRawSource } = require('./source') const { transformStyle, @@ -358,6 +360,10 @@ class MpxUnocssPlugin { result = transformClasses(result, classNameHandler) expSource.replace(start, end, result) }) + parseMpxEscapeKeys(exp).forEach(({ result, start, end }) => { + const expanded = transformClasses(result, classNameHandler) + expSource.replace(start, end, escapeKey(escapeClassName(expanded))) + }) return expSource.source() }, str => transformClasses(str, classNameHandler)) if (replaced) { diff --git a/packages/unocss-plugin/lib/parser.js b/packages/unocss-plugin/lib/parser.js index 1ce02abc72..f2edf29dd5 100644 --- a/packages/unocss-plugin/lib/parser.js +++ b/packages/unocss-plugin/lib/parser.js @@ -1,4 +1,5 @@ const { parseMustache, stringifyAttr } = require('@mpxjs/webpack-plugin/lib/template-compiler/compiler') +const { unescapeKey } = require('@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr') function parseClasses (content) { const output = [] @@ -78,9 +79,32 @@ function parseStrings (content) { return output } +// 匹配对象字面量中经过 MpxEscape 编码的标识符形式的 key,如 { ml_da_17rpxMpxEscape: flag } +// key 前面必须是 { 或 ,(加可选空格),后面是 :,且必须以 MpxEscape 结尾 +const objKeyReg = /(?:[{,]\s*)([\w-]+?MpxEscape)(?=\s*:)/gm + +function parseMpxEscapeKeys (content) { + const output = [] + if (!content) { return output } + let match + objKeyReg.lastIndex = 0 + while (match = objKeyReg.exec(content)) { + const raw = match[1] + const end = match.index + match[0].length - 1 + const start = end - raw.length + 1 + output.push({ + result: unescapeKey(raw), + start, + end + }) + } + return output +} + module.exports = { parseClasses, parseStrings, + parseMpxEscapeKeys, parseComments, parseCommentConfig, parseMustache, 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..07504c5cf5 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,8 +3,10 @@ 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 = { +function escapeRegExp (str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} +const classNameEscapeMap = { '(': '_pl_', ')': '_pr_', '[': '_bl_', @@ -23,22 +25,65 @@ const escapeMap = { '+': '_a_', $: '_si_' } +const classNameEscapeReg = new RegExp('[' + Object.keys(classNameEscapeMap).map(escapeRegExp).join('') + ']', 'g') + +// classNameEscapeMap 的反向映射,用于还原 escapeClassName 编码 +const classNameDecodeMap = Object.keys(classNameEscapeMap).reduce((acc, key) => { + acc[classNameEscapeMap[key]] = key + return acc +}, {}) +const classNameDecodeReg = new RegExp(Object.keys(classNameDecodeMap).sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'), 'g') -function mpEscape (str) { - return str.replace(escapeReg, function (match) { - if (escapeMap[match]) return escapeMap[match] +function escapeClassName (str) { + return str.replace(classNameEscapeReg, function (match) { + if (classNameEscapeMap[match]) return classNameEscapeMap[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 +function unescapeClassName (str) { + return str.replace(classNameDecodeReg, m => classNameDecodeMap[m] || m) +} + +const KEY_ESCAPE_SUFFIX = 'MpxEscape' +const KEY_ESCAPE_DASH = '_da_' +const KEY_ESCAPE_SPACE = '_sp_' + +const keyEscapeMap = { + '-': KEY_ESCAPE_DASH, + ' ': KEY_ESCAPE_SPACE } +const keyDecodeMap = Object.keys(keyEscapeMap).reduce((acc, key) => { + acc[keyEscapeMap[key]] = key + return acc +}, {}) +const keyDecodeReg = new RegExp(Object.keys(keyDecodeMap).map(escapeRegExp).join('|'), 'g') + +function escapeKey (str) { + const result = str.replace(/-/g, KEY_ESCAPE_DASH).replace(/\s+/g, KEY_ESCAPE_SPACE) + if (result !== str) return result + KEY_ESCAPE_SUFFIX + return str +} + +function unescapeKey (str) { + if (str.endsWith(KEY_ESCAPE_SUFFIX)) { + return unescapeClassName( + str.slice(0, -KEY_ESCAPE_SUFFIX.length).replace(keyDecodeReg, m => keyDecodeMap[m]) + ) + } + return str +} + +module.exports = transDynamicClassExpr +module.exports.KEY_ESCAPE_SUFFIX = KEY_ESCAPE_SUFFIX +module.exports.KEY_ESCAPE_DASH = KEY_ESCAPE_DASH +module.exports.KEY_ESCAPE_SPACE = KEY_ESCAPE_SPACE +module.exports.unescapeKey = unescapeKey +module.exports.escapeKey = escapeKey +module.exports.escapeClassName = escapeClassName -module.exports = function transDynamicClassExpr (expr, { error } = {}) { +function transDynamicClassExpr (expr, { error } = {}) { try { const ast = babylon.parse(expr, { plugins: [ @@ -50,7 +95,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 = escapeKey(escapeClassName(rawPropertyName)) if (!isValidIdentifierStr(propertyName)) { error && error(`Dynamic classname [${rawPropertyName}] can not be escaped as a valid identifier, which is not supported.`) } else { From 91c88c66c7cbd130020cae27a3e47d870d9c6b3a Mon Sep 17 00:00:00 2001 From: mackwang Date: Wed, 10 Jun 2026 14:21:28 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor(trans-dynamic-class-expr):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=20classN?= =?UTF-8?q?ameDecodeReg=20=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当前 classNameDecodeMap 的所有 key 互不重叠,无需按长度降序排列。 Co-Authored-By: Claude Opus 4.6 --- .../lib/template-compiler/trans-dynamic-class-expr.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 07504c5cf5..bfd32fd078 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 @@ -32,7 +32,7 @@ const classNameDecodeMap = Object.keys(classNameEscapeMap).reduce((acc, key) => acc[classNameEscapeMap[key]] = key return acc }, {}) -const classNameDecodeReg = new RegExp(Object.keys(classNameDecodeMap).sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'), 'g') +const classNameDecodeReg = new RegExp(Object.keys(classNameDecodeMap).map(escapeRegExp).join('|'), 'g') function escapeClassName (str) { return str.replace(classNameEscapeReg, function (match) { From e7b7d772d67c9e4eca2499db641e01ca9fda93fd Mon Sep 17 00:00:00 2001 From: mackwang Date: Wed, 10 Jun 2026 15:51:30 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dwx:class=20?= =?UTF-8?q?=E4=B8=8D=E6=94=AF=E6=8C=81unocss=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/unocss-plugin/lib/parser.js | 6 +++--- .../lib/template-compiler/trans-dynamic-class-expr.js | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/unocss-plugin/lib/parser.js b/packages/unocss-plugin/lib/parser.js index f2edf29dd5..a27b47fce9 100644 --- a/packages/unocss-plugin/lib/parser.js +++ b/packages/unocss-plugin/lib/parser.js @@ -79,9 +79,9 @@ function parseStrings (content) { return output } -// 匹配对象字面量中经过 MpxEscape 编码的标识符形式的 key,如 { ml_da_17rpxMpxEscape: flag } -// key 前面必须是 { 或 ,(加可选空格),后面是 :,且必须以 MpxEscape 结尾 -const objKeyReg = /(?:[{,]\s*)([\w-]+?MpxEscape)(?=\s*:)/gm +// 匹配对象字面量中标识符形式的 key,如 { ml_da_17rpxMpxEscape: flag, a: true } +// key 前面必须是 { 或 ,(加可选空格),后面是 : +const objKeyReg = /(?:[{,]\s*)([\w-]+?)(?=\s*:)/gm function parseMpxEscapeKeys (content) { const output = [] 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 bfd32fd078..e32a57cb49 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 @@ -52,7 +52,8 @@ const KEY_ESCAPE_SPACE = '_sp_' const keyEscapeMap = { '-': KEY_ESCAPE_DASH, - ' ': KEY_ESCAPE_SPACE + ' ': KEY_ESCAPE_SPACE, + '*': '_st_' } const keyDecodeMap = Object.keys(keyEscapeMap).reduce((acc, key) => { acc[keyEscapeMap[key]] = key @@ -61,7 +62,7 @@ const keyDecodeMap = Object.keys(keyEscapeMap).reduce((acc, key) => { const keyDecodeReg = new RegExp(Object.keys(keyDecodeMap).map(escapeRegExp).join('|'), 'g') function escapeKey (str) { - const result = str.replace(/-/g, KEY_ESCAPE_DASH).replace(/\s+/g, KEY_ESCAPE_SPACE) + const result = str.replace(/-/g, KEY_ESCAPE_DASH).replace(/\s+/g, KEY_ESCAPE_SPACE).replace(/\*/g, '_st_') if (result !== str) return result + KEY_ESCAPE_SUFFIX return str } From 597db01a10ad3e539fa65d7238ac35320551d987 Mon Sep 17 00:00:00 2001 From: mackwang Date: Mon, 22 Jun 2026 15:57:51 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E6=97=A0=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/template-compiler/trans-dynamic-class-expr.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) 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 e32a57cb49..340f42cc00 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 @@ -47,12 +47,10 @@ function unescapeClassName (str) { } const KEY_ESCAPE_SUFFIX = 'MpxEscape' -const KEY_ESCAPE_DASH = '_da_' -const KEY_ESCAPE_SPACE = '_sp_' const keyEscapeMap = { - '-': KEY_ESCAPE_DASH, - ' ': KEY_ESCAPE_SPACE, + '-': '_da_', + ' ': '_sp_', '*': '_st_' } const keyDecodeMap = Object.keys(keyEscapeMap).reduce((acc, key) => { @@ -62,7 +60,7 @@ const keyDecodeMap = Object.keys(keyEscapeMap).reduce((acc, key) => { const keyDecodeReg = new RegExp(Object.keys(keyDecodeMap).map(escapeRegExp).join('|'), 'g') function escapeKey (str) { - const result = str.replace(/-/g, KEY_ESCAPE_DASH).replace(/\s+/g, KEY_ESCAPE_SPACE).replace(/\*/g, '_st_') + const result = str.replace(/-/g, '_da_').replace(/\s+/g, '_sp_').replace(/\*/g, '_st_') if (result !== str) return result + KEY_ESCAPE_SUFFIX return str } @@ -77,9 +75,6 @@ function unescapeKey (str) { } module.exports = transDynamicClassExpr -module.exports.KEY_ESCAPE_SUFFIX = KEY_ESCAPE_SUFFIX -module.exports.KEY_ESCAPE_DASH = KEY_ESCAPE_DASH -module.exports.KEY_ESCAPE_SPACE = KEY_ESCAPE_SPACE module.exports.unescapeKey = unescapeKey module.exports.escapeKey = escapeKey module.exports.escapeClassName = escapeClassName From 12af9620527f716678cb87805ffd5ca061de8654 Mon Sep 17 00:00:00 2001 From: mackwang Date: Fri, 10 Jul 2026 17:52:44 +0800 Subject: [PATCH 5/9] fix: align unocss escape map handling --- packages/unocss-plugin/lib/index.js | 29 ++------ packages/unocss-plugin/lib/parser.js | 6 +- packages/webpack-plugin/lib/global.d.ts | 5 ++ .../lib/template-compiler/compiler.js | 8 +- .../lib/template-compiler/index.js | 2 + .../trans-dynamic-class-expr.js | 73 ++++++++++++++----- 6 files changed, 76 insertions(+), 47 deletions(-) diff --git a/packages/unocss-plugin/lib/index.js b/packages/unocss-plugin/lib/index.js index 2a2fcc2ffd..c2dd76db97 100644 --- a/packages/unocss-plugin/lib/index.js +++ b/packages/unocss-plugin/lib/index.js @@ -20,7 +20,7 @@ const { parseComments, parseCommentConfig } = require('./parser') -const { escapeKey, escapeClassName } = require('@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr') +const { escapeKey, mpEscapeMap } = require('@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr') const { getReplaceSource, getConcatSource, getRawSource } = require('./source') const { transformStyle, @@ -105,27 +105,7 @@ 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 - } + escapeMap = Object.assign({}, mpEscapeMap, { unknown: '_u_' }, escapeMap) scan.include = normalizeRules(scan.include, root) scan.exclude = normalizeRules(scan.exclude, root) @@ -258,6 +238,7 @@ class MpxUnocssPlugin { }, (compilation) => { const { __mpx__: mpx } = compilation mpx.hasUnoCSS = true + mpx.unocssEscapeMap = this.options.escapeMap if (mode === 'web') return compilation.hooks.processAssets.tapPromise({ name: PLUGIN_NAME, @@ -360,9 +341,9 @@ class MpxUnocssPlugin { result = transformClasses(result, classNameHandler) expSource.replace(start, end, result) }) - parseMpxEscapeKeys(exp).forEach(({ result, start, end }) => { + parseMpxEscapeKeys(exp, this.options.escapeMap).forEach(({ result, start, end }) => { const expanded = transformClasses(result, classNameHandler) - expSource.replace(start, end, escapeKey(escapeClassName(expanded))) + expSource.replace(start, end, escapeKey(expanded)) }) return expSource.source() }, str => transformClasses(str, classNameHandler)) diff --git a/packages/unocss-plugin/lib/parser.js b/packages/unocss-plugin/lib/parser.js index a27b47fce9..69a42e7313 100644 --- a/packages/unocss-plugin/lib/parser.js +++ b/packages/unocss-plugin/lib/parser.js @@ -1,5 +1,5 @@ const { parseMustache, stringifyAttr } = require('@mpxjs/webpack-plugin/lib/template-compiler/compiler') -const { unescapeKey } = require('@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr') +const { unescapeKey, mpUnescape } = require('@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr') function parseClasses (content) { const output = [] @@ -83,7 +83,7 @@ function parseStrings (content) { // key 前面必须是 { 或 ,(加可选空格),后面是 : const objKeyReg = /(?:[{,]\s*)([\w-]+?)(?=\s*:)/gm -function parseMpxEscapeKeys (content) { +function parseMpxEscapeKeys (content, escapeMap) { const output = [] if (!content) { return output } let match @@ -93,7 +93,7 @@ function parseMpxEscapeKeys (content) { const end = match.index + match[0].length - 1 const start = end - raw.length + 1 output.push({ - result: unescapeKey(raw), + result: mpUnescape(unescapeKey(raw), escapeMap), start, end }) diff --git a/packages/webpack-plugin/lib/global.d.ts b/packages/webpack-plugin/lib/global.d.ts index c629c8feeb..963cbb0cf5 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 小程序 class 转义映射 + */ + unocssEscapeMap?: Record + /** * 记录 entryModule 与 entryNode 的对应关系,用于体积分析 */ diff --git a/packages/webpack-plugin/lib/template-compiler/compiler.js b/packages/webpack-plugin/lib/template-compiler/compiler.js index a15bb438d8..d35bc0e814 100644 --- a/packages/webpack-plugin/lib/template-compiler/compiler.js +++ b/packages/webpack-plugin/lib/template-compiler/compiler.js @@ -105,6 +105,7 @@ let isNative let hasScoped let hasVirtualHost let isCustomText +let unocssEscapeMap let runtimeCompile let rulesRunner let customBuiltInComponentsOpt @@ -635,6 +636,7 @@ function parse (template, options) { hasScoped = options.hasScoped hasVirtualHost = options.hasVirtualHost isCustomText = options.isCustomText + unocssEscapeMap = options.unocssEscapeMap filePath = options.filePath i18n = options.i18n runtimeCompile = options.runtimeCompile @@ -2403,7 +2405,8 @@ function processClass (el, meta) { if (dynamicClass) { const staticClassExp = parseMustacheWithContext(staticClass).result const dynamicClassExp = transDynamicClassExpr(parseMustacheWithContext(dynamicClass).result, { - error: error$1 + error: error$1, + escapeMap: unocssEscapeMap }) addAttrs(el, [{ name: targetType, @@ -3503,7 +3506,8 @@ function processClassDynamic (el) { if (dynamicClass) { const staticClassExp = parseMustacheWithContext(staticClass).result const dynamicClassExp = transDynamicClassExpr(parseMustacheWithContext(dynamicClass).result, { - error: error$1 + error: error$1, + escapeMap: unocssEscapeMap }) addAttrs(el, [{ name: targetType, diff --git a/packages/webpack-plugin/lib/template-compiler/index.js b/packages/webpack-plugin/lib/template-compiler/index.js index 78919dd473..c410c92d45 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 unocssEscapeMap = mpx.unocssEscapeMap const decodeHTMLText = mpx.decodeHTMLText const globalSrcMode = mpx.srcMode const localSrcMode = queryObj.mode @@ -69,6 +70,7 @@ module.exports = function (raw) { defs, decodeHTMLText, externalClasses, + unocssEscapeMap, 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 340f42cc00..bd4d9b8719 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 @@ -6,7 +6,7 @@ const isValidIdentifierStr = require('../utils/is-valid-identifier-str') function escapeRegExp (str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -const classNameEscapeMap = { +const mpEscapeMap = { '(': '_pl_', ')': '_pr_', '[': '_bl_', @@ -25,25 +25,62 @@ const classNameEscapeMap = { '+': '_a_', $: '_si_' } -const classNameEscapeReg = new RegExp('[' + Object.keys(classNameEscapeMap).map(escapeRegExp).join('') + ']', 'g') +const mpEscapeReg = new RegExp('[' + Object.keys(mpEscapeMap).map(escapeRegExp).join('') + ']', 'g') -// classNameEscapeMap 的反向映射,用于还原 escapeClassName 编码 -const classNameDecodeMap = Object.keys(classNameEscapeMap).reduce((acc, key) => { - acc[classNameEscapeMap[key]] = key +// mpEscapeMap 的反向映射,用于还原 mpEscape 编码 +const mpDecodeMap = Object.keys(mpEscapeMap).reduce((acc, key) => { + acc[mpEscapeMap[key]] = key return acc }, {}) -const classNameDecodeReg = new RegExp(Object.keys(classNameDecodeMap).map(escapeRegExp).join('|'), 'g') +const mpDecodeReg = new RegExp(Object.keys(mpDecodeMap).map(escapeRegExp).join('|'), 'g') -function escapeClassName (str) { - return str.replace(classNameEscapeReg, function (match) { - if (classNameEscapeMap[match]) return classNameEscapeMap[match] +function getMpEscapeReg (escapeMap) { + if (!escapeMap || escapeMap === mpEscapeMap) { + return mpEscapeReg + } + const keys = Object.keys(escapeMap).filter(key => key !== 'unknown') + if (!keys.length) return null + return new RegExp(keys.sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'), 'g') +} + +function getMpDecodeInfo (escapeMap) { + if (!escapeMap || escapeMap === mpEscapeMap) { + return { + map: mpDecodeMap, + reg: mpDecodeReg + } + } + const decodeMap = Object.keys(escapeMap).reduce((acc, key) => { + if (key !== 'unknown') acc[escapeMap[key]] = key + return acc + }, {}) + const keys = Object.keys(decodeMap) + if (!keys.length) { + return { + map: decodeMap, + reg: null + } + } + return { + map: decodeMap, + reg: new RegExp(keys.sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'), 'g') + } +} + +function mpEscape (str, escapeMap = mpEscapeMap) { + const escapeReg = getMpEscapeReg(escapeMap) + if (!escapeReg) return str + return str.replace(escapeReg, function (match) { + if (escapeMap[match]) return escapeMap[match] // unknown escaped - return '_u_' + return escapeMap.unknown || '_u_' }) } -function unescapeClassName (str) { - return str.replace(classNameDecodeReg, m => classNameDecodeMap[m] || m) +function mpUnescape (str, escapeMap) { + const { map, reg } = getMpDecodeInfo(escapeMap) + if (!reg) return str + return str.replace(reg, m => map[m] || m) } const KEY_ESCAPE_SUFFIX = 'MpxEscape' @@ -67,9 +104,7 @@ function escapeKey (str) { function unescapeKey (str) { if (str.endsWith(KEY_ESCAPE_SUFFIX)) { - return unescapeClassName( - str.slice(0, -KEY_ESCAPE_SUFFIX.length).replace(keyDecodeReg, m => keyDecodeMap[m]) - ) + return str.slice(0, -KEY_ESCAPE_SUFFIX.length).replace(keyDecodeReg, m => keyDecodeMap[m]) } return str } @@ -77,9 +112,11 @@ function unescapeKey (str) { module.exports = transDynamicClassExpr module.exports.unescapeKey = unescapeKey module.exports.escapeKey = escapeKey -module.exports.escapeClassName = escapeClassName +module.exports.mpUnescape = mpUnescape +module.exports.mpEscape = mpEscape +module.exports.mpEscapeMap = mpEscapeMap -function transDynamicClassExpr (expr, { error } = {}) { +function transDynamicClassExpr (expr, { error, escapeMap } = {}) { try { const ast = babylon.parse(expr, { plugins: [ @@ -91,7 +128,7 @@ 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 = escapeKey(escapeClassName(rawPropertyName)) + const propertyName = escapeKey(mpEscape(rawPropertyName, escapeMap)) if (!isValidIdentifierStr(propertyName)) { error && error(`Dynamic classname [${rawPropertyName}] can not be escaped as a valid identifier, which is not supported.`) } else { From be8e3a6110603ad459e4547b4f9ed3a3e356889b Mon Sep 17 00:00:00 2001 From: mackwang Date: Wed, 29 Jul 2026 11:43:11 +0800 Subject: [PATCH 6/9] fix(unocss-plugin): validate dynamic class object keys --- docs-vitepress/api/compile.md | 28 --- .../guide/advance/utility-first-css.md | 12 +- packages/unocss-plugin/AGENTS.md | 2 +- .../__tests__/dynamic-class.test.js | 133 +++++++++++ .../unocss-plugin/__tests__/plugin.test.js | 5 +- packages/unocss-plugin/lib/index.js | 214 +++++++++++++++--- .../lib/parse-class-expression.js | 57 +++++ packages/unocss-plugin/lib/parser.js | 46 +--- packages/unocss-plugin/lib/transform.js | 26 ++- packages/unocss-plugin/package.json | 3 + packages/webpack-plugin/lib/global.d.ts | 4 +- .../lib/template-compiler/compiler.js | 24 +- .../lib/template-compiler/index.js | 4 +- .../trans-dynamic-class-expr.js | 115 +--------- .../lib/utils/escape-class-object-key.js | 7 + .../trans-dynamic-class-expr.spec.js | 41 ++++ 16 files changed, 481 insertions(+), 240 deletions(-) create mode 100644 packages/unocss-plugin/__tests__/dynamic-class.test.js create mode 100644 packages/unocss-plugin/lib/parse-class-expression.js create mode 100644 packages/webpack-plugin/lib/utils/escape-class-object-key.js create mode 100644 packages/webpack-plugin/test/template-compiler/trans-dynamic-class-expr.spec.js 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__/dynamic-class.test.js b/packages/unocss-plugin/__tests__/dynamic-class.test.js new file mode 100644 index 0000000000..a49fa5b17e --- /dev/null +++ b/packages/unocss-plugin/__tests__/dynamic-class.test.js @@ -0,0 +1,133 @@ +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, unknownClassChars } = parseTemplate(getRawSource(content), (className) => { + if (className) classes.push(className) + return className + }, (error, loc) => errors.push({ error, loc })) + const { matched } = await uno.generate(new Set(classes)) + unknownClassChars.forEach(({ value, loc }, className) => { + if (matched.has(className)) return + value.forEach((char) => { + errors.push({ + error: `Classname [${className}] contains unsupported character [${char}].`, + loc: Object.assign({ className }, loc) + }) + }) + }) + return { + output: newsource.source(), + classes, + matched + } + } + + 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, matched } = await transformTemplate( + '', + errors, + [ + [/^custom@(red|blue)$/, () => ({ color: 'red' })] + ] + ) + + expect(output).toContain('class="custom_u_blue"') + expect(output).toMatch(/custom_u_red:\s*flag/) + expect(matched).toEqual(new Set(['custom@blue', 'custom@red'])) + 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..af0925ad0d 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') @@ -44,7 +43,7 @@ describe('test plugin', () => { 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 49d99d88a6..30d5b2450f 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,14 +16,12 @@ import { minimatch } from 'minimatch' import * as path from 'path' import { parseClasses, - parseMpxEscapeKeys, + parseClassExpression, parseCommentConfig, parseComments, parseMustache, - parseStrings, stringifyAttr } from './parser.js' -import { escapeKey, mpEscapeMap } from '@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr.js' import platformPreflightsMap from './platform.js' import { UnoCSSRNWebpackPlugin } from './rn-plugin/index.js' import { @@ -39,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) { @@ -92,7 +157,6 @@ function normalizeOptions (options) { 'src/**/*' ] }, - escapeMap = {}, // 公共的配置 root = process.cwd(), config, @@ -116,8 +180,6 @@ function normalizeOptions (options) { ...webOptions } - escapeMap = Object.assign({}, mpEscapeMap, { unknown: '_u_' }, escapeMap) - scan.include = normalizeRules(scan.include, root) scan.exclude = normalizeRules(scan.exclude, root) @@ -126,7 +188,6 @@ function normalizeOptions (options) { styleIsolation, minCount, scan, - escapeMap, root, config, configFiles, @@ -168,15 +229,28 @@ function getPlugin (compiler, curPlugin) { return plugins.find(plugin => Object.getPrototypeOf(plugin).constructor === curPlugin) } +/** + * 生成小程序样式,并保留 UnoCSS 实际匹配的原始类名。 + * + * @param {import('@unocss/core').UnoGenerator} uno + * @param {string[]} classes + * @param {object} options + */ +async function generateStyleResult (uno, classes, options) { + const result = await uno.generate(new Set(classes), options) + return { + css: mpEscape(result.css), + matched: result.matched + } +} + class MpxUnocssPlugin { constructor (options = {}) { this.options = normalizeOptions(options) } async generateStyle (uno, classes = [], options = {}) { - const tokens = new Set(classes) - const result = await uno.generate(tokens, options) - return mpEscape(result.css, this.options.escapeMap) + return (await generateStyleResult(uno, classes, options)).css } getSafeListClasses (safelist) { @@ -218,7 +292,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) { @@ -226,27 +300,51 @@ 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 (source, classNameHandler = c => c, error) => { + // 未知字符是否有效由样式生成结果统一判断,这里只记录类名和源码位置 + 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) }) - parseMpxEscapeKeys(exp, this.options.escapeMap).forEach(({ result, start, end }) => { - const expanded = transformClasses(result, classNameHandler) - expSource.replace(start, end, escapeKey(expanded)) + 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) } }) // process comments @@ -262,7 +360,8 @@ class MpxUnocssPlugin { } return { newsource: source, - commentConfig + commentConfig, + unknownClassChars } } } @@ -300,14 +399,13 @@ class MpxUnocssPlugin { }, (compilation) => { const { __mpx__: mpx } = compilation mpx.hasUnoCSS = true - mpx.unocssEscapeMap = this.options.escapeMap if (isWeb(mode) || isReact(mode)) return compilation.hooks.processAssets.tapPromise({ 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)) @@ -353,6 +451,7 @@ class MpxUnocssPlugin { main: {} } const commentConfigMap = {} + const unknownClassErrors = new Map() const mainClassesMap = packageClassesMaps.main // config中的safelist视为主包classes @@ -363,9 +462,27 @@ class MpxUnocssPlugin { }) const parseTemplate = this.getTemplateParser(uno) - const processTemplate = async (file, source) => { + const processTemplate = (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 @@ -379,9 +496,32 @@ 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 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, unknownClassChars } = parseTemplate(source, classNameHandler, (msg, loc) => { + error(msg, getErrorOptions(loc)) + }) + unknownClassChars.forEach(({ value, loc }, className) => { + let records = unknownClassErrors.get(className) + if (!records) { + records = [] + unknownClassErrors.set(className, records) + } + records.push({ + chars: value, + options: getErrorOptions(Object.assign({ className }, loc)) + }) + }) commentConfigMap[filename] = commentConfig assets[file] = newsource } @@ -419,11 +559,14 @@ class MpxUnocssPlugin { Object.assign(mainClassesMap, commonClassesMap) // 生成主包uno.css let mainUnoFile + const matchedClasses = new Set() const mainClasses = Object.keys(mainClassesMap) - const mainUnoFileContent = await this.generateStyle(uno, mainClasses, { + const mainResult = await generateStyleResult(uno, mainClasses, { ...generateOptions, preflights: true }) + mainResult.matched.forEach(className => matchedClasses.add(className)) + const mainUnoFileContent = mainResult.css if (mainUnoFileContent) { mainUnoFile = this.options.unoFile + styleExt if (assets[mainUnoFile]) { @@ -470,11 +613,13 @@ class MpxUnocssPlugin { await Promise.all(Object.entries(packageClassesMaps).map(async ([packageRoot, classesMap]) => { let unoFile const classes = Object.keys(classesMap) - const unoFileContent = await this.generateStyle(uno, classes, { + const result = await generateStyleResult(uno, classes, { ...generateOptions, // 独立分包中的unocss文件生成preflights ...independentSubpackagesMap[packageRoot] ? { preflights: true } : null }) + result.matched.forEach(className => matchedClasses.add(className)) + const unoFileContent = result.css if (unoFileContent) { unoFile = toPosix(path.join(packageRoot, this.options.unoFile + styleExt)) if (assets[unoFile]) { @@ -537,6 +682,15 @@ class MpxUnocssPlugin { } }) })) + // 以实际生成结果为准,只有 UnoCSS 未处理的特殊字符类名才报错 + unknownClassErrors.forEach((records, className) => { + if (matchedClasses.has(className)) return + records.forEach(({ chars, options }) => { + chars.forEach((char) => { + error(`Classname [${className}] contains unsupported character [${char}].`, options) + }) + }) + }) }) }) } 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 0dedc00a04..ec3390be3e 100644 --- a/packages/unocss-plugin/lib/parser.js +++ b/packages/unocss-plugin/lib/parser.js @@ -1,5 +1,5 @@ import { parseMustache, stringifyAttr } from '@mpxjs/webpack-plugin/lib/template-compiler/compiler.js' -import { mpUnescape, unescapeKey } from '@mpxjs/webpack-plugin/lib/template-compiler/trans-dynamic-class-expr.js' +import parseClassExpression from './parse-class-expression.js' function parseClasses (content) { const output = [] @@ -60,51 +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 -} - -// 匹配对象字面量中标识符形式的 key,如 { ml_da_17rpxMpxEscape: flag, a: true } -// key 前面必须是 { 或 ,(加可选空格),后面是 : -const objKeyReg = /(?:[{,]\s*)([\w-]+?)(?=\s*:)/gm - -function parseMpxEscapeKeys (content, escapeMap) { - const output = [] - if (!content) { return output } - let match - objKeyReg.lastIndex = 0 - while (match = objKeyReg.exec(content)) { - const raw = match[1] - const end = match.index + match[0].length - 1 - const start = end - raw.length + 1 - output.push({ - result: mpUnescape(unescapeKey(raw), escapeMap), - start, - end - }) - } - return output -} - export { parseClasses, - parseStrings, - parseMpxEscapeKeys, + 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 963cbb0cf5..16d47695fb 100644 --- a/packages/webpack-plugin/lib/global.d.ts +++ b/packages/webpack-plugin/lib/global.d.ts @@ -80,9 +80,9 @@ declare global { dynamicEntryInfo: Record /** - * UnoCSS 小程序 class 转义映射 + * 是否使用 UnoCSS */ - unocssEscapeMap?: Record + 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 c4e21d4fde..db25531146 100644 --- a/packages/webpack-plugin/lib/template-compiler/compiler.js +++ b/packages/webpack-plugin/lib/template-compiler/compiler.js @@ -107,7 +107,7 @@ let isNative let hasScoped let hasVirtualHost let isCustomText -let unocssEscapeMap +let hasUnoCSS let runtimeCompile let rulesRunner let customBuiltInComponentsOpt @@ -638,7 +638,7 @@ function parse (template, options) { hasScoped = options.hasScoped hasVirtualHost = options.hasVirtualHost isCustomText = options.isCustomText - unocssEscapeMap = options.unocssEscapeMap + hasUnoCSS = options.hasUnoCSS filePath = options.filePath i18n = options.i18n runtimeCompile = options.runtimeCompile @@ -2416,10 +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, - escapeMap: unocssEscapeMap - }) + let dynamicClassExp = parseMustacheWithContext(dynamicClass).result + if (!hasUnoCSS) { + dynamicClassExp = transDynamicClassExpr(dynamicClassExp, { + error: error$1 + }) + } addAttrs(el, [{ name: targetType, // swan中externalClass是通过编译时静态实现,因此需要保留原有的staticClass形式避免externalClass失效 @@ -3521,10 +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, - escapeMap: unocssEscapeMap - }) + 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 c410c92d45..11e10cb8e0 100644 --- a/packages/webpack-plugin/lib/template-compiler/index.js +++ b/packages/webpack-plugin/lib/template-compiler/index.js @@ -18,7 +18,7 @@ module.exports = function (raw) { const defs = mpx.defs const i18n = mpx.i18n const externalClasses = mpx.externalClasses - const unocssEscapeMap = mpx.unocssEscapeMap + const hasUnoCSS = mpx.hasUnoCSS const decodeHTMLText = mpx.decodeHTMLText const globalSrcMode = mpx.srcMode const localSrcMode = queryObj.mode @@ -70,7 +70,7 @@ module.exports = function (raw) { defs, decodeHTMLText, externalClasses, - unocssEscapeMap, + 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 bd4d9b8719..41a1e9a594 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,120 +3,11 @@ 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') -function escapeRegExp (str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} -const mpEscapeMap = { - '(': '_pl_', - ')': '_pr_', - '[': '_bl_', - ']': '_br_', - '{': '_cl_', - '}': '_cr_', - '#': '_h_', - '!': '_i_', - '/': '_s_', - '.': '_d_', - ':': '_c_', - ',': '_2c_', - '%': '_p_', - "'": '_q_', - '"': '_dq_', - '+': '_a_', - $: '_si_' -} -const mpEscapeReg = new RegExp('[' + Object.keys(mpEscapeMap).map(escapeRegExp).join('') + ']', 'g') - -// mpEscapeMap 的反向映射,用于还原 mpEscape 编码 -const mpDecodeMap = Object.keys(mpEscapeMap).reduce((acc, key) => { - acc[mpEscapeMap[key]] = key - return acc -}, {}) -const mpDecodeReg = new RegExp(Object.keys(mpDecodeMap).map(escapeRegExp).join('|'), 'g') - -function getMpEscapeReg (escapeMap) { - if (!escapeMap || escapeMap === mpEscapeMap) { - return mpEscapeReg - } - const keys = Object.keys(escapeMap).filter(key => key !== 'unknown') - if (!keys.length) return null - return new RegExp(keys.sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'), 'g') -} - -function getMpDecodeInfo (escapeMap) { - if (!escapeMap || escapeMap === mpEscapeMap) { - return { - map: mpDecodeMap, - reg: mpDecodeReg - } - } - const decodeMap = Object.keys(escapeMap).reduce((acc, key) => { - if (key !== 'unknown') acc[escapeMap[key]] = key - return acc - }, {}) - const keys = Object.keys(decodeMap) - if (!keys.length) { - return { - map: decodeMap, - reg: null - } - } - return { - map: decodeMap, - reg: new RegExp(keys.sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'), 'g') - } -} - -function mpEscape (str, escapeMap = mpEscapeMap) { - const escapeReg = getMpEscapeReg(escapeMap) - if (!escapeReg) return str - return str.replace(escapeReg, function (match) { - if (escapeMap[match]) return escapeMap[match] - // unknown escaped - return escapeMap.unknown || '_u_' - }) -} - -function mpUnescape (str, escapeMap) { - const { map, reg } = getMpDecodeInfo(escapeMap) - if (!reg) return str - return str.replace(reg, m => map[m] || m) -} - -const KEY_ESCAPE_SUFFIX = 'MpxEscape' - -const keyEscapeMap = { - '-': '_da_', - ' ': '_sp_', - '*': '_st_' -} -const keyDecodeMap = Object.keys(keyEscapeMap).reduce((acc, key) => { - acc[keyEscapeMap[key]] = key - return acc -}, {}) -const keyDecodeReg = new RegExp(Object.keys(keyDecodeMap).map(escapeRegExp).join('|'), 'g') - -function escapeKey (str) { - const result = str.replace(/-/g, '_da_').replace(/\s+/g, '_sp_').replace(/\*/g, '_st_') - if (result !== str) return result + KEY_ESCAPE_SUFFIX - return str -} - -function unescapeKey (str) { - if (str.endsWith(KEY_ESCAPE_SUFFIX)) { - return str.slice(0, -KEY_ESCAPE_SUFFIX.length).replace(keyDecodeReg, m => keyDecodeMap[m]) - } - return str -} +const escapeClassObjectKey = require('../utils/escape-class-object-key') module.exports = transDynamicClassExpr -module.exports.unescapeKey = unescapeKey -module.exports.escapeKey = escapeKey -module.exports.mpUnescape = mpUnescape -module.exports.mpEscape = mpEscape -module.exports.mpEscapeMap = mpEscapeMap -function transDynamicClassExpr (expr, { error, escapeMap } = {}) { +function transDynamicClassExpr (expr, { error } = {}) { try { const ast = babylon.parse(expr, { plugins: [ @@ -128,7 +19,7 @@ function transDynamicClassExpr (expr, { error, escapeMap } = {}) { path.node.properties.forEach((property) => { if (t.isObjectProperty(property) && !property.computed) { const rawPropertyName = property.key.name || property.key.value - const propertyName = escapeKey(mpEscape(rawPropertyName, escapeMap)) + 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([]) + }) +}) From 01e82c3a5fd545765e49b04b827187792059b39c Mon Sep 17 00:00:00 2001 From: mackwang Date: Thu, 30 Jul 2026 14:28:16 +0800 Subject: [PATCH 7/9] fix(unocss-plugin): validate dynamic classes with parseToken --- .../__tests__/dynamic-class.test.js | 18 +---- .../unocss-plugin/__tests__/plugin.test.js | 2 +- packages/unocss-plugin/lib/index.js | 66 +++++-------------- .../trans-dynamic-class-expr.js | 4 +- 4 files changed, 21 insertions(+), 69 deletions(-) diff --git a/packages/unocss-plugin/__tests__/dynamic-class.test.js b/packages/unocss-plugin/__tests__/dynamic-class.test.js index a49fa5b17e..cbbbbefd33 100644 --- a/packages/unocss-plugin/__tests__/dynamic-class.test.js +++ b/packages/unocss-plugin/__tests__/dynamic-class.test.js @@ -12,24 +12,13 @@ describe('dynamic class object keys', () => { const uno = await createGenerator({ rules }) const parseTemplate = plugin.getTemplateParser(uno) const classes = [] - const { newsource, unknownClassChars } = parseTemplate(getRawSource(content), (className) => { + const { newsource } = await parseTemplate(getRawSource(content), (className) => { if (className) classes.push(className) return className }, (error, loc) => errors.push({ error, loc })) - const { matched } = await uno.generate(new Set(classes)) - unknownClassChars.forEach(({ value, loc }, className) => { - if (matched.has(className)) return - value.forEach((char) => { - errors.push({ - error: `Classname [${className}] contains unsupported character [${char}].`, - loc: Object.assign({ className }, loc) - }) - }) - }) return { output: newsource.source(), - classes, - matched + classes } } @@ -65,7 +54,7 @@ describe('dynamic class object keys', () => { test('allows configured classes containing special characters', async () => { const errors = [] - const { output, matched } = await transformTemplate( + const { output } = await transformTemplate( '', errors, [ @@ -75,7 +64,6 @@ describe('dynamic class object keys', () => { expect(output).toContain('class="custom_u_blue"') expect(output).toMatch(/custom_u_red:\s*flag/) - expect(matched).toEqual(new Set(['custom@blue', 'custom@red'])) expect(errors).toEqual([]) }) diff --git a/packages/unocss-plugin/__tests__/plugin.test.js b/packages/unocss-plugin/__tests__/plugin.test.js index af0925ad0d..3d214c7ec2 100644 --- a/packages/unocss-plugin/__tests__/plugin.test.js +++ b/packages/unocss-plugin/__tests__/plugin.test.js @@ -38,7 +38,7 @@ describe('test plugin', () => { }) { const source = getRawSource(content) const classmap = {} - const { newsource } = parseTemplate(source, (className) => { + const { newsource } = await parseTemplate(source, (className) => { if (!className) { return className } diff --git a/packages/unocss-plugin/lib/index.js b/packages/unocss-plugin/lib/index.js index 30d5b2450f..b677801bd1 100644 --- a/packages/unocss-plugin/lib/index.js +++ b/packages/unocss-plugin/lib/index.js @@ -229,28 +229,14 @@ function getPlugin (compiler, curPlugin) { return plugins.find(plugin => Object.getPrototypeOf(plugin).constructor === curPlugin) } -/** - * 生成小程序样式,并保留 UnoCSS 实际匹配的原始类名。 - * - * @param {import('@unocss/core').UnoGenerator} uno - * @param {string[]} classes - * @param {object} options - */ -async function generateStyleResult (uno, classes, options) { - const result = await uno.generate(new Set(classes), options) - return { - css: mpEscape(result.css), - matched: result.matched - } -} - class MpxUnocssPlugin { constructor (options = {}) { this.options = normalizeOptions(options) } async generateStyle (uno, classes = [], options = {}) { - return (await generateStyleResult(uno, classes, options)).css + const result = await uno.generate(new Set(classes), options) + return mpEscape(result.css) } getSafeListClasses (safelist) { @@ -314,8 +300,8 @@ class MpxUnocssPlugin { }) }).join(' ') } - return (source, classNameHandler = c => c, error) => { - // 未知字符是否有效由样式生成结果统一判断,这里只记录类名和源码位置 + return async (source, classNameHandler = c => c, error) => { + // 单个模板内先去重,再由 UnoCSS 判断包含未知字符的类名是否有效 const unknownClassChars = new Map() source = getReplaceSource(source) const content = source.original().source() @@ -347,6 +333,13 @@ class MpxUnocssPlugin { 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 }) => { @@ -360,8 +353,7 @@ class MpxUnocssPlugin { } return { newsource: source, - commentConfig, - unknownClassChars + commentConfig } } } @@ -451,7 +443,6 @@ class MpxUnocssPlugin { main: {} } const commentConfigMap = {} - const unknownClassErrors = new Map() const mainClassesMap = packageClassesMaps.main // config中的safelist视为主包classes @@ -462,7 +453,7 @@ class MpxUnocssPlugin { }) const parseTemplate = this.getTemplateParser(uno) - const processTemplate = (file, source) => { + const processTemplate = async (file, source) => { const packageName = getPackageName(file) const filename = file.slice(0, -templateExt.length) const content = source.source() @@ -508,20 +499,9 @@ class MpxUnocssPlugin { } return Object.assign({ file, source: content }, loc) } - const { newsource, commentConfig, unknownClassChars } = parseTemplate(source, classNameHandler, (msg, loc) => { + const { newsource, commentConfig } = await parseTemplate(source, classNameHandler, (msg, loc) => { error(msg, getErrorOptions(loc)) }) - unknownClassChars.forEach(({ value, loc }, className) => { - let records = unknownClassErrors.get(className) - if (!records) { - records = [] - unknownClassErrors.set(className, records) - } - records.push({ - chars: value, - options: getErrorOptions(Object.assign({ className }, loc)) - }) - }) commentConfigMap[filename] = commentConfig assets[file] = newsource } @@ -559,14 +539,11 @@ class MpxUnocssPlugin { Object.assign(mainClassesMap, commonClassesMap) // 生成主包uno.css let mainUnoFile - const matchedClasses = new Set() const mainClasses = Object.keys(mainClassesMap) - const mainResult = await generateStyleResult(uno, mainClasses, { + const mainUnoFileContent = await this.generateStyle(uno, mainClasses, { ...generateOptions, preflights: true }) - mainResult.matched.forEach(className => matchedClasses.add(className)) - const mainUnoFileContent = mainResult.css if (mainUnoFileContent) { mainUnoFile = this.options.unoFile + styleExt if (assets[mainUnoFile]) { @@ -613,13 +590,11 @@ class MpxUnocssPlugin { await Promise.all(Object.entries(packageClassesMaps).map(async ([packageRoot, classesMap]) => { let unoFile const classes = Object.keys(classesMap) - const result = await generateStyleResult(uno, classes, { + const unoFileContent = await this.generateStyle(uno, classes, { ...generateOptions, // 独立分包中的unocss文件生成preflights ...independentSubpackagesMap[packageRoot] ? { preflights: true } : null }) - result.matched.forEach(className => matchedClasses.add(className)) - const unoFileContent = result.css if (unoFileContent) { unoFile = toPosix(path.join(packageRoot, this.options.unoFile + styleExt)) if (assets[unoFile]) { @@ -682,15 +657,6 @@ class MpxUnocssPlugin { } }) })) - // 以实际生成结果为准,只有 UnoCSS 未处理的特殊字符类名才报错 - unknownClassErrors.forEach((records, className) => { - if (matchedClasses.has(className)) return - records.forEach(({ chars, options }) => { - chars.forEach((char) => { - error(`Classname [${className}] contains unsupported character [${char}].`, options) - }) - }) - }) }) }) } 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 41a1e9a594..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 @@ -5,9 +5,7 @@ const generate = require('@babel/generator').default const isValidIdentifierStr = require('../utils/is-valid-identifier-str') const escapeClassObjectKey = require('../utils/escape-class-object-key') -module.exports = transDynamicClassExpr - -function transDynamicClassExpr (expr, { error } = {}) { +module.exports = function transDynamicClassExpr (expr, { error } = {}) { try { const ast = babylon.parse(expr, { plugins: [ From d49ec9064413c45318cd5d51536126425708bf9f Mon Sep 17 00:00:00 2001 From: mackwang Date: Thu, 30 Jul 2026 14:45:54 +0800 Subject: [PATCH 8/9] test(unocss-plugin): update dynamic class order snapshot --- .../unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap b/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap index dcf6cc62ba..182f06e4ef 100644 --- a/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap +++ b/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap @@ -22,9 +22,9 @@ exports[`test plugin test-template 4`] = `" Date: Thu, 30 Jul 2026 16:42:57 +0800 Subject: [PATCH 9/9] test(unocss-plugin): update dynamic class snapshot --- .../unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap b/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap index 182f06e4ef..25bf682731 100644 --- a/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap +++ b/packages/unocss-plugin/__tests__/__snapshots__/plugin.test.js.snap @@ -17,7 +17,7 @@ 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`] = ` [