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([]) + }) +})