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