diff --git a/.changeset/funny-deers-shine.md b/.changeset/funny-deers-shine.md new file mode 100644 index 000000000..98d73c05f --- /dev/null +++ b/.changeset/funny-deers-shine.md @@ -0,0 +1,6 @@ +--- +"@weapp-tailwindcss/postcss": patch +"weapp-tailwindcss": patch +--- + +将 legacy selector/unit、空块清理和 Harmony CSS style value 变换迁到 @weapp-tailwindcss/postcss diff --git a/.changeset/real-pens-wonder.md b/.changeset/real-pens-wonder.md new file mode 100644 index 000000000..d54871b55 --- /dev/null +++ b/.changeset/real-pens-wonder.md @@ -0,0 +1,6 @@ +--- +"@weapp-tailwindcss/postcss": patch +"weapp-tailwindcss": patch +--- + +将 uni-app x 边框 preflight 与 v4 theme CSS 变换迁到 @weapp-tailwindcss/postcss diff --git a/packages/postcss/AGENTS.md b/packages/postcss/AGENTS.md index 884502c23..5c62f2c7a 100644 --- a/packages/postcss/AGENTS.md +++ b/packages/postcss/AGENTS.md @@ -10,7 +10,7 @@ - `src/syntax/`:CSS/SCSS 解析、`@import` specifier tokenize/quote,以及对外暴露的 syntax API。 - `src/plugins/`:插件实现与 pipeline 组装。 -- `src/compat/`:版本兼容与降级逻辑。 +- `src/compat/`:版本兼容与降级逻辑,包括 uni-app x 边框 preflight、style value、legacy selector/unit 与 Tailwind v4 theme source 变换。 - `src/selectorParser/`:选择器解析相关能力。 - `src/utils/`:纯工具函数,保持无副作用、可单测。 diff --git a/packages/postcss/src/compat/legacy-css/apply.ts b/packages/postcss/src/compat/legacy-css/apply.ts new file mode 100644 index 000000000..b005c899c --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/apply.ts @@ -0,0 +1,28 @@ +import postcss from 'postcss' + +/** 删除兼容源中的 `@apply` 规则及其空包装 at-rule。 */ +export function removeTailwindApplyRules(rawSource: string) { + try { + const root = postcss.parse(rawSource) + let removed = false + root.walkAtRules('apply', (rule) => { + const parent = rule.parent + if (parent?.type === 'rule') { + parent.remove() + } + else { + rule.remove() + } + removed = true + }) + root.walkAtRules((rule) => { + if (rule.nodes && rule.nodes.length === 0) { + rule.remove() + } + }) + return removed ? root.toString() : rawSource + } + catch { + return rawSource + } +} diff --git a/packages/postcss/src/compat/legacy-css/dedupe.ts b/packages/postcss/src/compat/legacy-css/dedupe.ts new file mode 100644 index 000000000..b365cd6f8 --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/dedupe.ts @@ -0,0 +1,164 @@ +import type { Node, Rule } from 'postcss' +import postcss from 'postcss' +import { + collectGeneratedSelectors, + getRuleCompatSelectorKeys, + hasUtilityClassSelector, + isCustomPropertyOnlyRule, + isPseudoContentInitRule, +} from './selectors' + +function collectGeneratedDeclarationPropsBySelector(generatedCss: string, selectors: Set) { + const propsBySelector = new Map>() + try { + const generatedRoot = postcss.parse(generatedCss) + generatedRoot.walkRules((rule) => { + const matchedSelectors = getRuleCompatSelectorKeys(rule).filter(selector => selectors.has(selector)) + if (matchedSelectors.length === 0) { + return + } + const props = new Set() + rule.walkDecls((decl) => { + props.add(decl.prop) + }) + for (const selector of matchedSelectors) { + const existing = propsBySelector.get(selector) + if (existing) { + for (const prop of props) { + existing.add(prop) + } + } + else { + propsBySelector.set(selector, new Set(props)) + } + } + }) + } + catch { + return propsBySelector + } + return propsBySelector +} + +function isRuleCoveredByGeneratedProps( + rule: Rule, + generatedDeclarationPropsBySelector: Map>, +) { + const nodeSelectors = getRuleCompatSelectorKeys(rule) + if (nodeSelectors.length === 0) { + return false + } + const props = new Set() + rule.walkDecls((decl) => { + props.add(decl.prop) + }) + if (props.size === 0) { + return false + } + for (const selector of nodeSelectors) { + const generatedProps = generatedDeclarationPropsBySelector.get(selector) + if (!generatedProps) { + continue + } + if ([...props].every(prop => generatedProps.has(prop))) { + return true + } + } + return false +} + +export function removeGeneratedSelectorCompatCss(css: string, generatedCss: string) { + const generatedSelectors = collectGeneratedSelectors(generatedCss) + if (generatedSelectors.size === 0) { + return css + } + + try { + const root = postcss.parse(css) + let removed = false + root.walkRules((rule) => { + if (isPseudoContentInitRule(rule)) { + rule.remove() + removed = true + return + } + if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { + return + } + if (getRuleCompatSelectorKeys(rule).some(selector => generatedSelectors.has(selector))) { + rule.remove() + removed = true + } + }) + root.walkAtRules((atRule) => { + if (atRule.nodes && atRule.nodes.length === 0) { + atRule.remove() + } + }) + return removed ? root.toString() : css + } + catch { + return css + } +} + +export function collectDedupedPostTransformCompatCss(css: string, generatedCss: string) { + const generatedSelectors = collectGeneratedSelectors(generatedCss) + if (generatedSelectors.size === 0) { + return css + } + const generatedDeclarationPropsBySelector = collectGeneratedDeclarationPropsBySelector(generatedCss, generatedSelectors) + + const preservedNodes: Node[] = [] + try { + const root = postcss.parse(css) + root.each((node) => { + if (node.type === 'rule') { + const nodeSelectors = getRuleCompatSelectorKeys(node) + const duplicated = nodeSelectors.some(selector => generatedSelectors.has(selector)) + if (!duplicated) { + preservedNodes.push(node.clone()) + return + } + if (isRuleCoveredByGeneratedProps(node, generatedDeclarationPropsBySelector)) { + return + } + if (isCustomPropertyOnlyRule(node) && !isPseudoContentInitRule(node) && !hasUtilityClassSelector(node.selector)) { + const declarationProps = new Set() + node.walkDecls((decl) => { + declarationProps.add(decl.prop) + }) + for (const selector of nodeSelectors) { + const generatedProps = generatedDeclarationPropsBySelector.get(selector) + if (!generatedProps) { + continue + } + for (const prop of generatedProps) { + declarationProps.delete(prop) + } + } + const nextRule = node.clone() + nextRule.walkDecls((decl) => { + if (!declarationProps.has(decl.prop)) { + decl.remove() + } + }) + if (nextRule.nodes.length > 0) { + preservedNodes.push(nextRule) + } + } + return + } + preservedNodes.push(node.clone()) + }) + if (preservedNodes.length === root.nodes.length) { + return css + } + const nextRoot = postcss.root() + nextRoot.append(preservedNodes) + return nextRoot.toString() + } + catch { + return css + } +} diff --git a/packages/postcss/src/compat/legacy-css/index.ts b/packages/postcss/src/compat/legacy-css/index.ts new file mode 100644 index 000000000..3ac4c3645 --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/index.ts @@ -0,0 +1,4 @@ +export { removeTailwindApplyRules } from './apply' +export { collectDedupedPostTransformCompatCss, removeGeneratedSelectorCompatCss } from './dedupe' +export { collectGeneratedSelectors, normalizeCompatSelectors } from './selectors' +export { inheritLegacyUnitConvertedDeclarations } from './units' diff --git a/packages/postcss/src/compat/legacy-css/selectors.ts b/packages/postcss/src/compat/legacy-css/selectors.ts new file mode 100644 index 000000000..aebdd6b32 --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/selectors.ts @@ -0,0 +1,255 @@ +import type { Rule } from 'postcss' +import { escape } from '@weapp-core/escape' +import postcss from 'postcss' + +const CLASS_SELECTOR_RE = /(?:^|[^\w-])\.[_a-z\u00A0-\uFFFF\\-]/i +const MINI_PROGRAM_THEME_SCOPE_SELECTORS = new Set([':host', 'page', '.tw-root', 'wx-root-portal-content']) +const SPECIFICITY_PLACEHOLDER_RE = /:not\(#(?:\\#|n)\)/g +const SELECTOR_CACHE_LIMIT = 64 +const LEGACY_PSEUDO_ELEMENTS = ['before', 'after', 'first-letter', 'first-line'] as const +const generatedSelectorCache = new Map>() + +function setGeneratedSelectorCache(css: string, selectors: Set) { + if (generatedSelectorCache.size >= SELECTOR_CACHE_LIMIT) { + const firstKey = generatedSelectorCache.keys().next().value + if (firstKey !== undefined) { + generatedSelectorCache.delete(firstKey) + } + } + generatedSelectorCache.set(css, selectors) +} + +function normalizeCompatSelector(selector: string) { + return selector + .replace(SPECIFICITY_PLACEHOLDER_RE, '') + .replace(/\s+/g, ' ') + .trim() +} + +function isLegacyPseudoElementAt(selector: string, index: number) { + for (const name of LEGACY_PSEUDO_ELEMENTS) { + if (!selector.startsWith(name, index)) { + continue + } + const next = selector[index + name.length] + if (next === undefined || !/[\w-]/.test(next)) { + return name + } + } + return undefined +} + +function normalizeLegacyPseudoElements(selector: string) { + let result = '' + let quote: string | undefined + let bracketDepth = 0 + let index = 0 + while (index < selector.length) { + const char = selector[index] + if (char === '\\') { + result += selector.slice(index, index + 2) + index += 2 + continue + } + if (quote !== undefined) { + result += char + if (char === quote) { + quote = undefined + } + index += 1 + continue + } + if (char === '"' || char === '\'') { + quote = char + result += char + index += 1 + continue + } + if (char === '[') { + bracketDepth++ + result += char + index += 1 + continue + } + if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1) + result += char + index += 1 + continue + } + if (bracketDepth === 0 && char === ':' && selector[index + 1] === ':') { + result += '::' + index += 2 + continue + } + if (bracketDepth === 0 && char === ':') { + const name = isLegacyPseudoElementAt(selector, index + 1) + if (name) { + result += `::${name}` + index += name.length + 1 + continue + } + } + result += char + index += 1 + } + return result +} + +function isClassSelectorTerminator(char: string) { + return /[\s>+~#,.:()[\]]/.test(char) +} + +function unescapeSimpleCssIdent(value: string) { + return value.replaceAll(/\\(.)/g, '$1') +} + +function escapeCompatSelectorClasses(selector: string) { + let result = '' + let index = 0 + let changed = false + while (index < selector.length) { + const char = selector[index] + if (char !== '.') { + result += char + index += 1 + continue + } + + let end = index + 1 + let className = '' + while (end < selector.length) { + const current = selector[end] + if (current === undefined) { + break + } + if (current === '\\' && end + 1 < selector.length) { + const escaped = selector[end + 1] + if (escaped === undefined) { + break + } + className += current + escaped + end += 2 + continue + } + if (isClassSelectorTerminator(current)) { + break + } + className += current + end += 1 + } + + if (className.includes('\\')) { + result += `.${escape(unescapeSimpleCssIdent(className))}` + changed = true + } + else { + result += `.${className}` + } + index = end + } + return changed ? result : selector +} + +export function normalizeCompatSelectors(selector: string) { + const normalized = normalizeCompatSelector(selector) + if (!normalized) { + return [] + } + const selectors = new Set([normalized]) + const escaped = normalizeCompatSelector(escapeCompatSelectorClasses(normalized)) + if (escaped) { + selectors.add(escaped) + } + return [...selectors] +} + +function normalizeCssSelector(selector: string) { + return normalizeLegacyPseudoElements(selector).trim().replace(/\s+/g, '') +} + +function getCompatSelectorKeys(selector: string) { + return normalizeCompatSelectors(selector).map(normalizeCssSelector) +} + +export function getRuleCompatSelectorKeys(rule: Rule) { + return (rule.selectors?.length ? rule.selectors : [rule.selector]) + .flatMap(selector => getCompatSelectorKeys(selector)) +} + +function hasClassSelector(selector: string) { + return CLASS_SELECTOR_RE.test(selector) +} + +function getNormalizedSelectorList(selector: string) { + return selector.split(',').map(normalizeCssSelector).filter(Boolean) +} + +function isMiniProgramThemeScopeSelector(selector: string) { + const selectors = getNormalizedSelectorList(selector) + return selectors.length > 0 + && selectors.every(item => MINI_PROGRAM_THEME_SCOPE_SELECTORS.has(item)) +} + +export function hasUtilityClassSelector(selector: string) { + return hasClassSelector(selector) && !isMiniProgramThemeScopeSelector(selector) +} + +export function isCustomPropertyOnlyRule(rule: Rule) { + let hasDeclaration = false + let allCustomProperties = true + + rule.each((node) => { + if (node.type !== 'decl') { + return + } + hasDeclaration = true + if (!node.prop.startsWith('--')) { + allCustomProperties = false + } + }) + + return hasDeclaration && allCustomProperties +} + +export function isPseudoContentInitRule(rule: Rule) { + let hasDeclaration = false + let onlyContentVariable = true + + rule.each((node) => { + if (node.type !== 'decl') { + return + } + hasDeclaration = true + if (node.prop !== '--tw-content') { + onlyContentVariable = false + } + }) + + return hasDeclaration && onlyContentVariable +} + +export function collectGeneratedSelectors(css: string) { + const cached = generatedSelectorCache.get(css) + if (cached) { + return cached + } + + const selectors = new Set() + try { + const root = postcss.parse(css) + root.walkRules((rule) => { + if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { + return + } + for (const selector of getRuleCompatSelectorKeys(rule)) { + selectors.add(selector) + } + }) + } + catch { + return selectors + } + setGeneratedSelectorCache(css, selectors) + return selectors +} diff --git a/packages/postcss/src/compat/legacy-css/units.ts b/packages/postcss/src/compat/legacy-css/units.ts new file mode 100644 index 000000000..f91237373 --- /dev/null +++ b/packages/postcss/src/compat/legacy-css/units.ts @@ -0,0 +1,67 @@ +import postcss from 'postcss' +import { normalizeCompatSelectors } from './selectors' + +const CSS_LENGTH_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)(?:px|rem)\b/i +const RPX_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)rpx\b/i + +function createLegacyDeclarationValueMap(css: string) { + const values = new Map() + const root = postcss.parse(css) + root.walkRules((rule) => { + if (!rule.selectors || rule.selectors.length === 0) { + return + } + for (const selector of rule.selectors) { + const normalizedSelectors = normalizeCompatSelectors(selector) + rule.walkDecls((decl) => { + if (RPX_UNIT_RE.test(decl.value)) { + for (const normalizedSelector of normalizedSelectors) { + values.set(`${normalizedSelector}\n${decl.prop}`, decl.value) + } + } + }) + } + }) + return values +} + +export function inheritLegacyUnitConvertedDeclarations(css: string, legacyCss: string) { + try { + const legacyValues = createLegacyDeclarationValueMap(legacyCss) + if (legacyValues.size === 0) { + return css + } + + const root = postcss.parse(css) + let changed = false + root.walkRules((rule) => { + if (!rule.selectors || rule.selectors.length === 0) { + return + } + const selectors = rule.selectors + .flatMap(selector => normalizeCompatSelectors(selector)) + if (selectors.length === 0) { + return + } + + rule.walkDecls((decl) => { + if (!CSS_LENGTH_UNIT_RE.test(decl.value)) { + return + } + for (const selector of selectors) { + const legacyValue = legacyValues.get(`${selector}\n${decl.prop}`) + if (legacyValue && legacyValue !== decl.value) { + decl.value = legacyValue + changed = true + return + } + } + }) + }) + + return changed ? root.toString() : css + } + catch { + return css + } +} diff --git a/packages/postcss/src/compat/mini-program-css/empty-blocks.ts b/packages/postcss/src/compat/mini-program-css/empty-blocks.ts new file mode 100644 index 000000000..2b847e448 --- /dev/null +++ b/packages/postcss/src/compat/mini-program-css/empty-blocks.ts @@ -0,0 +1,175 @@ +import postcss from 'postcss' +import { repairTrailingUnclosedTailwindSourceMedia } from './directives' +import { removeEmptyAtRules, removeEmptyRules } from './root-cleanups' + +function isCssWhitespace(code: number) { + return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 +} + +function isCssWordChar(code: number) { + return (code >= 48 && code <= 57) + || (code >= 65 && code <= 90) + || (code >= 97 && code <= 122) + || code === 95 +} + +// 先用原生正则排除绝大多数不含空块的 CSS,避免每次 HMR 都逐字符扫描完整产物。 +const EMPTY_CSS_BLOCK_RE = /\{(?:[\t\n\f\r ]|\/\*(?:[^*]|\*(?!\/))*\*\/)*\}/ + +function findCssPreludeStart(css: string, start: number, end: number) { + let cursor = start + while (cursor < end) { + while (cursor < end && isCssWhitespace(css.charCodeAt(cursor))) { + cursor++ + } + if (css.charCodeAt(cursor) !== 47 || css.charCodeAt(cursor + 1) !== 42) { + return cursor + } + const commentEnd = css.indexOf('*/', cursor + 2) + if (commentEnd < 0 || commentEnd + 2 > end) { + return end + } + cursor = commentEnd + 2 + } + return end +} + +function isKeyframesAtRule(css: string, start: number, end: number) { + let cursor = start + 1 + if (css.charCodeAt(cursor) === 45) { + cursor++ + const prefixStart = cursor + while (cursor < end && isCssWordChar(css.charCodeAt(cursor))) { + cursor++ + } + if (cursor === prefixStart || css.charCodeAt(cursor) !== 45) { + return false + } + cursor++ + } + if (cursor + 9 > end || css.slice(cursor, cursor + 9).toLowerCase() !== 'keyframes') { + return false + } + const next = css.charCodeAt(cursor + 9) + return !isCssWordChar(next) +} + +export function hasEmptyCssBlockCandidate(css: string) { + if (!EMPTY_CSS_BLOCK_RE.test(css)) { + return false + } + const blocks: Array<{ + hasContent: boolean + isKeyframesContainer: boolean + isKeyframeStep: boolean + }> = [] + let parenthesisDepth = 0 + let quote = 0 + let squareBracketDepth = 0 + let statementStart = 0 + for (let index = 0; index < css.length; index++) { + const code = css.charCodeAt(index) + if (quote !== 0) { + if (code === 92) { + index++ + } + else if (code === quote) { + quote = 0 + } + continue + } + if (code === 34 || code === 39) { + quote = code + if (blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + continue + } + if (code === 92) { + if (blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + index++ + continue + } + if (code === 47 && css.charCodeAt(index + 1) === 42) { + const commentEnd = css.indexOf('*/', index + 2) + if (commentEnd < 0) { + return false + } + index = commentEnd + 1 + continue + } + if (code === 40) { + parenthesisDepth++ + continue + } + if (code === 41 && parenthesisDepth > 0) { + parenthesisDepth-- + continue + } + if (code === 91) { + squareBracketDepth++ + continue + } + if (code === 93 && squareBracketDepth > 0) { + squareBracketDepth-- + continue + } + if (code === 123 && parenthesisDepth === 0 && squareBracketDepth === 0) { + const preludeStart = findCssPreludeStart(css, statementStart, index) + const isAtRule = css.charCodeAt(preludeStart) === 64 + const isKeyframesContainer = isAtRule && isKeyframesAtRule(css, preludeStart, index) + blocks.push({ + hasContent: false, + isKeyframesContainer, + isKeyframeStep: !isAtRule && blocks[blocks.length - 1]?.isKeyframesContainer === true, + }) + statementStart = index + 1 + continue + } + if (code === 125 && parenthesisDepth === 0 && squareBracketDepth === 0) { + const block = blocks.pop() + if (!block) { + continue + } + if (!block.hasContent && !block.isKeyframeStep) { + return true + } + if (blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + statementStart = index + 1 + continue + } + if (code === 59 && parenthesisDepth === 0 && squareBracketDepth === 0) { + statementStart = index + 1 + continue + } + if (!isCssWhitespace(code) && blocks.length > 0) { + blocks[blocks.length - 1].hasContent = true + } + } + return false +} + +/** 在小程序样式进入最终产物图时递归清理无语义的空 CSS 块。 */ +export function finalizeMiniProgramCssStructure(css: string) { + const repaired = repairTrailingUnclosedTailwindSourceMedia(css) + if (!hasEmptyCssBlockCandidate(repaired)) { + return repaired + } + try { + const root = postcss.parse(repaired) + let removed = 0 + let passRemoved = 0 + do { + passRemoved = removeEmptyRules(root) + removeEmptyAtRules(root) + removed += passRemoved + } while (passRemoved > 0) + return removed > 0 ? root.toString() : repaired + } + catch { + return repaired + } +} diff --git a/packages/postcss/src/compat/mini-program-css/index.ts b/packages/postcss/src/compat/mini-program-css/index.ts index 6124323f3..9c0e86611 100644 --- a/packages/postcss/src/compat/mini-program-css/index.ts +++ b/packages/postcss/src/compat/mini-program-css/index.ts @@ -6,6 +6,10 @@ export { } from './at-rules' export { consumeCascadeLayers } from './cascade-layers' export { repairTrailingUnclosedTailwindSourceMedia } from './directives' +export { + finalizeMiniProgramCssStructure, + hasEmptyCssBlockCandidate, +} from './empty-blocks' export { finalizeMiniProgramCss, type FinalizeMiniProgramCssOptions, diff --git a/packages/postcss/src/compat/tailwindcss-v4.ts b/packages/postcss/src/compat/tailwindcss-v4.ts index 9e75d5385..e73ffb507 100644 --- a/packages/postcss/src/compat/tailwindcss-v4.ts +++ b/packages/postcss/src/compat/tailwindcss-v4.ts @@ -1,4 +1,5 @@ export { normalizeTailwindcssV4Declaration } from './tailwindcss-v4/declarations' export { appendTailwindcssV4MiniProgramGradientRules, mergeTailwindcssV4GradientDirectionRules, normalizeTailwindcssV4GradientPosition, normalizeTailwindcssV4InfinityCalcCss, normalizeTailwindcssV4InfinityCalcValue } from './tailwindcss-v4/gradients' export { isTailwindcssV4DisplayP3Declaration, isTailwindcssV4DisplayP3Media, isTailwindcssV4DisplayP3Supports, isTailwindcssV4LinearGradientSupports, isTailwindcssV4ModernCheck } from './tailwindcss-v4/modern-syntax' +export { removeTailwindV4PreflightImports, removeUnsupportedThemeVendorKeyframes } from './tailwindcss-v4/theme-source' export { collectUsedTailwindcssV4Variables, createMissingCssVarsV4Nodes, createUsedCssVarsV4Nodes, cssVarsV4Nodes, isTailwindcssV4, isTailwindcssV4ThemeVariable, testIfRootHostForV4, usesTailwindcssV4ContentVariable } from './tailwindcss-v4/variables' diff --git a/packages/postcss/src/compat/tailwindcss-v4/theme-source.ts b/packages/postcss/src/compat/tailwindcss-v4/theme-source.ts new file mode 100644 index 000000000..23c0e9db7 --- /dev/null +++ b/packages/postcss/src/compat/tailwindcss-v4/theme-source.ts @@ -0,0 +1,73 @@ +import type { AtRule, Container, Root } from 'postcss' +import postcss from 'postcss' +import { parseCssImportSpecifier } from '../../syntax/css-import' + +function isTailwindCssPreflightImport(params: string) { + const specifier = parseCssImportSpecifier(params)?.specifier + return specifier === 'tailwindcss/preflight.css' || specifier === 'tailwindcss/preflight' +} + +/** 从小程序入口 CSS 中移除 Tailwind v4 preflight import。 */ +export function removeTailwindV4PreflightImports(css: string) { + if (!css.includes('preflight')) { + return css + } + + let root: Root + try { + root = postcss.parse(css) + } + catch { + return css + } + + let changed = false + root.walkAtRules('import', (rule) => { + if (isTailwindCssPreflightImport(rule.params)) { + rule.remove() + changed = true + } + }) + + return changed ? root.toString() : css +} + +function hasThemeParent(rule: AtRule) { + let parent = rule.parent as Container | undefined + while (parent) { + if (parent.type === 'atrule' && (parent as AtRule).name === 'theme') { + return true + } + parent = parent.parent as Container | undefined + } + return false +} + +function isVendorPrefixedKeyframes(rule: AtRule) { + return rule.name.startsWith('-') && rule.name.endsWith('keyframes') +} + +/** 删除 `@theme` 内不被小程序接受的厂商前缀 keyframes。 */ +export function removeUnsupportedThemeVendorKeyframes(css: string) { + if (!css.includes('@theme') || !css.includes('@-')) { + return css + } + + let root: Root + try { + root = postcss.parse(css) + } + catch { + return css + } + + let changed = false + root.walkAtRules((rule) => { + if (isVendorPrefixedKeyframes(rule) && hasThemeParent(rule)) { + rule.remove() + changed = true + } + }) + + return changed ? root.toString() : css +} diff --git a/packages/postcss/src/compat/uni-app-x-border.ts b/packages/postcss/src/compat/uni-app-x-border.ts new file mode 100644 index 000000000..1162e2c18 --- /dev/null +++ b/packages/postcss/src/compat/uni-app-x-border.ts @@ -0,0 +1,39 @@ +import type { CssPreflightOptions } from '../types' +import postcss from 'postcss' +import { createInjectPreflight } from '../preflight' + +export const UNI_APP_X_BORDER_PREFLIGHT_CLASS = 'weapp-tw-border' + +/** 框架回放组件样式后恢复基础规则的顺序,确保作者 class 可以覆盖重置。 */ +export function hoistUniAppXBorderPreflight(css: string) { + if (!css.includes(UNI_APP_X_BORDER_PREFLIGHT_CLASS)) { + return css + } + const root = postcss.parse(css) + const resets = root.nodes.filter(node => node.type === 'rule' && node.selector === `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}`) + const anchor = root.nodes.find(node => !resets.includes(node) + && node.type !== 'comment' + && !(node.type === 'atrule' && ['charset', 'import'].includes(node.name))) + if (!anchor || resets.length === 0) { + return css + } + for (const reset of resets) { + reset.remove() + root.insertBefore(anchor, reset) + } + return root.toString() +} + +/** uni-app x 移除通配符 preflight 后,用独立基础类承载用户配置的边框默认值。 */ +export function createUniAppXBorderPreflight(options?: CssPreflightOptions) { + const declarations = createInjectPreflight(options)() + .filter(({ prop }) => prop === 'border' || prop.startsWith('border-')) + if (declarations.length === 0) { + return undefined + } + const rule = postcss.rule({ selector: `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}` }) + for (const declaration of declarations) { + rule.append(postcss.decl(declaration)) + } + return rule.toString() +} diff --git a/packages/postcss/src/compat/uni-app-x-style-value.ts b/packages/postcss/src/compat/uni-app-x-style-value.ts new file mode 100644 index 000000000..2242b9296 --- /dev/null +++ b/packages/postcss/src/compat/uni-app-x-style-value.ts @@ -0,0 +1,126 @@ +import type { AtRule } from 'postcss' +import { splitCandidateTokens } from '@tailwindcss-mangle/engine' +import { escape } from '@weapp-core/escape' +import postcss from 'postcss' +import { parseUniAppXStyleSource } from '../syntax/parse' + +const CLASS_SELECTOR_PREFIX_RE = /^\.((?:\\[^\n\r\f]|[\w-])+)(?=$|[.:#[])/ +const STRING_STYLE_PROPERTIES = new Set(['lineHeight']) + +type StyleDeclarations = Record +export type CssClassStyleValue = Record> + +function toCamelCase(prop: string) { + return prop.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()) +} + +function normalizeValue(prop: string, value: string) { + const trimmed = value.trim() + if (!STRING_STYLE_PROPERTIES.has(toCamelCase(prop)) && /^-?\d+(?:\.\d+)?px$/.test(trimmed)) { + return Number(trimmed.slice(0, -2)) + } + return trimmed.replace(/\s*,\s*/g, ',') +} + +function unescapeCssClassSelector(className: string) { + return className.replace(/\\([^\n\r\f0-9a-f])/gi, '$1') +} + +function assignClassStyleValue( + result: CssClassStyleValue, + className: string, + declarations: StyleDeclarations, +) { + const unescapedClassName = unescapeCssClassSelector(className) + result[className] = { '': declarations } + result[unescapedClassName] = { '': declarations } + result[escape(unescapedClassName)] = { '': declarations } +} + +/** 把 CSS 规则编译成 Harmony/UTS 可消费的 class -> 声明对象。 */ +export function cssToClassStyleValue(source: string): CssClassStyleValue | undefined { + let root: postcss.Root + try { + root = postcss.parse(source) + } + catch { + return + } + const result: CssClassStyleValue = {} + root.walkRules((rule) => { + const selectors = rule.selectors ?? [] + for (const selector of selectors) { + const match = selector.trim().match(CLASS_SELECTOR_PREFIX_RE) + if (!match?.[1]) { + continue + } + const declarations: StyleDeclarations = {} + rule.walkDecls((decl) => { + declarations[toCamelCase(decl.prop)] = normalizeValue(decl.prop, decl.value) + }) + if (Object.keys(declarations).length > 0) { + assignClassStyleValue(result, match[1], declarations) + } + } + }) + return Object.keys(result).length > 0 ? result : undefined +} + +/** 从 SCSS/CSS 源码收集 `@apply` 工具类。 */ +export function collectCssApplyUtilities(source: string) { + const utilities = new Set() + let root: postcss.Root + try { + root = parseUniAppXStyleSource(source) + } + catch { + return utilities + } + root.walkAtRules('apply', (rule) => { + for (const utility of splitCandidateTokens(rule.params)) { + utilities.add(utility) + } + }) + return utilities +} + +/** 把 `@apply` 规则展开成已有 utility 声明。 */ +export function expandCssApplySourcesToStyleValue( + source: string, + utilityStyles: CssClassStyleValue, +): CssClassStyleValue | undefined { + let root: postcss.Root + try { + root = parseUniAppXStyleSource(source) + } + catch { + return + } + const result: CssClassStyleValue = {} + root.walkRules((rule) => { + const applyRules = rule.nodes?.filter((node): node is AtRule => node.type === 'atrule' && node.name === 'apply') ?? [] + if (applyRules.length === 0) { + return + } + const selectors = rule.selectors ?? [rule.selector] + for (const selector of selectors) { + const className = selector.trim().match(CLASS_SELECTOR_PREFIX_RE)?.[1] + if (!className) { + continue + } + const declarations: StyleDeclarations = {} + for (const applyRule of applyRules) { + for (const utility of splitCandidateTokens(applyRule.params)) { + const utilityDeclarations = utilityStyles[utility]?.[''] ?? utilityStyles[escape(utility)]?.[''] + if (utilityDeclarations) { + Object.assign(declarations, utilityDeclarations) + } + } + } + if (Object.keys(declarations).length > 0) { + assignClassStyleValue(result, className, declarations) + } + } + }) + return Object.keys(result).length > 0 ? result : undefined +} diff --git a/packages/postcss/src/index.ts b/packages/postcss/src/index.ts index 3e993e91a..0bba3481a 100644 --- a/packages/postcss/src/index.ts +++ b/packages/postcss/src/index.ts @@ -15,12 +15,22 @@ export { protectDynamicColorMixAlpha, protectDynamicVarFallbacks, } from './compat/color-mix' +export { + collectDedupedPostTransformCompatCss, + collectGeneratedSelectors, + inheritLegacyUnitConvertedDeclarations, + normalizeCompatSelectors, + removeGeneratedSelectorCompatCss, + removeTailwindApplyRules, +} from './compat/legacy-css' export { transformLynxCssCompat } from './compat/lynx-css' export { consumeCascadeLayers, finalizeMiniProgramCss, type FinalizeMiniProgramCssOptions, finalizeMiniProgramCssRoot, + finalizeMiniProgramCssStructure, + hasEmptyCssBlockCandidate, hasMiniProgramCssSpecificityPlaceholders, hoistTailwindPreflightBase, normalizeMiniProgramGeneratedCssForPostcss, @@ -49,11 +59,23 @@ export { } from './compat/tailwindcss-rpx' export { normalizeTailwindcssV4InfinityCalcCss } from './compat/tailwindcss-v4' export { normalizeTailwindcssV4InfinityRadiusCss } from './compat/tailwindcss-v4/infinity-radius' +export { removeTailwindV4PreflightImports, removeUnsupportedThemeVendorKeyframes } from './compat/tailwindcss-v4/theme-source' export { normalizeUniAppXImportantApplyForSass, restoreUniAppXImportantApplyMarker, UNI_APP_X_IMPORTANT_APPLY_MARKER, } from './compat/uni-app-x' +export { + createUniAppXBorderPreflight, + hoistUniAppXBorderPreflight, + UNI_APP_X_BORDER_PREFLIGHT_CLASS, +} from './compat/uni-app-x-border' +export { + collectCssApplyUtilities, + type CssClassStyleValue, + cssToClassStyleValue, + expandCssApplySourcesToStyleValue, +} from './compat/uni-app-x-style-value' export { type NormalizedWebCssCompatOptions, normalizeWebCssCompatOptions, diff --git a/packages/postcss/test/empty-blocks.test.ts b/packages/postcss/test/empty-blocks.test.ts new file mode 100644 index 000000000..0b7de945d --- /dev/null +++ b/packages/postcss/test/empty-blocks.test.ts @@ -0,0 +1,32 @@ +import { finalizeMiniProgramCssStructure, hasEmptyCssBlockCandidate } from '@/index' + +describe('final mini-program css cleanup', () => { + it('detects empty selector and at-rule blocks with a linear precheck', () => { + expect(hasEmptyCssBlockCandidate(':is(page,.tw-root,wx-root-portal-content){}')).toBe(true) + expect(hasEmptyCssBlockCandidate('@media screen { /* token */ .keep { color: red } }')).toBe(false) + expect(hasEmptyCssBlockCandidate('@media screen { @supports (display: grid) { /* removed */ } }')).toBe(true) + expect(hasEmptyCssBlockCandidate('@supports (background: url(data:image/svg+xml;utf8,test)) {}')).toBe(true) + expect(hasEmptyCssBlockCandidate('@keyframes spin { 0% {} to { transform: rotate(1turn); } }')).toBe(false) + expect(hasEmptyCssBlockCandidate('/* prefix */ @-webkit-keyframes spin { 0% {} }')).toBe(false) + expect(hasEmptyCssBlockCandidate('@custom "value;{}"; .keep { color: red }')).toBe(false) + }) + + it('recursively removes empty selector rules and block at-rules', () => { + const source = [ + '@media (prefers-color-scheme: light) {}', + '@media screen { @supports (display: grid) { /* removed declarations */ } }', + '@supports (display: flex) { /* removed declarations */ }', + ':is(page,.tw-root,wx-root-portal-content) {}', + '@media print { .removed { /* removed declarations */ } }', + '.keep { color: red; }', + ].join('\n') + + expect(finalizeMiniProgramCssStructure(source)).toBe('.keep { color: red; }') + }) + + it('returns malformed css unchanged when parsing fails', () => { + const source = '@media (prefers-color-scheme: dark) {}\n.broken {' + + expect(finalizeMiniProgramCssStructure(source)).toBe(source) + }) +}) diff --git a/packages/postcss/test/legacy-css.test.ts b/packages/postcss/test/legacy-css.test.ts new file mode 100644 index 000000000..c522e12f2 --- /dev/null +++ b/packages/postcss/test/legacy-css.test.ts @@ -0,0 +1,69 @@ +import { + collectDedupedPostTransformCompatCss, + collectGeneratedSelectors, + inheritLegacyUnitConvertedDeclarations, + normalizeCompatSelectors, + removeGeneratedSelectorCompatCss, + removeTailwindApplyRules, +} from '@/index' + +describe('legacy css compatibility helpers', () => { + it('normalizes escaped selectors and ignores theme-only custom property rules', () => { + expect(normalizeCompatSelectors('.w-\\[100px\\]:not(#\\#)')).toEqual([ + '.w-\\[100px\\]', + '.w-_b100px_B', + ]) + expect(normalizeCompatSelectors(' ')).toEqual([]) + + const selectors = collectGeneratedSelectors([ + ':host,page{--color-red-500:red}', + '.w-_b100px_B{width:100px}', + '::before{--tw-content:""}', + ].join('\n')) + + expect(selectors.has('.w-_b100px_B')).toBe(true) + expect(selectors.has(':host')).toBe(false) + expect(selectors.has('::before')).toBe(true) + expect(collectGeneratedSelectors('.broken{').size).toBe(0) + }) + + it('removes compat selectors already generated while preserving custom properties', () => { + const css = removeGeneratedSelectorCompatCss([ + '.w-\\[100px\\]{width:100px}', + '.keep{color:red}', + ':root{--token:1}', + '::before{--tw-content:""}', + ].join('\n'), '.w-_b100px_B{width:100px}') + + expect(css).not.toContain('.w-\\[100px\\]') + expect(css).toContain('.keep') + expect(css).toContain('--token') + expect(css).not.toContain('--tw-content') + }) + + it('dedupes post-transform compat rules with legacy pseudo-element selectors', () => { + const css = collectDedupedPostTransformCompatCss( + '.before_ccontent-_b_aindependent_subpackage_mpx-tailwindcss-v4_a_B:before{--tw-content:"independent subpackage mpx-tailwindcss-v4";content:var(--tw-content)}', + '.before_ccontent-_b_aindependent_subpackage_mpx-tailwindcss-v4_a_B::before{--tw-content:\'independent subpackage mpx-tailwindcss-v4\';content:var(--tw-content)}', + ) + + expect(css).toBe('') + }) + + it('inherits rpx declarations from legacy css onto matching px/rem rules', () => { + const css = inheritLegacyUnitConvertedDeclarations( + '.w-_b100px_B{width:100px}.keep{color:red}', + '.w-\\[100px\\]{width:200rpx}', + ) + expect(css).toContain('width:200rpx') + expect(css).toContain('.keep{color:red}') + expect(inheritLegacyUnitConvertedDeclarations('.keep{color:red}', '.broken{')).toBe('.keep{color:red}') + }) + + it('removes @apply rules and empty wrapper at-rules', () => { + expect(removeTailwindApplyRules('@media screen { .card { @apply flex; } } .keep{color:red}')).toBe('.keep{color:red}') + expect(removeTailwindApplyRules('@apply flex; .keep{color:red}')).toBe('.keep{color:red}') + expect(removeTailwindApplyRules('.keep{color:red}')).toBe('.keep{color:red}') + expect(removeTailwindApplyRules('.broken{')).toBe('.broken{') + }) +}) diff --git a/packages/postcss/test/tailwindcss-v4-theme-source.test.ts b/packages/postcss/test/tailwindcss-v4-theme-source.test.ts new file mode 100644 index 000000000..d92aad996 --- /dev/null +++ b/packages/postcss/test/tailwindcss-v4-theme-source.test.ts @@ -0,0 +1,24 @@ +import { + removeTailwindV4PreflightImports, + removeUnsupportedThemeVendorKeyframes, +} from '@/index' + +describe('tailwind v4 theme source css', () => { + it('removes quoted, url and escaped preflight imports', () => { + expect(removeTailwindV4PreflightImports('@import "tailwindcss/theme.css";')).toBe('@import "tailwindcss/theme.css";') + expect(removeTailwindV4PreflightImports('@import "tailwindcss/preflight.css";@import "tailwindcss/theme.css";')) + .toBe('@import "tailwindcss/theme.css";') + expect(removeTailwindV4PreflightImports('@import url("tailwindcss/preflight");.btn{color:red}')) + .toBe('.btn{color:red}') + expect(removeTailwindV4PreflightImports('@import "broken')).toBe('@import "broken') + }) + + it('removes vendor-prefixed keyframes nested in theme', () => { + const css = '@theme{:root{--x:1}@-webkit-keyframes spin{to{transform:rotate(1turn)}}@keyframes spin{to{transform:rotate(1turn)}}}' + const output = removeUnsupportedThemeVendorKeyframes(css) + expect(output).not.toContain('@-webkit-keyframes') + expect(output).toContain('@keyframes spin') + expect(removeUnsupportedThemeVendorKeyframes('@keyframes spin{to{transform:rotate(1turn)}}')).toBe('@keyframes spin{to{transform:rotate(1turn)}}') + expect(removeUnsupportedThemeVendorKeyframes('@theme{:root{--x:1}')).toBe('@theme{:root{--x:1}') + }) +}) diff --git a/packages/postcss/test/uni-app-x-border.test.ts b/packages/postcss/test/uni-app-x-border.test.ts new file mode 100644 index 000000000..c8f453da7 --- /dev/null +++ b/packages/postcss/test/uni-app-x-border.test.ts @@ -0,0 +1,29 @@ +import { + createUniAppXBorderPreflight, + hoistUniAppXBorderPreflight, + postcss, + UNI_APP_X_BORDER_PREFLIGHT_CLASS, +} from '@/index' + +const reset = `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS} { border-width: 0; }` + +describe('uni-app x border preflight css', () => { + it('restores base priority after component CSS replay without crossing imports', () => { + const source = `@import "./theme.wxss";@media (min-width:1px){.custom{border-width:3px}}.native{border-top-width:1px}.apply{border-top-width:1px}${reset}.pair{border-left-width:2px}` + const output = hoistUniAppXBorderPreflight(source) + const root = postcss.parse(output) + expect(root.nodes.map(node => node.type === 'rule' ? node.selector : node.type === 'atrule' ? node.name : node.type)).toEqual(['import', `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}`, 'media', '.native', '.apply', '.pair']) + expect(hoistUniAppXBorderPreflight(output)).toBe(output) + expect(hoistUniAppXBorderPreflight('.custom{border-width:3px}')).toBe('.custom{border-width:3px}') + }) + + it('uses only configured border defaults and honors disabling', () => { + expect(createUniAppXBorderPreflight(false)).toBeUndefined() + expect(createUniAppXBorderPreflight()).toBeUndefined() + expect(createUniAppXBorderPreflight({ border: false, 'border-width': false, padding: '0' })).toBeUndefined() + const parsed = postcss.parse(createUniAppXBorderPreflight({ border: false, 'border-width': '0', padding: '0' })!) + expect(parsed.nodes).toHaveLength(1) + expect(parsed.first).toMatchObject({ selector: `.${UNI_APP_X_BORDER_PREFLIGHT_CLASS}`, nodes: [{ prop: 'border-width', value: '0' }] }) + expect(createUniAppXBorderPreflight({ border: '0 solid', 'border-color': 'red' })).toContain('border: 0 solid;') + }) +}) diff --git a/packages/postcss/test/uni-app-x-style-value.test.ts b/packages/postcss/test/uni-app-x-style-value.test.ts new file mode 100644 index 000000000..7d55fc509 --- /dev/null +++ b/packages/postcss/test/uni-app-x-style-value.test.ts @@ -0,0 +1,31 @@ +import { + collectCssApplyUtilities, + cssToClassStyleValue, + expandCssApplySourcesToStyleValue, +} from '@/index' + +describe('uni-app x css class style value', () => { + it('compiles class rules into camelCase declarations with escaped aliases', () => { + const utilityStyles = cssToClassStyleValue([ + '.flex{display:flex}', + '.w-\\[12px\\]{width:12px}', + '.leading-_b26px_B{--tw-leading:26px;line-height:26px}', + ].join(''))! + expect(cssToClassStyleValue('.broken{')).toBeUndefined() + expect(utilityStyles.flex['']).toMatchObject({ display: 'flex' }) + expect(utilityStyles['w-[12px]']['']).toMatchObject({ width: 12 }) + expect(utilityStyles['leading-_b26px_B']['']).toMatchObject({ + '-TwLeading': 26, + lineHeight: '26px', + }) + }) + + it('expands apply sources and collects utilities from scss', () => { + const utilityStyles = cssToClassStyleValue('.flex{display:flex}.w-\\[12px\\]{width:12px}')! + const applied = expandCssApplySourcesToStyleValue('.card{@apply flex w-[12px]}', utilityStyles) + expect(applied?.card['']).toMatchObject({ display: 'flex', width: 12 }) + expect(expandCssApplySourcesToStyleValue('.broken{', utilityStyles)).toBeUndefined() + expect([...collectCssApplyUtilities('.a{@apply flex block}')]).toEqual(['flex', 'block']) + expect(collectCssApplyUtilities('.broken{').size).toBe(0) + }) +}) diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts b/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts index 8e7246938..c7e2c688d 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/final-css-cleanup.ts @@ -1,175 +1,4 @@ -import { postcss, removeEmptyAtRules, removeEmptyRules, repairTrailingUnclosedTailwindSourceMedia } from '@weapp-tailwindcss/postcss' - -function isCssWhitespace(code: number) { - return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 -} - -function isCssWordChar(code: number) { - return (code >= 48 && code <= 57) - || (code >= 65 && code <= 90) - || (code >= 97 && code <= 122) - || code === 95 -} - -// 先用原生正则排除绝大多数不含空块的 CSS,避免每次 HMR 都逐字符扫描完整产物。 -const EMPTY_CSS_BLOCK_RE = /\{(?:[\t\n\f\r ]|\/\*(?:[^*]|\*(?!\/))*\*\/)*\}/ - -function findCssPreludeStart(css: string, start: number, end: number) { - let cursor = start - while (cursor < end) { - while (cursor < end && isCssWhitespace(css.charCodeAt(cursor))) { - cursor++ - } - if (css.charCodeAt(cursor) !== 47 || css.charCodeAt(cursor + 1) !== 42) { - return cursor - } - const commentEnd = css.indexOf('*/', cursor + 2) - if (commentEnd < 0 || commentEnd + 2 > end) { - return end - } - cursor = commentEnd + 2 - } - return end -} - -function isKeyframesAtRule(css: string, start: number, end: number) { - let cursor = start + 1 - if (css.charCodeAt(cursor) === 45) { - cursor++ - const prefixStart = cursor - while (cursor < end && isCssWordChar(css.charCodeAt(cursor))) { - cursor++ - } - if (cursor === prefixStart || css.charCodeAt(cursor) !== 45) { - return false - } - cursor++ - } - if (cursor + 9 > end || css.slice(cursor, cursor + 9).toLowerCase() !== 'keyframes') { - return false - } - const next = css.charCodeAt(cursor + 9) - return !isCssWordChar(next) -} - -export function hasEmptyCssBlockCandidate(css: string) { - if (!EMPTY_CSS_BLOCK_RE.test(css)) { - return false - } - const blocks: Array<{ - hasContent: boolean - isKeyframesContainer: boolean - isKeyframeStep: boolean - }> = [] - let parenthesisDepth = 0 - let quote = 0 - let squareBracketDepth = 0 - let statementStart = 0 - for (let index = 0; index < css.length; index++) { - const code = css.charCodeAt(index) - if (quote !== 0) { - if (code === 92) { - index++ - } - else if (code === quote) { - quote = 0 - } - continue - } - if (code === 34 || code === 39) { - quote = code - if (blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - continue - } - if (code === 92) { - if (blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - index++ - continue - } - if (code === 47 && css.charCodeAt(index + 1) === 42) { - const commentEnd = css.indexOf('*/', index + 2) - if (commentEnd < 0) { - return false - } - index = commentEnd + 1 - continue - } - if (code === 40) { - parenthesisDepth++ - continue - } - if (code === 41 && parenthesisDepth > 0) { - parenthesisDepth-- - continue - } - if (code === 91) { - squareBracketDepth++ - continue - } - if (code === 93 && squareBracketDepth > 0) { - squareBracketDepth-- - continue - } - if (code === 123 && parenthesisDepth === 0 && squareBracketDepth === 0) { - const preludeStart = findCssPreludeStart(css, statementStart, index) - const isAtRule = css.charCodeAt(preludeStart) === 64 - const isKeyframesContainer = isAtRule && isKeyframesAtRule(css, preludeStart, index) - blocks.push({ - hasContent: false, - isKeyframesContainer, - isKeyframeStep: !isAtRule && blocks[blocks.length - 1]?.isKeyframesContainer === true, - }) - statementStart = index + 1 - continue - } - if (code === 125 && parenthesisDepth === 0 && squareBracketDepth === 0) { - const block = blocks.pop() - if (!block) { - continue - } - if (!block.hasContent && !block.isKeyframeStep) { - return true - } - if (blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - statementStart = index + 1 - continue - } - if (code === 59 && parenthesisDepth === 0 && squareBracketDepth === 0) { - statementStart = index + 1 - continue - } - if (!isCssWhitespace(code) && blocks.length > 0) { - blocks[blocks.length - 1].hasContent = true - } - } - return false -} - -/** - * 在小程序样式进入最终产物图时递归清理无语义的空 CSS 块。 - */ -export function finalizeMiniProgramCssStructure(css: string) { - const repaired = repairTrailingUnclosedTailwindSourceMedia(css) - if (!hasEmptyCssBlockCandidate(repaired)) { - return repaired - } - try { - const root = postcss.parse(repaired) - let removed = 0 - let passRemoved = 0 - do { - passRemoved = removeEmptyRules(root) + removeEmptyAtRules(root) - removed += passRemoved - } while (passRemoved > 0) - return removed > 0 ? root.toString() : repaired - } - catch { - return repaired - } -} +export { + finalizeMiniProgramCssStructure, + hasEmptyCssBlockCandidate, +} from '@weapp-tailwindcss/postcss' diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts index 45880d608..0ed0affad 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts @@ -2,7 +2,7 @@ import type { IStyleHandlerOptions } from '@weapp-tailwindcss/postcss/types' import type { TailwindResolvedSource } from '@/generator' import type { InternalUserDefinedOptions } from '@/types' import { readFileSync } from 'node:fs' -import { filterExistingCssRules, postcss } from '@weapp-tailwindcss/postcss' +import { filterExistingCssRules, postcss, removeTailwindApplyRules } from '@weapp-tailwindcss/postcss' import { removeUnsupportedMiniProgramAtRules } from '../css-cleanup' import { removeTailwindSourceDirectives, resolveCssEntrySource } from './directives' import { collectDedupedPostTransformCompatCss, collectGeneratedSelectors, removeDuplicatedViteMarkers, removeGeneratedSelectorCompatCss } from './legacy-selectors' @@ -144,31 +144,7 @@ function closeTrailingUnclosedBlocks(source: string) { } } -export function removeTailwindApplyRules(rawSource: string) { - try { - const root = postcss.parse(rawSource) - let removed = false - root.walkAtRules('apply', (rule) => { - const parent = rule.parent - if (parent?.type === 'rule') { - parent.remove() - } - else { - rule.remove() - } - removed = true - }) - root.walkAtRules((rule) => { - if (rule.nodes && rule.nodes.length === 0) { - rule.remove() - } - }) - return removed ? root.toString() : rawSource - } - catch { - return rawSource - } -} +export { removeTailwindApplyRules } function resolveLegacyCompatCssSource(rawSource: string) { const cached = legacyCompatSourceCache.get(rawSource) diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts index 1a6fbe136..8a9228069 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-selectors.ts @@ -1,413 +1,11 @@ -import { postcss } from '@weapp-tailwindcss/postcss' -import { replaceWxml } from '@/wxml' import { VITE_MARKER_RE } from './markers' -const CLASS_SELECTOR_RE = /(?:^|[^\w-])\.[_a-z\u00A0-\uFFFF\\-]/i -const MINI_PROGRAM_THEME_SCOPE_SELECTORS = new Set([':host', 'page', '.tw-root', 'wx-root-portal-content']) -const SPECIFICITY_PLACEHOLDER_RE = /:not\(#(?:\\#|n)\)/g -const SELECTOR_CACHE_LIMIT = 64 -const LEGACY_PSEUDO_ELEMENTS = ['before', 'after', 'first-letter', 'first-line'] as const -const generatedSelectorCache = new Map>() - -function setGeneratedSelectorCache(css: string, selectors: Set) { - if (generatedSelectorCache.size >= SELECTOR_CACHE_LIMIT) { - const firstKey = generatedSelectorCache.keys().next().value - if (firstKey !== undefined) { - generatedSelectorCache.delete(firstKey) - } - } - generatedSelectorCache.set(css, selectors) -} - -function normalizeCompatSelector(selector: string) { - return selector - .replace(SPECIFICITY_PLACEHOLDER_RE, '') - .replace(/\s+/g, ' ') - .trim() -} - -function isLegacyPseudoElementAt(selector: string, index: number) { - for (const name of LEGACY_PSEUDO_ELEMENTS) { - if (!selector.startsWith(name, index)) { - continue - } - const next = selector[index + name.length] - if (next === undefined || !/[\w-]/.test(next)) { - return name - } - } - return undefined -} - -function normalizeLegacyPseudoElements(selector: string) { - let result = '' - let quote: string | undefined - let bracketDepth = 0 - let index = 0 - while (index < selector.length) { - const char = selector[index] - if (char === '\\') { - result += selector.slice(index, index + 2) - index += 2 - continue - } - if (quote !== undefined) { - result += char - if (char === quote) { - quote = undefined - } - index += 1 - continue - } - if (char === '"' || char === '\'') { - quote = char - result += char - index += 1 - continue - } - if (char === '[') { - bracketDepth++ - result += char - index += 1 - continue - } - if (char === ']') { - bracketDepth = Math.max(0, bracketDepth - 1) - result += char - index += 1 - continue - } - if (bracketDepth === 0 && char === ':' && selector[index + 1] === ':') { - result += '::' - index += 2 - continue - } - if (bracketDepth === 0 && char === ':') { - const name = isLegacyPseudoElementAt(selector, index + 1) - if (name) { - result += `::${name}` - index += name.length + 1 - continue - } - } - result += char - index += 1 - } - return result -} - -function isClassSelectorTerminator(char: string) { - return /[\s>+~#,.:()[\]]/.test(char) -} - -function unescapeSimpleCssIdent(value: string) { - return value.replaceAll(/\\(.)/g, '$1') -} - -function escapeCompatSelectorClasses(selector: string) { - let result = '' - let index = 0 - let changed = false - while (index < selector.length) { - const char = selector[index] - if (char !== '.') { - result += char - index += 1 - continue - } - - let end = index + 1 - let className = '' - while (end < selector.length) { - const current = selector[end] - if (current === undefined) { - break - } - if (current === '\\' && end + 1 < selector.length) { - const escaped = selector[end + 1] - if (escaped === undefined) { - break - } - className += current + escaped - end += 2 - continue - } - if (isClassSelectorTerminator(current)) { - break - } - className += current - end += 1 - } - - if (className.includes('\\')) { - result += `.${replaceWxml(unescapeSimpleCssIdent(className))}` - changed = true - } - else { - result += `.${className}` - } - index = end - } - return changed ? result : selector -} - -export function normalizeCompatSelectors(selector: string) { - const normalized = normalizeCompatSelector(selector) - if (!normalized) { - return [] - } - const selectors = new Set([normalized]) - const escaped = normalizeCompatSelector(escapeCompatSelectorClasses(normalized)) - if (escaped) { - selectors.add(escaped) - } - return [...selectors] -} - -function normalizeCssSelector(selector: string) { - return normalizeLegacyPseudoElements(selector).trim().replace(/\s+/g, '') -} - -function getCompatSelectorKeys(selector: string) { - return normalizeCompatSelectors(selector).map(normalizeCssSelector) -} - -function getRuleCompatSelectorKeys(rule: postcss.Rule) { - return (rule.selectors?.length ? rule.selectors : [rule.selector]) - .flatMap(selector => getCompatSelectorKeys(selector)) -} - -function hasClassSelector(selector: string) { - return CLASS_SELECTOR_RE.test(selector) -} - -function getNormalizedSelectorList(selector: string) { - return selector.split(',').map(normalizeCssSelector).filter(Boolean) -} - -function isMiniProgramThemeScopeSelector(selector: string) { - const selectors = getNormalizedSelectorList(selector) - return selectors.length > 0 - && selectors.every(item => MINI_PROGRAM_THEME_SCOPE_SELECTORS.has(item)) -} - -function hasUtilityClassSelector(selector: string) { - return hasClassSelector(selector) && !isMiniProgramThemeScopeSelector(selector) -} - -function isCustomPropertyOnlyRule(rule: postcss.Rule) { - let hasDeclaration = false - let allCustomProperties = true - - rule.each((node) => { - if (node.type !== 'decl') { - return - } - hasDeclaration = true - if (!node.prop.startsWith('--')) { - allCustomProperties = false - } - }) - - return hasDeclaration && allCustomProperties -} - -function isPseudoContentInitRule(rule: postcss.Rule) { - let hasDeclaration = false - let onlyContentVariable = true - - rule.each((node) => { - if (node.type !== 'decl') { - return - } - hasDeclaration = true - if (node.prop !== '--tw-content') { - onlyContentVariable = false - } - }) - - return hasDeclaration && onlyContentVariable -} - -export function collectGeneratedSelectors(css: string) { - const cached = generatedSelectorCache.get(css) - if (cached) { - return cached - } - - const selectors = new Set() - try { - const root = postcss.parse(css) - root.walkRules((rule) => { - if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { - return - } - for (const selector of getRuleCompatSelectorKeys(rule)) { - selectors.add(selector) - } - }) - } - catch { - return selectors - } - setGeneratedSelectorCache(css, selectors) - return selectors -} - -function collectGeneratedDeclarationPropsBySelector(generatedCss: string, selectors: Set) { - const propsBySelector = new Map>() - try { - const generatedRoot = postcss.parse(generatedCss) - generatedRoot.walkRules((rule) => { - const matchedSelectors = getRuleCompatSelectorKeys(rule).filter(selector => selectors.has(selector)) - if (matchedSelectors.length === 0) { - return - } - const props = new Set() - rule.walkDecls((decl) => { - props.add(decl.prop) - }) - for (const selector of matchedSelectors) { - const existing = propsBySelector.get(selector) - if (existing) { - for (const prop of props) { - existing.add(prop) - } - } - else { - propsBySelector.set(selector, new Set(props)) - } - } - }) - } - catch { - return propsBySelector - } - return propsBySelector -} - -function isRuleCoveredByGeneratedProps( - rule: postcss.Rule, - generatedDeclarationPropsBySelector: Map>, -) { - const nodeSelectors = getRuleCompatSelectorKeys(rule) - if (nodeSelectors.length === 0) { - return false - } - const props = new Set() - rule.walkDecls((decl) => { - props.add(decl.prop) - }) - if (props.size === 0) { - return false - } - for (const selector of nodeSelectors) { - const generatedProps = generatedDeclarationPropsBySelector.get(selector) - if (!generatedProps) { - continue - } - if ([...props].every(prop => generatedProps.has(prop))) { - return true - } - } - return false -} - -export function removeGeneratedSelectorCompatCss(css: string, generatedCss: string) { - const generatedSelectors = collectGeneratedSelectors(generatedCss) - if (generatedSelectors.size === 0) { - return css - } - - try { - const root = postcss.parse(css) - let removed = false - root.walkRules((rule) => { - if (isPseudoContentInitRule(rule)) { - rule.remove() - removed = true - return - } - if (isCustomPropertyOnlyRule(rule) && !isPseudoContentInitRule(rule) && !hasUtilityClassSelector(rule.selector)) { - return - } - if (getRuleCompatSelectorKeys(rule).some(selector => generatedSelectors.has(selector))) { - rule.remove() - removed = true - } - }) - root.walkAtRules((atRule) => { - if (atRule.nodes && atRule.nodes.length === 0) { - atRule.remove() - } - }) - return removed ? root.toString() : css - } - catch { - return css - } -} - -export function collectDedupedPostTransformCompatCss(css: string, generatedCss: string) { - const generatedSelectors = collectGeneratedSelectors(generatedCss) - if (generatedSelectors.size === 0) { - return css - } - const generatedDeclarationPropsBySelector = collectGeneratedDeclarationPropsBySelector(generatedCss, generatedSelectors) - - const preservedNodes: postcss.Node[] = [] - try { - const root = postcss.parse(css) - root.each((node) => { - if (node.type === 'rule') { - const nodeSelectors = getRuleCompatSelectorKeys(node) - const duplicated = nodeSelectors.some(selector => generatedSelectors.has(selector)) - if (!duplicated) { - preservedNodes.push(node.clone()) - return - } - if (isRuleCoveredByGeneratedProps(node, generatedDeclarationPropsBySelector)) { - return - } - if (isCustomPropertyOnlyRule(node) && !isPseudoContentInitRule(node) && !hasUtilityClassSelector(node.selector)) { - const declarationProps = new Set() - node.walkDecls((decl) => { - declarationProps.add(decl.prop) - }) - for (const selector of nodeSelectors) { - const generatedProps = generatedDeclarationPropsBySelector.get(selector) - if (!generatedProps) { - continue - } - for (const prop of generatedProps) { - declarationProps.delete(prop) - } - } - const nextRule = node.clone() - nextRule.walkDecls((decl) => { - if (!declarationProps.has(decl.prop)) { - decl.remove() - } - }) - if (nextRule.nodes.length > 0) { - preservedNodes.push(nextRule) - } - } - return - } - preservedNodes.push(node.clone()) - }) - if (preservedNodes.length === root.nodes.length) { - return css - } - const nextRoot = postcss.root() - nextRoot.append(preservedNodes) - return nextRoot.toString() - } - catch { - return css - } -} +export { + collectDedupedPostTransformCompatCss, + collectGeneratedSelectors, + normalizeCompatSelectors, + removeGeneratedSelectorCompatCss, +} from '@weapp-tailwindcss/postcss' export function removeDuplicatedViteMarkers(css: string, baseCss: string) { if (!VITE_MARKER_RE.test(baseCss)) { diff --git a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts index 7126d0d6c..c115070a4 100644 --- a/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts +++ b/packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-units.ts @@ -1,67 +1 @@ -import { postcss } from '@weapp-tailwindcss/postcss' -import { normalizeCompatSelectors } from './legacy-selectors' - -const CSS_LENGTH_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)(?:px|rem)\b/i -const RPX_UNIT_RE = /(?:^|[\s(,])[-+]?(?:\d+|\d*\.\d+)rpx\b/i - -function createLegacyDeclarationValueMap(css: string) { - const values = new Map() - const root = postcss.parse(css) - root.walkRules((rule) => { - if (!rule.selectors || rule.selectors.length === 0) { - return - } - for (const selector of rule.selectors) { - const normalizedSelectors = normalizeCompatSelectors(selector) - rule.walkDecls((decl) => { - if (RPX_UNIT_RE.test(decl.value)) { - for (const normalizedSelector of normalizedSelectors) { - values.set(`${normalizedSelector}\n${decl.prop}`, decl.value) - } - } - }) - } - }) - return values -} - -export function inheritLegacyUnitConvertedDeclarations(css: string, legacyCss: string) { - try { - const legacyValues = createLegacyDeclarationValueMap(legacyCss) - if (legacyValues.size === 0) { - return css - } - - const root = postcss.parse(css) - let changed = false - root.walkRules((rule) => { - if (!rule.selectors || rule.selectors.length === 0) { - return - } - const selectors = rule.selectors - .flatMap(selector => normalizeCompatSelectors(selector)) - if (selectors.length === 0) { - return - } - - rule.walkDecls((decl) => { - if (!CSS_LENGTH_UNIT_RE.test(decl.value)) { - return - } - for (const selector of selectors) { - const legacyValue = legacyValues.get(`${selector}\n${decl.prop}`) - if (legacyValue && legacyValue !== decl.value) { - decl.value = legacyValue - changed = true - return - } - } - }) - }) - - return changed ? root.toString() : css - } - catch { - return css - } -} +export { inheritLegacyUnitConvertedDeclarations } from '@weapp-tailwindcss/postcss' diff --git a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts index 0e729bffb..028e4dfea 100644 --- a/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts +++ b/packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator/css-compat.ts @@ -2,7 +2,7 @@ import type { TailwindV4GenerateTarget, TailwindV4ResolvedSource } from '../type import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' -import { parseCssImportSpecifier, postcss } from '@weapp-tailwindcss/postcss' +import { parseCssImportSpecifier, postcss, removeTailwindV4PreflightImports, removeUnsupportedThemeVendorKeyframes } from '@weapp-tailwindcss/postcss' import { createTailwindV4DefaultColorThemeCss } from '../tailwind-v4-default-colors' const require = createRequire(import.meta.url) @@ -83,74 +83,6 @@ function applyMiniProgramTailwindV4DefaultColorCss(css: string, source: Tailwind return `${css.slice(0, insertionIndex)}\n${themeCss}\n${css.slice(insertionIndex)}` } -function isTailwindCssPreflightImport(params: string) { - const specifier = parseCssImportSpecifier(params)?.specifier - return specifier === 'tailwindcss/preflight.css' || specifier === 'tailwindcss/preflight' -} - -function removeTailwindV4PreflightImports(css: string) { - if (!css.includes('preflight')) { - return css - } - - let root: postcss.Root - try { - root = postcss.parse(css) - } - catch { - return css - } - - let changed = false - root.walkAtRules('import', (rule) => { - if (isTailwindCssPreflightImport(rule.params)) { - rule.remove() - changed = true - } - }) - - return changed ? root.toString() : css -} - -function hasThemeParent(rule: postcss.AtRule) { - let parent = rule.parent as postcss.Container | undefined - while (parent) { - if (parent.type === 'atrule' && (parent as postcss.AtRule).name === 'theme') { - return true - } - parent = parent.parent as postcss.Container | undefined - } - return false -} - -function isVendorPrefixedKeyframes(rule: postcss.AtRule) { - return rule.name.startsWith('-') && rule.name.endsWith('keyframes') -} - -function removeUnsupportedThemeVendorKeyframes(css: string) { - if (!css.includes('@theme') || !css.includes('@-')) { - return css - } - - let root: postcss.Root - try { - root = postcss.parse(css) - } - catch { - return css - } - - let changed = false - root.walkAtRules((rule) => { - if (isVendorPrefixedKeyframes(rule) && hasThemeParent(rule)) { - rule.remove() - changed = true - } - }) - - return changed ? root.toString() : css -} - export function createCompatibleSource( source: TailwindV4ResolvedSource, target: TailwindV4GenerateTarget, diff --git a/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts b/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts index 2002711f4..4ff5a8f06 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/border-preflight.ts @@ -2,43 +2,17 @@ import type { ElementNode } from '@vue/compiler-dom' import type MagicString from 'magic-string' import type { CssPreflightOptions } from '@/types' import { NodeTypes } from '@vue/compiler-dom' -import { createInjectPreflight, postcss } from '@weapp-tailwindcss/postcss' +import { + createUniAppXBorderPreflight, + UNI_APP_X_BORDER_PREFLIGHT_CLASS, +} from '@weapp-tailwindcss/postcss' -export const BORDER_PREFLIGHT_CLASS = 'weapp-tw-border' +export { + createUniAppXBorderPreflight, + hoistUniAppXBorderPreflight, +} from '@weapp-tailwindcss/postcss' -/** 框架回放组件样式后恢复基础规则的顺序,确保作者 class 可以覆盖重置。 */ -export function hoistUniAppXBorderPreflight(css: string) { - if (!css.includes(BORDER_PREFLIGHT_CLASS)) { - return css - } - const root = postcss.parse(css) - const resets = root.nodes.filter(node => node.type === 'rule' && node.selector === `.${BORDER_PREFLIGHT_CLASS}`) - const anchor = root.nodes.find(node => !resets.includes(node) - && node.type !== 'comment' - && !(node.type === 'atrule' && ['charset', 'import'].includes(node.name))) - if (!anchor || resets.length === 0) { - return css - } - for (const reset of resets) { - reset.remove() - root.insertBefore(anchor, reset) - } - return root.toString() -} - -/** uni-app x 移除通配符 preflight 后,用独立基础类承载用户配置的边框默认值。 */ -export function createUniAppXBorderPreflight(options?: CssPreflightOptions) { - const declarations = createInjectPreflight(options)() - .filter(({ prop }) => prop === 'border' || prop.startsWith('border-')) - if (declarations.length === 0) { - return undefined - } - const rule = postcss.rule({ selector: `.${BORDER_PREFLIGHT_CLASS}` }) - for (const declaration of declarations) { - rule.append(postcss.decl(declaration)) - } - return rule.toString() -} +export const BORDER_PREFLIGHT_CLASS = UNI_APP_X_BORDER_PREFLIGHT_CLASS export function resolveUniAppXBorderPreflightOptions( options: { cssPreflight?: CssPreflightOptions, cssPreflightRange?: 'all' }, diff --git a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts index 1cb6db948..989cbdbcf 100644 --- a/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts +++ b/packages/weapp-tailwindcss/src/uni-app-x/style-asset/style-value.ts @@ -1,7 +1,13 @@ +import type { Root } from '@weapp-tailwindcss/postcss' import type { OutputChunk, SourceMap } from 'rollup' import { splitCandidateTokens } from '@tailwindcss-mangle/engine' -import { parseUniAppXStyleSource, postcss } from '@weapp-tailwindcss/postcss' -import { replaceWxml } from '@/wxml' +import { + collectCssApplyUtilities, + cssToClassStyleValue, + expandCssApplySourcesToStyleValue, + parseUniAppXStyleSource, + +} from '@weapp-tailwindcss/postcss' import { resolveStyleReferencePath } from '../style-reference-path' const GEN_APP_STYLES_RE = /const\s+GenAppStyles\s*=\s*\[_uM\(\[([\s\S]*?)\]\)\]/ @@ -9,7 +15,6 @@ const STYLE_ENTRY_RE = /\[\s*("((?:\\.|[^"\\])+)")\s*,\s*(_pS\(_uM\(\[[\s\S]*?\] const STRING_LITERAL_RE = /(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g const SFC_STYLE_BLOCK_RE = /]*>([\s\S]*?)<\/style>/gi const STYLE_EXPORT_PREFIX_RE = /^\s*export\s+default\s+/ -const CLASS_SELECTOR_PREFIX_RE = /^\.((?:\\[^\n\r\f]|[\w-])+)(?=$|[.:#[])/ const STRING_STYLE_PROPERTIES = new Set(['lineHeight']) type StyleDeclarations = Record @@ -60,10 +65,6 @@ function normalizeStyleValue(prop: string, value: string | number) { return normalizeValue(prop, value) } -function unescapeCssClassSelector(className: string) { - return className.replace(/\\([^\n\r\f0-9a-f])/gi, '$1') -} - export function parseStyleExport(source: string): StyleValue | undefined { const json = source.replace(STYLE_EXPORT_PREFIX_RE, '').trim() if (!json) { @@ -187,41 +188,10 @@ export function createUtsStyleArrayFromAppStyles(code: string, appSource?: strin return createUtsStyleArray([...used].map(className => entries.get(className)!).filter(Boolean)) } -function cssToStyleExport(source: string): StyleValue | undefined { - let root: postcss.Root - try { - root = postcss.parse(source) - } - catch { - return - } - const result: StyleValue = {} - root.walkRules((rule) => { - const selectors = rule.selectors ?? [] - for (const selector of selectors) { - const match = selector.trim().match(CLASS_SELECTOR_PREFIX_RE) - if (!match?.[1]) { - continue - } - const declarations: Record = {} - rule.walkDecls((decl) => { - declarations[toCamelCase(decl.prop)] = normalizeValue(decl.prop, decl.value) - }) - if (Object.keys(declarations).length > 0) { - result[match[1]] = { '': declarations } - const className = unescapeCssClassSelector(match[1]) - result[className] = { '': declarations } - result[replaceWxml(className)] = { '': declarations } - } - } - }) - return Object.keys(result).length > 0 ? result : undefined -} - export function cssSourceToStyleValue(source: string) { return STYLE_EXPORT_PREFIX_RE.test(source) ? parseStyleExport(source) - : cssToStyleExport(source) + : cssToClassStyleValue(source) } export function mergeStyleValues(...items: Array) { @@ -249,48 +219,18 @@ export function createStyleValueFromApplySources(sources: string[], utilityStyle ? [...source.matchAll(SFC_STYLE_BLOCK_RE)].map(styleBlock => styleBlock[1] ?? '') : [source] for (const styleSource of styleSources) { - let root: postcss.Root - try { - root = parseUniAppXStyleSource(styleSource) - } - catch { + const applied = expandCssApplySourcesToStyleValue(styleSource, utilityStyles) + if (!applied) { continue } - root.walkRules((rule) => { - const applyRules = rule.nodes?.filter((node): node is postcss.AtRule => node.type === 'atrule' && node.name === 'apply') ?? [] - if (applyRules.length === 0) { - return - } - const selectors = rule.selectors ?? [rule.selector] - for (const selector of selectors) { - const className = selector.trim().match(CLASS_SELECTOR_PREFIX_RE)?.[1] - if (!className) { - continue - } - const declarations: Record = {} - for (const applyRule of applyRules) { - for (const utility of splitCandidateTokens(applyRule.params)) { - const utilityDeclarations = utilityStyles[utility]?.[''] ?? utilityStyles[replaceWxml(utility)]?.[''] - if (utilityDeclarations) { - Object.assign(declarations, utilityDeclarations) - } - } - } - if (Object.keys(declarations).length > 0) { - const unescapedClassName = unescapeCssClassSelector(className) - result[className] = { '': declarations } - result[unescapedClassName] = { '': declarations } - result[replaceWxml(unescapedClassName)] = { '': declarations } - } - } - }) + Object.assign(result, applied) } } return Object.keys(result).length > 0 ? result : undefined } function resolveReferencePaths(styleSource: string, sourceId?: string) { - let root: postcss.Root + let root: Root try { root = parseUniAppXStyleSource(styleSource) } @@ -333,18 +273,9 @@ export function collectUniAppXHarmonyApplyUtilitiesFromSources(sources: Iterable const utilities = new Set() for (const source of sources) { for (const styleSource of collectUniAppXHarmonyApplyStyleSourcesFromSource(source)) { - let root: postcss.Root - try { - root = parseUniAppXStyleSource(styleSource) - } - catch { - continue + for (const utility of collectCssApplyUtilities(styleSource)) { + utilities.add(utility) } - root.walkAtRules('apply', (rule) => { - for (const utility of splitCandidateTokens(rule.params)) { - utilities.add(utility) - } - }) } } return utilities