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)
}
function* candidatePermutations(candidate) {
let lastIndex = Infinity
while (lastIndex >= 0) {
let dashIdx
let wasSlash = false
if (lastIndex === Infinity && candidate.endsWith(']')) {
let bracketIdx = candidate.indexOf('[')
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
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) => {
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 = []
for (let [meta, rule] of matches) {
let container = postcss.root({ nodes: [rule.clone()] })
container.walkRules((r) => {
let ast = selectorParser().astSync(r.selector)
ast.each((sel) => eliminateIrrelevantSelectors(sel, classCandidate))
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
}
function applyVariant(variant, matches, context) {
if (matches.length === 0) {
return matches
}
let args = { modifier: null, value: sharedState.NONE }
{
let [baseVariant, ...modifiers] = splitAtTopLevelOnly(variant, '/')
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 []
}
}
}
if (variant.endsWith(']') && !variant.startsWith('[')) {
let match = /(.)(-?)\[(.*)\]/g.exec(variant)
if (match) {
let [, char, separator, value] = match
if (char === '@' && separator === '-') return []
if (char !== '@' && separator === '') return []
variant = variant.replace(`${separator}[${value}]`, '')
args.value = value
}
}
if (isArbitraryValue(variant) && !context.variantMap.has(variant)) {
let sort = context.offsets.recordVariant(variant)
let selector = normalize(variant.slice(1, -1))
let selectors = splitAtTopLevelOnly(selector, ',')
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) {
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() {
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({
get container() {
prepareBackup()
return clone
},
separator: context.tailwindConfig.separator,
modifySelectors,
wrap(wrapper) {
let nodes = clone.nodes
clone.removeAll()
wrapper.append(nodes)
clone.append(wrapper)
},
format(selectorFormat) {
collectedFormats.push({
format: selectorFormat,
respectPrefix,
})
},
args,
})
if (Array.isArray(ruleWithVariant)) {
for (let [idx, variantFunction] of ruleWithVariant.entries()) {
variantFunctionTuples.push([
context.offsets.applyParallelOffset(variantSort, idx),
variantFunction,
clone.clone(),
])
}
continue
}
if (typeof ruleWithVariant === 'string') {
collectedFormats.push({
format: ruleWithVariant,
respectPrefix,
})
}
if (ruleWithVariant === null) {
continue
}
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
let modified = rule.selector
let rebuiltBase = selectorParser((selectors) => {
selectors.walkClasses((classNode) => {
classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}`
})
}).processSync(before)
collectedFormats.push({
format: modified.replace(rebuiltBase, '&'),
respectPrefix,
})
rule.selector = before
})
}
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 = {}) {
if (!isPlainObject(rule) && !Array.isArray(rule)) {
return [[rule], options]
}
if (Array.isArray(rule)) {
return parseRules(rule[0], cache, rule[1])
}
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)
}
function looksLikeUri(declaration) {
if (!declaration.includes('://')) {
return false
}
try {
const url = new URL(declaration)
return url.scheme !== '' && url.host !== ''
} catch (err) {
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) {
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)
}
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])
}
}
}
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) {
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 (matches.length === 1) {
return matches[0]
}
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
)
})
})
}
let fallback = findFallback(withoutAny) ?? findFallback(withAny)
if (fallback) {
matches = [fallback]
}
else {
let typesPerPlugin = matches.map(
(match) => new Set([...(typesByMatches.get(match) ?? [])])
)
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) .map((line) => line.trim())
.map((x) => ` ${x}`) .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 }
match = applyFinalFormat(match, { context, candidate })
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 {
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,
})
if (selector === null) {
rule.remove()
return
}
rule.selector = selector
} catch {
isValid = false
return false
}
})
if (!isValid) {
return null
}
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]
}
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 }