import postcss from 'postcss' import selectorParser from 'postcss-selector-parser' import parseObjectStyles from '../util/parseObjectStyles' import isPlainObject from '../util/isPlainObject' import prefixSelector from '../util/prefixSelector' import { updateAllClasses, getMatchingTypes } from '../util/pluginUtils' import log from '../util/log' import * as sharedState from './sharedState' import { formatVariantSelector, finalizeSelector, eliminateIrrelevantSelectors, } from '../util/formatVariantSelector' import { asClass } from '../util/nameClass' import { normalize } from '../util/dataTypes' import { isValidVariantFormatString, parseVariant, INTERNAL_FEATURES } from './setupContextUtils' import isValidArbitraryValue from '../util/isSyntacticallyValidPropertyValue' import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js' import { flagEnabled } from '../featureFlags' import { applyImportantSelector } from '../util/applyImportantSelector' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value }) export function getClassNameFromSelector(selector) { return classNameParser.transformSync(selector) } // Generate match permutations for a class candidate, like: // ['ring-offset-blue', '100'] // ['ring-offset', 'blue-100'] // ['ring', 'offset-blue-100'] // Example with dynamic classes: // ['grid-cols', '[[linename],1fr,auto]'] // ['grid', 'cols-[[linename],1fr,auto]'] function* candidatePermutations(candidate) { let lastIndex = Infinity while (lastIndex >= 0) { let dashIdx let wasSlash = false if (lastIndex === Infinity && candidate.endsWith(']')) { let bracketIdx = candidate.indexOf('[') // If character before `[` isn't a dash or a slash, this isn't a dynamic class // eg. string[] if (candidate[bracketIdx - 1] === '-') { dashIdx = bracketIdx - 1 } else if (candidate[bracketIdx - 1] === '/') { dashIdx = bracketIdx - 1 wasSlash = true } else { dashIdx = -1 } } else if (lastIndex === Infinity && candidate.includes('/')) { dashIdx = candidate.lastIndexOf('/') wasSlash = true } else { dashIdx = candidate.lastIndexOf('-', lastIndex) } if (dashIdx < 0) { break } let prefix = candidate.slice(0, dashIdx) let modifier = candidate.slice(wasSlash ? dashIdx : dashIdx + 1) lastIndex = dashIdx - 1 // TODO: This feels a bit hacky if (prefix === '' || modifier === '/') { continue } yield [prefix, modifier] } } function applyPrefix(matches, context) { if (matches.length === 0 || context.tailwindConfig.prefix === '') { return matches } for (let match of matches) { let [meta] = match if (meta.options.respectPrefix) { let container = postcss.root({ nodes: [match[1].clone()] }) let classCandidate = match[1].raws.tailwind.classCandidate container.walkRules((r) => { // If this is a negative utility with a dash *before* the prefix we // have to ensure that the generated selector matches the candidate // Not doing this will cause `-tw-top-1` to generate the class `.tw--top-1` // The disconnect between candidate <-> class can cause @apply to hard crash. let shouldPrependNegative = classCandidate.startsWith('-') r.selector = prefixSelector( context.tailwindConfig.prefix, r.selector, shouldPrependNegative ) }) match[1] = container.nodes[0] } } return matches } function applyImportant(matches, classCandidate) { if (matches.length === 0) { return matches } let result = [] function isInKeyframes(rule) { return rule.parent && rule.parent.type === 'atrule' && rule.parent.name === 'keyframes' } for (let [meta, rule] of matches) { let container = postcss.root({ nodes: [rule.clone()] }) container.walkRules((r) => { // Declarations inside keyframes cannot be marked as important // They will be ignored by the browser if (isInKeyframes(r)) { return } let ast = selectorParser().astSync(r.selector) // Remove extraneous selectors that do not include the base candidate ast.each((sel) => eliminateIrrelevantSelectors(sel, classCandidate)) // Update all instances of the base candidate to include the important marker updateAllClasses(ast, (className) => className === classCandidate ? `!${className}` : className ) r.selector = ast.toString() r.walkDecls((d) => (d.important = true)) }) result.push([{ ...meta, important: true }, container.nodes[0]]) } return result } // Takes a list of rule tuples and applies a variant like `hover`, sm`, // whatever to it. We used to do some extra caching here to avoid generating // a variant of the same rule more than once, but this was never hit because // we cache at the entire selector level further up the tree. // // Technically you can get a cache hit if you have `hover:focus:text-center` // and `focus:hover:text-center` in the same project, but it doesn't feel // worth the complexity for that case. function applyVariant(variant, matches, context) { if (matches.length === 0) { return matches } /** @type {{modifier: string | null, value: string | null}} */ let args = { modifier: null, value: sharedState.NONE } // Retrieve "modifier" { let [baseVariant, ...modifiers] = splitAtTopLevelOnly(variant, '/') // This is a hack to support variants with `/` in them, like `ar-1/10/20:text-red-500` // In this case 1/10 is a value but /20 is a modifier if (modifiers.length > 1) { baseVariant = baseVariant + '/' + modifiers.slice(0, -1).join('/') modifiers = modifiers.slice(-1) } if (modifiers.length && !context.variantMap.has(variant)) { variant = baseVariant args.modifier = modifiers[0] if (!flagEnabled(context.tailwindConfig, 'generalizedModifiers')) { return [] } } } // Retrieve "arbitrary value" if (variant.endsWith(']') && !variant.startsWith('[')) { // We either have: // @[200px] // group-[:hover] // // But we don't want: // @-[200px] (`-` is incorrect) // group[:hover] (`-` is missing) let match = /(.)(-?)\[(.*)\]/g.exec(variant) if (match) { let [, char, separator, value] = match // @-[200px] case if (char === '@' && separator === '-') return [] // group[:hover] case if (char !== '@' && separator === '') return [] variant = variant.replace(`${separator}[${value}]`, '') args.value = value } } // Register arbitrary variants if (isArbitraryValue(variant) && !context.variantMap.has(variant)) { let sort = context.offsets.recordVariant(variant) let selector = normalize(variant.slice(1, -1)) let selectors = splitAtTopLevelOnly(selector, ',') // We do not support multiple selectors for arbitrary variants if (selectors.length > 1) { return [] } if (!selectors.every(isValidVariantFormatString)) { return [] } let records = selectors.map((sel, idx) => [ context.offsets.applyParallelOffset(sort, idx), parseVariant(sel.trim()), ]) context.variantMap.set(variant, records) } if (context.variantMap.has(variant)) { let isArbitraryVariant = isArbitraryValue(variant) let internalFeatures = context.variantOptions.get(variant)?.[INTERNAL_FEATURES] ?? {} let variantFunctionTuples = context.variantMap.get(variant).slice() let result = [] let respectPrefix = (() => { if (isArbitraryVariant) return false if (internalFeatures.respectPrefix === false) return false return true })() for (let [meta, rule] of matches) { // Don't generate variants for user css if (meta.layer === 'user') { continue } let container = postcss.root({ nodes: [rule.clone()] }) for (let [variantSort, variantFunction, containerFromArray] of variantFunctionTuples) { let clone = (containerFromArray ?? container).clone() let collectedFormats = [] function prepareBackup() { // Already prepared, chicken out if (clone.raws.neededBackup) { return } clone.raws.neededBackup = true clone.walkRules((rule) => (rule.raws.originalSelector = rule.selector)) } function modifySelectors(modifierFunction) { prepareBackup() clone.each((rule) => { if (rule.type !== 'rule') { return } rule.selectors = rule.selectors.map((selector) => { return modifierFunction({ get className() { return getClassNameFromSelector(selector) }, selector, }) }) }) return clone } let ruleWithVariant = variantFunction({ // Public API get container() { prepareBackup() return clone }, separator: context.tailwindConfig.separator, modifySelectors, // Private API for now wrap(wrapper) { let nodes = clone.nodes clone.removeAll() wrapper.append(nodes) clone.append(wrapper) }, format(selectorFormat) { collectedFormats.push({ format: selectorFormat, respectPrefix, }) }, args, }) // It can happen that a list of format strings is returned from within the function. In that // case, we have to process them as well. We can use the existing `variantSort`. if (Array.isArray(ruleWithVariant)) { for (let [idx, variantFunction] of ruleWithVariant.entries()) { // This is a little bit scary since we are pushing to an array of items that we are // currently looping over. However, you can also think of it like a processing queue // where you keep handling jobs until everything is done and each job can queue more // jobs if needed. variantFunctionTuples.push([ context.offsets.applyParallelOffset(variantSort, idx), variantFunction, // If the clone has been modified we have to pass that back // though so each rule can use the modified container clone.clone(), ]) } continue } if (typeof ruleWithVariant === 'string') { collectedFormats.push({ format: ruleWithVariant, respectPrefix, }) } if (ruleWithVariant === null) { continue } // We had to backup selectors, therefore we assume that somebody touched // `container` or `modifySelectors`. Let's see if they did, so that we // can restore the selectors, and collect the format strings. if (clone.raws.neededBackup) { delete clone.raws.neededBackup clone.walkRules((rule) => { let before = rule.raws.originalSelector if (!before) return delete rule.raws.originalSelector if (before === rule.selector) return // No mutation happened let modified = rule.selector // Rebuild the base selector, this is what plugin authors would do // as well. E.g.: `${variant}${separator}${className}`. // However, plugin authors probably also prepend or append certain // classes, pseudos, ids, ... let rebuiltBase = selectorParser((selectors) => { selectors.walkClasses((classNode) => { classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}` }) }).processSync(before) // Now that we know the original selector, the new selector, and // the rebuild part in between, we can replace the part that plugin // authors need to rebuild with `&`, and eventually store it in the // collectedFormats. Similar to what `format('...')` would do. // // E.g.: // variant: foo // selector: .markdown > p // modified (by plugin): .foo .foo\\:markdown > p // rebuiltBase (internal): .foo\\:markdown > p // format: .foo & collectedFormats.push({ format: modified.replace(rebuiltBase, '&'), respectPrefix, }) rule.selector = before }) } // This tracks the originating layer for the variant // For example: // .sm:underline {} is a variant of something in the utilities layer // .sm:container {} is a variant of the container component clone.nodes[0].raws.tailwind = { ...clone.nodes[0].raws.tailwind, parentLayer: meta.layer } let withOffset = [ { ...meta, sort: context.offsets.applyVariantOffset( meta.sort, variantSort, Object.assign(args, context.variantOptions.get(variant)) ), collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), }, clone.nodes[0], ] result.push(withOffset) } } return result } return [] } function parseRules(rule, cache, options = {}) { // PostCSS node if (!isPlainObject(rule) && !Array.isArray(rule)) { return [[rule], options] } // Tuple if (Array.isArray(rule)) { return parseRules(rule[0], cache, rule[1]) } // Simple object if (!cache.has(rule)) { cache.set(rule, parseObjectStyles(rule)) } return [cache.get(rule), options] } const IS_VALID_PROPERTY_NAME = /^[a-z_-]/ function isValidPropName(name) { return IS_VALID_PROPERTY_NAME.test(name) } /** * @param {string} declaration * @returns {boolean} */ function looksLikeUri(declaration) { // Quick bailout for obvious non-urls // This doesn't support schemes that don't use a leading // but that's unlikely to be a problem if (!declaration.includes('://')) { return false } try { const url = new URL(declaration) return url.scheme !== '' && url.host !== '' } catch (err) { // Definitely not a valid url return false } } function isParsableNode(node) { let isParsable = true node.walkDecls((decl) => { if (!isParsableCssValue(decl.prop, decl.value)) { isParsable = false return false } }) return isParsable } function isParsableCssValue(property, value) { // We don't want to to treat [https://example.com] as a custom property // Even though, according to the CSS grammar, it's a totally valid CSS declaration // So we short-circuit here by checking if the custom property looks like a url if (looksLikeUri(`${property}:${value}`)) { return false } try { postcss.parse(`a{${property}:${value}}`).toResult() return true } catch (err) { return false } } function extractArbitraryProperty(classCandidate, context) { let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? [] if (value === undefined) { return null } if (!isValidPropName(property)) { return null } if (!isValidArbitraryValue(value)) { return null } let normalized = normalize(value, { property }) if (!isParsableCssValue(property, normalized)) { return null } let sort = context.offsets.arbitraryProperty() return [ [ { sort, layer: 'utilities' }, () => ({ [asClass(classCandidate)]: { [property]: normalized, }, }), ], ] } function* resolveMatchedPlugins(classCandidate, context) { if (context.candidateRuleMap.has(classCandidate)) { yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT'] } yield* (function* (arbitraryPropertyRule) { if (arbitraryPropertyRule !== null) { yield [arbitraryPropertyRule, 'DEFAULT'] } })(extractArbitraryProperty(classCandidate, context)) let candidatePrefix = classCandidate let negative = false const twConfigPrefix = context.tailwindConfig.prefix const twConfigPrefixLen = twConfigPrefix.length const hasMatchingPrefix = candidatePrefix.startsWith(twConfigPrefix) || candidatePrefix.startsWith(`-${twConfigPrefix}`) if (candidatePrefix[twConfigPrefixLen] === '-' && hasMatchingPrefix) { negative = true candidatePrefix = twConfigPrefix + candidatePrefix.slice(twConfigPrefixLen + 1) } if (negative && context.candidateRuleMap.has(candidatePrefix)) { yield [context.candidateRuleMap.get(candidatePrefix), '-DEFAULT'] } for (let [prefix, modifier] of candidatePermutations(candidatePrefix)) { if (context.candidateRuleMap.has(prefix)) { yield [context.candidateRuleMap.get(prefix), negative ? `-${modifier}` : modifier] } } } function splitWithSeparator(input, separator) { if (input === sharedState.NOT_ON_DEMAND) { return [sharedState.NOT_ON_DEMAND] } return splitAtTopLevelOnly(input, separator) } function* recordCandidates(matches, classCandidate) { for (const match of matches) { match[1].raws.tailwind = { ...match[1].raws.tailwind, classCandidate, preserveSource: match[0].options?.preserveSource ?? false, } yield match } } function* resolveMatches(candidate, context) { let separator = context.tailwindConfig.separator let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse() let important = false if (classCandidate.startsWith('!')) { important = true classCandidate = classCandidate.slice(1) } // TODO: Reintroduce this in ways that doesn't break on false positives // function sortAgainst(toSort, against) { // return toSort.slice().sort((a, z) => { // return bigSign(against.get(a)[0] - against.get(z)[0]) // }) // } // let sorted = sortAgainst(variants, context.variantMap) // if (sorted.toString() !== variants.toString()) { // let corrected = sorted.reverse().concat(classCandidate).join(':') // throw new Error(`Class ${candidate} should be written as ${corrected}`) // } for (let matchedPlugins of resolveMatchedPlugins(classCandidate, context)) { let matches = [] let typesByMatches = new Map() let [plugins, modifier] = matchedPlugins let isOnlyPlugin = plugins.length === 1 for (let [sort, plugin] of plugins) { let matchesPerPlugin = [] if (typeof plugin === 'function') { for (let ruleSet of [].concat(plugin(modifier, { isOnlyPlugin }))) { let [rules, options] = parseRules(ruleSet, context.postCssNodeCache) for (let rule of rules) { matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) } } } // Only process static plugins on exact matches else if (modifier === 'DEFAULT' || modifier === '-DEFAULT') { let ruleSet = plugin let [rules, options] = parseRules(ruleSet, context.postCssNodeCache) for (let rule of rules) { matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) } } if (matchesPerPlugin.length > 0) { let matchingTypes = Array.from( getMatchingTypes( sort.options?.types ?? [], modifier, sort.options ?? {}, context.tailwindConfig ) ).map(([_, type]) => type) if (matchingTypes.length > 0) { typesByMatches.set(matchesPerPlugin, matchingTypes) } matches.push(matchesPerPlugin) } } if (isArbitraryValue(modifier)) { if (matches.length > 1) { // Partition plugins in 2 categories so that we can start searching in the plugins that // don't have `any` as a type first. let [withAny, withoutAny] = matches.reduce( (group, plugin) => { let hasAnyType = plugin.some(([{ options }]) => options.types.some(({ type }) => type === 'any') ) if (hasAnyType) { group[0].push(plugin) } else { group[1].push(plugin) } return group }, [[], []] ) function findFallback(matches) { // If only a single plugin matches, let's take that one if (matches.length === 1) { return matches[0] } // Otherwise, find the plugin that creates a valid rule given the arbitrary value, and // also has the correct type which preferOnConflicts the plugin in case of clashes. return matches.find((rules) => { let matchingTypes = typesByMatches.get(rules) return rules.some(([{ options }, rule]) => { if (!isParsableNode(rule)) { return false } return options.types.some( ({ type, preferOnConflict }) => matchingTypes.includes(type) && preferOnConflict ) }) }) } // Try to find a fallback plugin, because we already know that multiple plugins matched for // the given arbitrary value. let fallback = findFallback(withoutAny) ?? findFallback(withAny) if (fallback) { matches = [fallback] } // We couldn't find a fallback plugin which means that there are now multiple plugins that // generated css for the current candidate. This means that the result is ambiguous and this // should not happen. We won't generate anything right now, so let's report this to the user // by logging some options about what they can do. else { let typesPerPlugin = matches.map( (match) => new Set([...(typesByMatches.get(match) ?? [])]) ) // Remove duplicates, so that we can detect proper unique types for each plugin. for (let pluginTypes of typesPerPlugin) { for (let type of pluginTypes) { let removeFromOwnGroup = false for (let otherGroup of typesPerPlugin) { if (pluginTypes === otherGroup) continue if (otherGroup.has(type)) { otherGroup.delete(type) removeFromOwnGroup = true } } if (removeFromOwnGroup) pluginTypes.delete(type) } } let messages = [] for (let [idx, group] of typesPerPlugin.entries()) { for (let type of group) { let rules = matches[idx] .map(([, rule]) => rule) .flat() .map((rule) => rule .toString() .split('\n') .slice(1, -1) // Remove selector and closing '}' .map((line) => line.trim()) .map((x) => ` ${x}`) // Re-indent .join('\n') ) .join('\n\n') messages.push( ` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\`` ) break } } log.warn([ `The class \`${candidate}\` is ambiguous and matches multiple utilities.`, ...messages, `If this is content and not a class, replace it with \`${candidate .replace('[', '[') .replace(']', ']')}\` to silence this warning.`, ]) continue } } matches = matches.map((list) => list.filter((match) => isParsableNode(match[1]))) } matches = matches.flat() matches = Array.from(recordCandidates(matches, classCandidate)) matches = applyPrefix(matches, context) if (important) { matches = applyImportant(matches, classCandidate) } for (let variant of variants) { matches = applyVariant(variant, matches, context) } for (let match of matches) { match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate } // Apply final format selector match = applyFinalFormat(match, { context, candidate }) // 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 } yield match } } } function applyFinalFormat(match, { context, candidate }) { 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 { let selector = finalizeSelector(rule.selector, finalFormat, { candidate, context, }) // Finalize Selector determined that this candidate is irrelevant // TODO: This elimination should happen earlier so this never happens if (selector === null) { rule.remove() return } rule.selector = selector } 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 } // If all rules have been eliminated we can skip this candidate entirely if (container.nodes.length === 0) { return null } match[1] = container.nodes[0] return match } function inKeyframes(rule) { return rule.parent && rule.parent.type === 'atrule' && rule.parent.name === 'keyframes' } function getImportantStrategy(important) { if (important === true) { return (rule) => { if (inKeyframes(rule)) { return } rule.walkDecls((d) => { if (d.parent.type === 'rule' && !inKeyframes(d.parent)) { d.important = true } }) } } if (typeof important === 'string') { return (rule) => { if (inKeyframes(rule)) { return } rule.selectors = rule.selectors.map((selector) => { return applyImportantSelector(selector, important) }) } } } function generateRules(candidates, context, isSorting = false) { let allRules = [] let strategy = getImportantStrategy(context.tailwindConfig.important) for (let candidate of candidates) { if (context.notClassCache.has(candidate)) { continue } if (context.candidateRuleCache.has(candidate)) { allRules = allRules.concat(Array.from(context.candidateRuleCache.get(candidate))) continue } let matches = Array.from(resolveMatches(candidate, context)) if (matches.length === 0) { context.notClassCache.add(candidate) continue } context.classCache.set(candidate, matches) let rules = context.candidateRuleCache.get(candidate) ?? new Set() context.candidateRuleCache.set(candidate, rules) for (const match of matches) { let [{ sort, options }, rule] = match if (options.respectImportant && strategy) { let container = postcss.root({ nodes: [rule.clone()] }) container.walkRules(strategy) rule = container.nodes[0] } // Note: We have to clone rules during sorting // so we eliminate some shared mutable state let newEntry = [sort, isSorting ? rule.clone() : rule] rules.add(newEntry) context.ruleCache.add(newEntry) allRules.push(newEntry) } } return allRules } function isArbitraryValue(input) { return input.startsWith('[') && input.endsWith(']') } export { resolveMatches, generateRules }