import selectorParser from 'postcss-selector-parser' import unescape from 'postcss-selector-parser/dist/util/unesc' import escapeClassName from '../util/escapeClassName' import prefixSelector from '../util/prefixSelector' import { movePseudos } from './pseudoElements' import { splitAtTopLevelOnly } from './splitAtTopLevelOnly' /** @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, respectPrefix: boolean}[]} RawFormats */ /** @typedef {import('postcss-selector-parser').Root} ParsedFormats */ /** @typedef {RawFormats | ParsedFormats} AcceptedFormats */ let MERGE = ':merge' /** * @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.respectPrefix ? prefixSelector(prefix, ast) : ast, } }) // 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 formatAst } /** * Given any node in a selector this gets the "simple" selector it's a part of * A simple selector is just a list of nodes without any combinators * 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 {Node} node * @returns {Node[]} **/ function simpleSelectorForNode(node) { /** @type {Node[]} */ let nodes = [] // Walk backwards until we hit a combinator node (or the start) while (node.prev() && node.prev().type !== 'combinator') { node = node.prev() } // Now record all non-combinator nodes until we hit one (or the end) while (node && node.type !== 'combinator') { nodes.push(node) node = node.next() } return nodes } /** * 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 {Selector} sel * @returns {Selector} **/ function resortSelector(sel) { sel.sort((a, b) => { if (a.type === 'tag' && b.type === 'class') { return -1 } else if (a.type === 'class' && b.type === 'tag') { return 1 } else if (a.type === 'class' && b.type === 'pseudo' && b.value.startsWith('::')) { return -1 } else if (a.type === 'pseudo' && a.value.startsWith('::') && b.type === 'class') { return 1 } return sel.index(a) - sel.index(b) }) 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 */ export function eliminateIrrelevantSelectors(sel, base) { let hasClassesMatchingCandidate = false sel.walk((child) => { if (child.type === 'class' && child.value === base) { hasClassesMatchingCandidate = true return false // Stop walking } }) if (!hasClassesMatchingCandidate) { sel.remove() } // We do NOT recursively eliminate sub selectors that don't have the base class // as this is NOT a safe operation. For example, if we have: // `.space-x-2 > :not([hidden]) ~ :not([hidden])` // We cannot remove the [hidden] from the :not() because it would change the // meaning of the selector. // TODO: Can we do this for :matches, :is, and :where? } /** * @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 ?? splitAtTopLevelOnly(candidate, separator).pop() // Parse the selector into an AST let selector = selectorParser().astSync(current) // Normalize escaped classes, e.g.: // // The idea would be to replace the escaped `base` in the selector with the // `format`. However, in css you can escape the same selector in a few // different ways. This would result in different strings and therefore we // can't replace it properly. // // base: bg-[rgb(255,0,0)] // base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\] // escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\] // 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 ffter eliminating irrelevant selectors, we end up with nothing // Then the whole "rule" this is associated with does not need to exist // We use `null` as a marker value for that case if (selector.length === 0) { return null } // 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. selector.walkClasses((node) => { if (node.value !== base) { return } let parent = node.parent let formatNodes = formatAst.nodes[0].nodes // Perf optimization: if the parent is a single class we can just replace it and be done if (parent.nodes.length === 1) { node.replaceWith(...formatNodes) return } let simpleSelector = simpleSelectorForNode(node) parent.insertBefore(simpleSelector[0], simpleStart) parent.insertAfter(simpleSelector[simpleSelector.length - 1], simpleEnd) for (let child of formatNodes) { parent.insertBefore(simpleSelector[0], child.clone()) } node.remove() // Re-sort the simple selector to ensure it's in the correct order simpleSelector = simpleSelectorForNode(simpleStart) let firstNode = parent.index(simpleStart) parent.nodes.splice( firstNode, simpleSelector.length, ...resortSelector(selectorParser.selector({ nodes: simpleSelector })).nodes ) simpleStart.remove() simpleEnd.remove() }) // Remove unnecessary pseudo selectors that we used as placeholders selector.walkPseudos((p) => { if (p.value === MERGE) { p.replaceWith(p.nodes) } }) // Move pseudo elements to the end of the selector (if necessary) selector.each((sel) => movePseudos(sel)) 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(), }) } }) // 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] }