diff --git a/CHANGELOG.md b/CHANGELOG.md index abd79d9395f9..cab1a1c33f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update list of length units ([#10100](https://github.com/tailwindlabs/tailwindcss/pull/10100)) - Fix not matching arbitrary properties when closely followed by square brackets ([#10212](https://github.com/tailwindlabs/tailwindcss/pull/10212)) - Allow direct nesting in `root` or `@layer` nodes ([#10229](https://github.com/tailwindlabs/tailwindcss/pull/10229)) +- Don't prefix classes in arbitrary variants ([#10214](https://github.com/tailwindlabs/tailwindcss/pull/10214)) ### Changed diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 636b9eaac0d4..c33276464e59 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -201,6 +201,7 @@ function applyVariant(variant, matches, context) { } if (context.variantMap.has(variant)) { + let isArbitraryVariant = isArbitraryValue(variant) let variantFunctionTuples = context.variantMap.get(variant).slice() let result = [] @@ -262,7 +263,10 @@ function applyVariant(variant, matches, context) { clone.append(wrapper) }, format(selectorFormat) { - collectedFormats.push(selectorFormat) + collectedFormats.push({ + format: selectorFormat, + isArbitraryVariant, + }) }, args, }) @@ -288,7 +292,10 @@ function applyVariant(variant, matches, context) { } if (typeof ruleWithVariant === 'string') { - collectedFormats.push(ruleWithVariant) + collectedFormats.push({ + format: ruleWithVariant, + isArbitraryVariant, + }) } if (ruleWithVariant === null) { @@ -329,7 +336,10 @@ function applyVariant(variant, matches, context) { // modified (by plugin): .foo .foo\\:markdown > p // rebuiltBase (internal): .foo\\:markdown > p // format: .foo & - collectedFormats.push(modified.replace(rebuiltBase, '&')) + collectedFormats.push({ + format: modified.replace(rebuiltBase, '&'), + isArbitraryVariant, + }) rule.selector = before }) } @@ -349,7 +359,6 @@ function applyVariant(variant, matches, context) { Object.assign(args, context.variantOptions.get(variant)) ), collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), - isArbitraryVariant: isArbitraryValue(variant), }, clone.nodes[0], ] @@ -733,48 +742,15 @@ function* resolveMatches(candidate, context, original = candidate) { } for (let match of matches) { - let isValid = true - match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate } // Apply final format selector - if (match[0].collectedFormats) { - let finalFormat = formatVariantSelector('&', ...match[0].collectedFormats) - let container = postcss.root({ nodes: [match[1].clone()] }) - container.walkRules((rule) => { - if (inKeyframes(rule)) return - - let selectorOptions = { - selector: rule.selector, - candidate: original, - base: candidate - .split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`)) - .pop(), - isArbitraryVariant: match[0].isArbitraryVariant, - - context, - } - - try { - rule.selector = finalizeSelector(finalFormat, selectorOptions) - } catch { - // The selector we produced is invalid - // This could be because: - // - A bug exists - // - A plugin introduced an invalid variant selector (ex: `addVariant('foo', '&;foo')`) - // - The user used an invalid arbitrary variant (ex: `[&;foo]:underline`) - // Either way the build will fail because of this - // We would rather that the build pass "silently" given that this could - // happen because of picking up invalid things when scanning content - // So we'll throw out the candidate instead - isValid = false - return false - } - }) - match[1] = container.nodes[0] - } + match = applyFinalFormat(match, { context, candidate, original }) - if (!isValid) { + // Skip rules with invalid selectors + // This will cause the candidate to be added to the "not class" + // cache skipping it entirely for future builds + if (match === null) { continue } @@ -783,6 +759,62 @@ function* resolveMatches(candidate, context, original = candidate) { } } +function applyFinalFormat(match, { context, candidate, original }) { + if (!match[0].collectedFormats) { + return match + } + + let isValid = true + let finalFormat + + try { + finalFormat = formatVariantSelector(match[0].collectedFormats, { + context, + candidate, + }) + } catch { + // The format selector we produced is invalid + // This could be because: + // - A bug exists + // - A plugin introduced an invalid variant selector (ex: `addVariant('foo', '&;foo')`) + // - The user used an invalid arbitrary variant (ex: `[&;foo]:underline`) + // Either way the build will fail because of this + // We would rather that the build pass "silently" given that this could + // happen because of picking up invalid things when scanning content + // So we'll throw out the candidate instead + + return null + } + + let container = postcss.root({ nodes: [match[1].clone()] }) + + container.walkRules((rule) => { + if (inKeyframes(rule)) { + return + } + + try { + rule.selector = finalizeSelector(rule.selector, finalFormat, { + candidate: original, + context, + }) + } catch { + // If this selector is invalid we also want to skip it + // But it's likely that being invalid here means there's a bug in a plugin rather than too loosely matching content + isValid = false + return false + } + }) + + if (!isValid) { + return null + } + + match[1] = container.nodes[0] + + return match +} + function inKeyframes(rule) { return rule.parent && rule.parent.type === 'atrule' && rule.parent.name === 'keyframes' } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 0912ffab7467..15d22a347f6d 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -1080,20 +1080,38 @@ function registerPlugins(plugins, context) { }) } - let result = formatStrings.map((formatString) => - finalizeSelector(formatVariantSelector('&', ...formatString), { - selector: `.${candidate}`, - candidate, - context, - isArbitraryVariant: !(value in (options.values ?? {})), - }) + let isArbitraryVariant = !(value in (options.values ?? {})) + + formatStrings = formatStrings.map((format) => + format.map((str) => ({ + format: str, + isArbitraryVariant, + })) + ) + + manualFormatStrings = manualFormatStrings.map((format) => ({ + format, + isArbitraryVariant, + })) + + let opts = { + candidate, + context, + } + + let result = formatStrings.map((formats) => + finalizeSelector(`.${candidate}`, formatVariantSelector(formats, opts), opts) .replace(`.${candidate}`, '&') .replace('{ & }', '') .trim() ) if (manualFormatStrings.length > 0) { - result.push(formatVariantSelector('&', ...manualFormatStrings)) + result.push( + formatVariantSelector(manualFormatStrings, opts) + .toString() + .replace(`.${candidate}`, '&') + ) } return result diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js index 79c94fae8385..38a8bfbc76fc 100644 --- a/src/util/formatVariantSelector.js +++ b/src/util/formatVariantSelector.js @@ -3,30 +3,57 @@ import unescape from 'postcss-selector-parser/dist/util/unesc' import escapeClassName from '../util/escapeClassName' import prefixSelector from '../util/prefixSelector' +/** @typedef {import('postcss-selector-parser').Root} Root */ +/** @typedef {import('postcss-selector-parser').Selector} Selector */ +/** @typedef {import('postcss-selector-parser').Pseudo} Pseudo */ +/** @typedef {import('postcss-selector-parser').Node} Node */ + +/** @typedef {{format: string, isArbitraryVariant: boolean}[]} RawFormats */ +/** @typedef {import('postcss-selector-parser').Root} ParsedFormats */ +/** @typedef {RawFormats | ParsedFormats} AcceptedFormats */ + let MERGE = ':merge' -let PARENT = '&' - -export let selectorFunctions = new Set([MERGE]) - -export function formatVariantSelector(current, ...others) { - for (let other of others) { - let incomingValue = resolveFunctionArgument(other, MERGE) - if (incomingValue !== null) { - let existingValue = resolveFunctionArgument(current, MERGE, incomingValue) - if (existingValue !== null) { - let existingTarget = `${MERGE}(${incomingValue})` - let splitIdx = other.indexOf(existingTarget) - let addition = other.slice(splitIdx + existingTarget.length).split(' ')[0] - - current = current.replace(existingTarget, existingTarget + addition) - continue - } + +/** + * @param {RawFormats} formats + * @param {{context: any, candidate: string, base: string | null}} options + * @returns {ParsedFormats | null} + */ +export function formatVariantSelector(formats, { context, candidate }) { + let prefix = context?.tailwindConfig.prefix ?? '' + + // Parse the format selector into an AST + let parsedFormats = formats.map((format) => { + let ast = selectorParser().astSync(format.format) + + return { + ...format, + ast: format.isArbitraryVariant ? ast : prefixSelector(prefix, ast), } + }) - current = other.replace(PARENT, current) + // We start with the candidate selector + let formatAst = selectorParser.root({ + nodes: [ + selectorParser.selector({ + nodes: [selectorParser.className({ value: escapeClassName(candidate) })], + }), + ], + }) + + // And iteratively merge each format selector into the candidate selector + for (let { ast } of parsedFormats) { + // 1. Handle :merge() special pseudo-class + ;[formatAst, ast] = handleMergePseudo(formatAst, ast) + + // 2. Merge the format selector into the current selector AST + ast.walkNesting((nesting) => nesting.replaceWith(...formatAst.nodes[0].nodes)) + + // 3. Keep going! + formatAst = ast } - return current + return formatAst } /** @@ -35,11 +62,11 @@ export function formatVariantSelector(current, ...others) { * Technically :is(), :not(), :has(), etc… can have combinators but those are nested * inside the relevant node and won't be picked up so they're fine to ignore * - * @param {import('postcss-selector-parser').Node} node - * @returns {import('postcss-selector-parser').Node[]} + * @param {Node} node + * @returns {Node[]} **/ function simpleSelectorForNode(node) { - /** @type {import('postcss-selector-parser').Node[]} */ + /** @type {Node[]} */ let nodes = [] // Walk backwards until we hit a combinator node (or the start) @@ -60,8 +87,8 @@ function simpleSelectorForNode(node) { * Resorts the nodes in a selector to ensure they're in the correct order * Tags go before classes, and pseudo classes go after classes * - * @param {import('postcss-selector-parser').Selector} sel - * @returns {import('postcss-selector-parser').Selector} + * @param {Selector} sel + * @returns {Selector} **/ function resortSelector(sel) { sel.sort((a, b) => { @@ -81,6 +108,18 @@ function resortSelector(sel) { return sel } +/** + * Remove extraneous selectors that do not include the base class/candidate + * + * Example: + * Given the utility `.a, .b { color: red}` + * Given the candidate `sm:b` + * + * The final selector should be `.sm\:b` and not `.a, .sm\:b` + * + * @param {Selector} ast + * @param {string} base + */ function eliminateIrrelevantSelectors(sel, base) { let hasClassesMatchingCandidate = false @@ -104,41 +143,26 @@ function eliminateIrrelevantSelectors(sel, base) { // TODO: Can we do this for :matches, :is, and :where? } -export function finalizeSelector( - format, - { - selector, - candidate, - context, - isArbitraryVariant, - - // Split by the separator, but ignore the separator inside square brackets: - // - // E.g.: dark:lg:hover:[paint-order:markers] - // ┬ ┬ ┬ ┬ - // │ │ │ ╰── We will not split here - // ╰──┴─────┴─────────────── We will split here - // - base = candidate - .split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`)) - .pop(), - } -) { - let ast = selectorParser().astSync(selector) - - // We explicitly DO NOT prefix classes in arbitrary variants - if (context?.tailwindConfig?.prefix && !isArbitraryVariant) { - format = prefixSelector(context.tailwindConfig.prefix, format) - } - - format = format.replace(PARENT, `.${escapeClassName(candidate)}`) - - let formatAst = selectorParser().astSync(format) +/** + * @param {string} current + * @param {AcceptedFormats} formats + * @param {{context: any, candidate: string, base: string | null}} options + * @returns {string} + */ +export function finalizeSelector(current, formats, { context, candidate, base }) { + let separator = context?.tailwindConfig?.separator ?? ':' + + // Split by the separator, but ignore the separator inside square brackets: + // + // E.g.: dark:lg:hover:[paint-order:markers] + // ┬ ┬ ┬ ┬ + // │ │ │ ╰── We will not split here + // ╰──┴─────┴─────────────── We will split here + // + base = base ?? candidate.split(new RegExp(`\\${separator}(?![^[]*\\])`)).pop() - // Remove extraneous selectors that do not include the base class/candidate being matched against - // For example if we have a utility defined `.a, .b { color: red}` - // And the formatted variant is sm:b then we want the final selector to be `.sm\:b` and not `.a, .sm\:b` - ast.each((sel) => eliminateIrrelevantSelectors(sel, base)) + // Parse the selector into an AST + let selector = selectorParser().astSync(current) // Normalize escaped classes, e.g.: // @@ -151,18 +175,31 @@ export function finalizeSelector( // base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\] // escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\] // - ast.walkClasses((node) => { + selector.walkClasses((node) => { if (node.raws && node.value.includes(base)) { node.raws.value = escapeClassName(unescape(node.raws.value)) } }) + // Remove extraneous selectors that do not include the base candidate + selector.each((sel) => eliminateIrrelevantSelectors(sel, base)) + + // If there are no formats that means there were no variants added to the candidate + // so we can just return the selector as-is + let formatAst = Array.isArray(formats) + ? formatVariantSelector(formats, { context, candidate }) + : formats + + if (formatAst === null) { + return selector.toString() + } + let simpleStart = selectorParser.comment({ value: '/*__simple__*/' }) let simpleEnd = selectorParser.comment({ value: '/*__simple__*/' }) // We can safely replace the escaped base now, since the `base` section is // now in a normalized escaped value. - ast.walkClasses((node) => { + selector.walkClasses((node) => { if (node.value !== base) { return } @@ -200,47 +237,86 @@ export function finalizeSelector( simpleEnd.remove() }) - // This will make sure to move pseudo's to the correct spot (the end for - // pseudo elements) because otherwise the selector will never work - // anyway. - // - // E.g.: - // - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before` - // - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before` - // - // `::before:hover` doesn't work, which means that we can make it work for you by flipping the order. - function collectPseudoElements(selector) { - let nodes = [] - - for (let node of selector.nodes) { - if (isPseudoElement(node)) { - nodes.push(node) - selector.removeChild(node) - } - - if (node?.nodes) { - nodes.push(...collectPseudoElements(node)) - } + // Remove unnecessary pseudo selectors that we used as placeholders + selector.walkPseudos((p) => { + if (p.value === MERGE) { + p.replaceWith(p.nodes) } + }) - return nodes - } - - // Remove unnecessary pseudo selectors that we used as placeholders - ast.each((selector) => { - selector.walkPseudos((p) => { - if (selectorFunctions.has(p.value)) { - p.replaceWith(p.nodes) - } - }) - - let pseudoElements = collectPseudoElements(selector) + // Move pseudo elements to the end of the selector (if necessary) + selector.each((sel) => { + let pseudoElements = collectPseudoElements(sel) if (pseudoElements.length > 0) { - selector.nodes.push(pseudoElements.sort(sortSelector)) + sel.nodes.push(pseudoElements.sort(sortSelector)) + } + }) + + return selector.toString() +} + +/** + * + * @param {Selector} selector + * @param {Selector} format + */ +export function handleMergePseudo(selector, format) { + /** @type {{pseudo: Pseudo, value: string}[]} */ + let merges = [] + + // Find all :merge() pseudo-classes in `selector` + selector.walkPseudos((pseudo) => { + if (pseudo.value === MERGE) { + merges.push({ + pseudo, + value: pseudo.nodes[0].toString(), + }) } }) - return ast.toString() + // Find all :merge() "attachments" in `format` and attach them to the matching selector in `selector` + format.walkPseudos((pseudo) => { + if (pseudo.value !== MERGE) { + return + } + + let value = pseudo.nodes[0].toString() + + // Does `selector` contain a :merge() pseudo-class with the same value? + let existing = merges.find((merge) => merge.value === value) + + // Nope so there's nothing to do + if (!existing) { + return + } + + // Everything after `:merge()` up to the next combinator is what is attached to the merged selector + let attachments = [] + let next = pseudo.next() + while (next && next.type !== 'combinator') { + attachments.push(next) + next = next.next() + } + + let combinator = next + + existing.pseudo.parent.insertAfter( + existing.pseudo, + selectorParser.selector({ nodes: attachments.map((node) => node.clone()) }) + ) + + pseudo.remove() + attachments.forEach((node) => node.remove()) + + // What about this case: + // :merge(.group):focus > & + // :merge(.group):hover & + if (combinator && combinator.type === 'combinator') { + combinator.remove() + } + }) + + return [selector, format] } // Note: As a rule, double colons (::) should be used instead of a single colon @@ -263,6 +339,37 @@ let pseudoElementExceptions = [ '::-webkit-resizer', ] +/** + * This will make sure to move pseudo's to the correct spot (the end for + * pseudo elements) because otherwise the selector will never work + * anyway. + * + * E.g.: + * - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before` + * - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before` + * + * `::before:hover` doesn't work, which means that we can make it work for you by flipping the order. + * + * @param {Selector} selector + **/ +function collectPseudoElements(selector) { + /** @type {Node[]} */ + let nodes = [] + + for (let node of selector.nodes) { + if (isPseudoElement(node)) { + nodes.push(node) + selector.removeChild(node) + } + + if (node?.nodes) { + nodes.push(...collectPseudoElements(node)) + } + } + + return nodes +} + // This will make sure to move pseudo's to the correct spot (the end for // pseudo elements) because otherwise the selector will never work // anyway. @@ -303,28 +410,3 @@ function isPseudoElement(node) { return node.value.startsWith('::') || pseudoElementsBC.includes(node.value) } - -function resolveFunctionArgument(haystack, needle, arg) { - let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle) - if (startIdx === -1) return null - - // Start inside the `(` - startIdx += needle.length + 1 - - let target = '' - let count = 0 - - for (let char of haystack.slice(startIdx)) { - if (char !== '(' && char !== ')') { - target += char - } else if (char === '(') { - target += char - count++ - } else if (char === ')') { - if (--count < 0) break // unbalanced - target += char - } - } - - return target -} diff --git a/src/util/prefixSelector.js b/src/util/prefixSelector.js index 34ce7d57c413..0e7bb445bdd9 100644 --- a/src/util/prefixSelector.js +++ b/src/util/prefixSelector.js @@ -1,14 +1,32 @@ import parser from 'postcss-selector-parser' +/** + * @template {string | import('postcss-selector-parser').Root} T + * + * Prefix all classes in the selector with the given prefix + * + * It can take either a string or a selector AST and will return the same type + * + * @param {string} prefix + * @param {T} selector + * @param {boolean} prependNegative + * @returns {T} + */ export default function (prefix, selector, prependNegative = false) { - return parser((selectors) => { - selectors.walkClasses((classSelector) => { - let baseClass = classSelector.value - let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-') - - classSelector.value = shouldPlaceNegativeBeforePrefix - ? `-${prefix}${baseClass.slice(1)}` - : `${prefix}${baseClass}` - }) - }).processSync(selector) + if (prefix === '') { + return selector + } + + let ast = typeof selector === 'string' ? parser().astSync(selector) : selector + + ast.walkClasses((classSelector) => { + let baseClass = classSelector.value + let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-') + + classSelector.value = shouldPlaceNegativeBeforePrefix + ? `-${prefix}${baseClass.slice(1)}` + : `${prefix}${baseClass}` + }) + + return typeof selector === 'string' ? ast.toString() : ast } diff --git a/tests/arbitrary-variants.test.js b/tests/arbitrary-variants.test.js index 80d854f03c93..5ef6520f7248 100644 --- a/tests/arbitrary-variants.test.js +++ b/tests/arbitrary-variants.test.js @@ -542,6 +542,14 @@ test('classes in arbitrary variants should not be prefixed', () => {