From 7d8eb21de6e9dc81760184a75675e06b7599e52f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 3 Jan 2023 09:40:47 -0500 Subject: [PATCH] Don't prefix classes in arbitrary variants (#10214) * Add tests * Refactor refactor * Allow `prefixSelector` to take an AST * Consider multiple formats in `finalizeSelector` The functions `finalizeSelector` and `formatVariantSelector` together were using a mix for AST and string-based parsing. This now does the full transformation using the selector AST. This also parses the format strings AST as early as possible and is set up to parse them only once for a given set of rules. All of this will allow considering metadata per format string. For instance, we now know if the format string `.foo &` was produced by a normal variant or by an arbitrary variant. We use this information to control the prefixing behavior for individual format strings. * Update changelog * Cleanup code a bit --- CHANGELOG.md | 1 + src/lib/generateRules.js | 116 ++++++---- src/lib/setupContextUtils.js | 34 ++- src/util/formatVariantSelector.js | 322 ++++++++++++++++---------- src/util/prefixSelector.js | 38 ++- tests/arbitrary-variants.test.js | 17 +- tests/format-variant-selector.test.js | 208 +++++++++++------ 7 files changed, 487 insertions(+), 249 deletions(-) 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', () => {
should not be red
should be red
+
+
should not be red
+
should be red
+
+
+
should not be red
+
should be red
+
`, }, ], @@ -558,7 +566,14 @@ test('classes in arbitrary variants should not be prefixed', () => { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity)); } - + .hover\:\[\&_\.foo\]\:tw-text-red-400 .foo:hover { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); + } + .\[\&_\.foo\]\:hover\:tw-text-red-400:hover .foo { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); + } .foo .\[\.foo_\&\]\:tw-text-red-400 { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity)); diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js index 94e86ddfcab7..91bff419dcd8 100644 --- a/tests/format-variant-selector.test.js +++ b/tests/format-variant-selector.test.js @@ -1,23 +1,24 @@ -import { formatVariantSelector, finalizeSelector } from '../src/util/formatVariantSelector' +import { finalizeSelector } from '../src/util/formatVariantSelector' it('should be possible to add a simple variant to a simple selector', () => { let selector = '.text-center' let candidate = 'hover:text-center' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( - '.hover\\:text-center:hover' - ) + expect(finalizeSelector(selector, formats, { candidate })).toEqual('.hover\\:text-center:hover') }) it('should be possible to add a multiple simple variants to a simple selector', () => { let selector = '.text-center' let candidate = 'focus:hover:text-center' - let variants = ['&:hover', '&:focus'] + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, + { format: '&:focus', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.focus\\:hover\\:text-center:hover:focus' ) }) @@ -26,9 +27,9 @@ it('should be possible to add a simple variant to a selector containing escaped let selector = '.bg-\\[rgba\\(0\\,0\\,0\\)\\]' let candidate = 'hover:bg-[rgba(0,0,0)]' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover' ) }) @@ -37,9 +38,9 @@ it('should be possible to add a simple variant to a selector containing escaped let selector = '.bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]' let candidate = 'hover:bg-[rgba(0,0,0)]' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover' ) }) @@ -48,9 +49,9 @@ it('should be possible to add a simple variant to a more complex selector', () = let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'hover:space-x-4' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])' ) }) @@ -59,9 +60,13 @@ it('should be possible to add multiple simple variants to a more complex selecto let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'disabled:focus:hover:space-x-4' - let variants = ['&:hover', '&:focus', '&:disabled'] + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, + { format: '&:focus', isArbitraryVariant: false }, + { format: '&:disabled', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.disabled\\:focus\\:hover\\:space-x-4:hover:focus:disabled > :not([hidden]) ~ :not([hidden])' ) }) @@ -70,9 +75,9 @@ it('should be possible to add a single merge variant to a simple selector', () = let selector = '.text-center' let candidate = 'group-hover:text-center' - let variants = [':merge(.group):hover &'] + let formats = [{ format: ':merge(.group):hover &', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .group-hover\\:text-center' ) }) @@ -81,9 +86,12 @@ it('should be possible to add multiple merge variants to a simple selector', () let selector = '.text-center' let candidate = 'group-focus:group-hover:text-center' - let variants = [':merge(.group):hover &', ':merge(.group):focus &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.group):focus &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:focus:hover .group-focus\\:group-hover\\:text-center' ) }) @@ -92,9 +100,9 @@ it('should be possible to add a single merge variant to a more complex selector' let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])' let candidate = 'group-hover:space-x-4' - let variants = [':merge(.group):hover &'] + let formats = [{ format: ':merge(.group):hover &', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])' ) }) @@ -103,9 +111,12 @@ it('should be possible to add multiple merge variants to a more complex selector let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])' let candidate = 'group-focus:group-hover:space-x-4' - let variants = [':merge(.group):hover &', ':merge(.group):focus &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.group):focus &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:focus:hover .group-focus\\:group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])' ) }) @@ -114,9 +125,12 @@ it('should be possible to add multiple unique merge variants to a simple selecto let selector = '.text-center' let candidate = 'peer-focus:group-hover:text-center' - let variants = [':merge(.group):hover &', ':merge(.peer):focus ~ &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.peer):focus ~ &' }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.peer:focus ~ .group:hover .peer-focus\\:group-hover\\:text-center' ) }) @@ -125,37 +139,41 @@ it('should be possible to add multiple unique merge variants to a simple selecto let selector = '.text-center' let candidate = 'group-hover:peer-focus:text-center' - let variants = [':merge(.peer):focus ~ &', ':merge(.group):hover &'] + let formats = [ + { format: ':merge(.peer):focus ~ &', isArbitraryVariant: false }, + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .peer:focus ~ .group-hover\\:peer-focus\\:text-center' ) }) it('should be possible to use multiple :merge() calls with different "arguments"', () => { - let result = '&' - result = formatVariantSelector(result, ':merge(.group):hover &') - expect(result).toEqual(':merge(.group):hover &') - - result = formatVariantSelector(result, ':merge(.peer):hover ~ &') - expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):hover &') - - result = formatVariantSelector(result, ':merge(.group):focus &') - expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):focus:hover &') + let selector = '.foo' + let candidate = 'peer-focus:group-focus:peer-hover:group-hover:foo' + + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.peer):hover ~ &', isArbitraryVariant: false }, + { format: ':merge(.group):focus &', isArbitraryVariant: false }, + { format: ':merge(.peer):focus ~ &', isArbitraryVariant: false }, + ] - result = formatVariantSelector(result, ':merge(.peer):focus ~ &') - expect(result).toEqual(':merge(.peer):focus:hover ~ :merge(.group):focus:hover &') + expect(finalizeSelector(selector, formats, { candidate })).toEqual( + '.peer:focus:hover ~ .group:focus:hover .peer-focus\\:group-focus\\:peer-hover\\:group-hover\\:foo' + ) }) it('group hover and prose headings combination', () => { let selector = '.text-center' let candidate = 'group-hover:prose-headings:text-center' - let variants = [ - ':where(&) :is(h1, h2, h3, h4)', // Prose Headings - ':merge(.group):hover &', // Group Hover + let formats = [ + { format: ':where(&) :is(h1, h2, h3, h4)', isArbitraryVariant: false }, // Prose Headings + { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group Hover ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover :where(.group-hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4)' ) }) @@ -163,12 +181,12 @@ it('group hover and prose headings combination', () => { it('group hover and prose headings combination flipped', () => { let selector = '.text-center' let candidate = 'prose-headings:group-hover:text-center' - let variants = [ - ':merge(.group):hover &', // Group Hover - ':where(&) :is(h1, h2, h3, h4)', // Prose Headings + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group Hover + { format: ':where(&) :is(h1, h2, h3, h4)', isArbitraryVariant: false }, // Prose Headings ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( ':where(.group:hover .prose-headings\\:group-hover\\:text-center) :is(h1, h2, h3, h4)' ) }) @@ -176,28 +194,74 @@ it('group hover and prose headings combination flipped', () => { it('should be possible to handle a complex utility', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4' - let variants = [ - '&:hover', // Hover - '&:focus', // Focus - ':merge(.group):focus &', // Group focus - ':merge(.group):hover &', // Group hover - ':merge(.peer):first-child ~ &', // Peer first-child - ':merge(.peer):disabled ~ &', // Peer disabled + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, // Hover + { format: '&:focus', isArbitraryVariant: false }, // Focus + { format: ':merge(.group):focus &', isArbitraryVariant: false }, // Group focus + { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group hover + { format: ':merge(.peer):first-child ~ &', isArbitraryVariant: false }, // Peer first-child + { format: ':merge(.peer):disabled ~ &', isArbitraryVariant: false }, // Peer disabled ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.peer:disabled:first-child ~ .group:hover:focus .peer-disabled\\:peer-first-child\\:group-hover\\:group-focus\\:focus\\:hover\\:space-x-4:hover:focus > :not([hidden]) ~ :not([hidden])' ) }) +it('should match base utilities that are prefixed', () => { + let context = { tailwindConfig: { prefix: 'tw-' } } + let selector = '.tw-text-center' + let candidate = 'tw-text-center' + let formats = [] + + expect(finalizeSelector(selector, formats, { candidate, context })).toEqual('.tw-text-center') +}) + +it('should prefix classes from variants', () => { + let context = { tailwindConfig: { prefix: 'tw-' } } + let selector = '.tw-text-center' + let candidate = 'foo:tw-text-center' + let formats = [{ format: '.foo &', isArbitraryVariant: false }] + + expect(finalizeSelector(selector, formats, { candidate, context })).toEqual( + '.tw-foo .foo\\:tw-text-center' + ) +}) + +it('should not prefix classes from arbitrary variants', () => { + let context = { tailwindConfig: { prefix: 'tw-' } } + let selector = '.tw-text-center' + let candidate = '[.foo_&]:tw-text-center' + let formats = [{ format: '.foo &', isArbitraryVariant: true }] + + expect(finalizeSelector(selector, formats, { candidate, context })).toEqual( + '.foo .\\[\\.foo_\\&\\]\\:tw-text-center' + ) +}) + +it('Merged selectors with mixed combinators uses the first one', () => { + // This isn't explicitly specced behavior but it is how it works today + + let selector = '.text-center' + let candidate = 'text-center' + let formats = [ + { format: ':merge(.group):focus > &', isArbitraryVariant: true }, + { format: ':merge(.group):hover &', isArbitraryVariant: true }, + ] + + expect(finalizeSelector(selector, formats, { candidate })).toEqual( + '.group:hover:focus > .text-center' + ) +}) + describe('real examples', () => { it('example a', () => { let selector = '.placeholder-red-500::placeholder' let candidate = 'hover:placeholder-red-500' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:placeholder-red-500:hover::placeholder' ) }) @@ -206,9 +270,12 @@ describe('real examples', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'group-hover:hover:space-x-4' - let variants = ['&:hover', ':merge(.group):hover &'] + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .group-hover\\:hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])' ) }) @@ -217,9 +284,12 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'dark:group-hover:text-center' - let variants = [':merge(.group):hover &', '.dark &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: '.dark &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.dark .group:hover .dark\\:group-hover\\:text-center' ) }) @@ -228,9 +298,12 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'group-hover:dark:text-center' - let variants = ['.dark &', ':merge(.group):hover &'] + let formats = [ + { format: '.dark &' }, + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .dark .group-hover\\:dark\\:text-center' ) }) @@ -240,9 +313,9 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'hover:prose-headings:text-center' - let variants = [':where(&) :is(h1, h2, h3, h4)', '&:hover'] + let formats = [{ format: ':where(&) :is(h1, h2, h3, h4)' }, { format: '&:hover' }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( ':where(.hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4):hover' ) }) @@ -251,9 +324,9 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'prose-headings:hover:text-center' - let variants = ['&:hover', ':where(&) :is(h1, h2, h3, h4)'] + let formats = [{ format: '&:hover' }, { format: ':where(&) :is(h1, h2, h3, h4)' }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( ':where(.prose-headings\\:hover\\:text-center:hover) :is(h1, h2, h3, h4)' ) }) @@ -274,8 +347,7 @@ describe('pseudo elements', () => { ${':where(&::before) :is(h1, h2, h3, h4)'} | ${':where(&) :is(h1, h2, h3, h4)::before'} ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'} | ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'} `('should translate "$before" into "$after"', ({ before, after }) => { - let result = finalizeSelector(formatVariantSelector('&', before), { - selector: '.a', + let result = finalizeSelector('.a', [{ format: before, isArbitraryVariant: false }], { candidate: 'a', })