import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast'
import { type Variant } from './candidate'
import type { Theme } from './theme'
import { DefaultMap } from './utils/default-map'
import { segment } from './utils/segment'
type VariantFn<T extends Variant['kind']> = (
rule: Rule,
variant: Extract<Variant, { kind: T }>,
) => null | void
type CompareFn = (a: Variant, z: Variant) => number
export class Variants {
private compareFns = new Map<number, CompareFn>()
private variants = new Map<
string,
{
kind: Variant['kind']
order: number
applyFn: VariantFn<any>
compounds: boolean
}
>()
private completions = new Map<string, () => string[]>()
private groupOrder: null | number = null
private lastOrder = 0
static(name: string, applyFn: VariantFn<'static'>, { compounds }: { compounds?: boolean } = {}) {
this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true })
}
fromAst(name: string, ast: AstNode[]) {
this.static(name, (r) => {
let body = structuredClone(ast)
walk(body, (node, { replaceWith }) => {
if (node.kind === 'rule' && node.selector === '@slot') {
replaceWith(r.nodes)
}
else if (
node.kind === 'rule' &&
node.selector[0] === '@' &&
(node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property '))
) {
Object.assign(node, {
selector: '@at-root',
nodes: [rule(node.selector, node.nodes)],
})
return WalkAction.Skip
}
})
r.nodes = body
})
}
functional(
name: string,
applyFn: VariantFn<'functional'>,
{ compounds }: { compounds?: boolean } = {},
) {
this.set(name, { kind: 'functional', applyFn, compounds: compounds ?? true })
}
compound(
name: string,
applyFn: VariantFn<'compound'>,
{ compounds }: { compounds?: boolean } = {},
) {
this.set(name, { kind: 'compound', applyFn, compounds: compounds ?? true })
}
group(fn: () => void, compareFn?: CompareFn) {
this.groupOrder = this.nextOrder()
if (compareFn) this.compareFns.set(this.groupOrder, compareFn)
fn()
this.groupOrder = null
}
has(name: string) {
return this.variants.has(name)
}
get(name: string) {
return this.variants.get(name)
}
kind(name: string) {
return this.variants.get(name)?.kind!
}
compounds(name: string) {
return this.variants.get(name)?.compounds!
}
suggest(name: string, suggestions: () => string[]) {
this.completions.set(name, suggestions)
}
getCompletions(name: string) {
return this.completions.get(name)?.() ?? []
}
compare(a: Variant | null, z: Variant | null): number {
if (a === z) return 0
if (a === null) return -1
if (z === null) return 1
if (a.kind === 'arbitrary' && z.kind === 'arbitrary') {
return a.selector < z.selector ? -1 : 1
} else if (a.kind === 'arbitrary') {
return 1
} else if (z.kind === 'arbitrary') {
return -1
}
let aOrder = this.variants.get(a.root)!.order
let zOrder = this.variants.get(z.root)!.order
let orderedByVariant = aOrder - zOrder
if (orderedByVariant !== 0) return orderedByVariant
if (a.kind === 'compound' && z.kind === 'compound') {
return this.compare(a.variant, z.variant)
}
let compareFn = this.compareFns.get(aOrder)
if (compareFn === undefined) return 0
return compareFn(a, z) || (a.root < z.root ? -1 : 1)
}
keys() {
return this.variants.keys()
}
entries() {
return this.variants.entries()
}
private set<T extends Variant['kind']>(
name: string,
{ kind, applyFn, compounds }: { kind: T; applyFn: VariantFn<T>; compounds: boolean },
) {
let existing = this.variants.get(name)
if (existing) {
Object.assign(existing, { kind, applyFn, compounds })
} else {
this.lastOrder = this.nextOrder()
this.variants.set(name, {
kind,
applyFn,
order: this.lastOrder,
compounds,
})
}
}
private nextOrder() {
return this.groupOrder ?? this.lastOrder + 1
}
}
export function createVariants(theme: Theme): Variants {
let variants = new Variants()
function staticVariant(
name: string,
selectors: string[],
{ compounds }: { compounds?: boolean } = {},
) {
variants.static(
name,
(r) => {
r.nodes = selectors.map((selector) => rule(selector, r.nodes))
},
{ compounds },
)
}
variants.static('force', () => {}, { compounds: false })
staticVariant('*', [':where(& > *)'], { compounds: false })
variants.compound('not', (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
if (variant.modifier) return null
let didApply = false
walk([ruleNode], (node) => {
if (node.kind !== 'rule') return WalkAction.Continue
if (node.selector[0] === '@') return WalkAction.Continue
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
}
node.selector = `&:not(${node.selector.replaceAll('&', '*')})`
didApply = true
})
if (!didApply) {
return null
}
})
variants.compound('group', (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
let groupSelector = variant.modifier
? `:where(.group\\/${variant.modifier.value})`
: ':where(.group)'
let didApply = false
walk([ruleNode], (node) => {
if (node.kind !== 'rule') return WalkAction.Continue
if (node.selector[0] === '@') return WalkAction.Continue
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
}
node.selector = node.selector.replaceAll('&', groupSelector)
if (segment(node.selector, ',').length > 1) {
node.selector = `:is(${node.selector})`
}
node.selector = `&:is(${node.selector} *)`
didApply = true
})
if (!didApply) {
return null
}
})
variants.suggest('group', () => {
return Array.from(variants.keys()).filter((name) => {
return variants.get(name)?.compounds ?? false
})
})
variants.compound('peer', (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
let peerSelector = variant.modifier
? `:where(.peer\\/${variant.modifier.value})`
: ':where(.peer)'
let didApply = false
walk([ruleNode], (node) => {
if (node.kind !== 'rule') return WalkAction.Continue
if (node.selector[0] === '@') return WalkAction.Continue
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
}
node.selector = node.selector.replaceAll('&', peerSelector)
if (segment(node.selector, ',').length > 1) {
node.selector = `:is(${node.selector})`
}
node.selector = `&:is(${node.selector} ~ *)`
didApply = true
})
if (!didApply) {
return null
}
})
variants.suggest('peer', () => {
return Array.from(variants.keys()).filter((name) => {
return variants.get(name)?.compounds ?? false
})
})
staticVariant('first-letter', ['&::first-letter'], { compounds: false })
staticVariant('first-line', ['&::first-line'], { compounds: false })
staticVariant('marker', ['& *::marker', '&::marker'], { compounds: false })
staticVariant('selection', ['& *::selection', '&::selection'], { compounds: false })
staticVariant('file', ['&::file-selector-button'], { compounds: false })
staticVariant('placeholder', ['&::placeholder'], { compounds: false })
staticVariant('backdrop', ['&::backdrop'], { compounds: false })
{
function contentProperties() {
return rule('@at-root', [
rule('@property --tw-content', [
decl('syntax', '"*"'),
decl('initial-value', '""'),
decl('inherits', 'false'),
]),
])
}
variants.static(
'before',
(v) => {
v.nodes = [
rule('&::before', [
contentProperties(),
decl('content', 'var(--tw-content)'),
...v.nodes,
]),
]
},
{ compounds: false },
)
variants.static(
'after',
(v) => {
v.nodes = [
rule('&::after', [contentProperties(), decl('content', 'var(--tw-content)'), ...v.nodes]),
]
},
{ compounds: false },
)
}
let pseudos: [name: string, selector: string][] = [
['first', '&:first-child'],
['last', '&:last-child'],
['only', '&:only-child'],
['odd', '&:nth-child(odd)'],
['even', '&:nth-child(even)'],
['first-of-type', '&:first-of-type'],
['last-of-type', '&:last-of-type'],
['only-of-type', '&:only-of-type'],
['visited', '&:visited'],
['target', '&:target'],
['open', '&:is([open], :popover-open)'],
['default', '&:default'],
['checked', '&:checked'],
['indeterminate', '&:indeterminate'],
['placeholder-shown', '&:placeholder-shown'],
['autofill', '&:autofill'],
['optional', '&:optional'],
['required', '&:required'],
['valid', '&:valid'],
['invalid', '&:invalid'],
['in-range', '&:in-range'],
['out-of-range', '&:out-of-range'],
['read-only', '&:read-only'],
['empty', '&:empty'],
['focus-within', '&:focus-within'],
[
'hover',
'&:hover',
],
['focus', '&:focus'],
['focus-visible', '&:focus-visible'],
['active', '&:active'],
['enabled', '&:enabled'],
['disabled', '&:disabled'],
]
for (let [key, value] of pseudos) {
staticVariant(key, [value])
}
staticVariant('inert', ['&:is([inert], [inert] *)'])
variants.compound('has', (ruleNode, variant) => {
if (variant.modifier) return null
let didApply = false
walk([ruleNode], (node) => {
if (node.kind !== 'rule') return WalkAction.Continue
if (node.selector[0] === '@') return WalkAction.Continue
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
}
node.selector = `&:has(${node.selector.replaceAll('&', '*')})`
didApply = true
})
if (!didApply) {
return null
}
})
variants.suggest('has', () => {
return Array.from(variants.keys()).filter((name) => {
return variants.get(name)?.compounds ?? false
})
})
variants.functional('aria', (ruleNode, variant) => {
if (!variant.value || variant.modifier) return null
if (variant.value.kind === 'arbitrary') {
ruleNode.nodes = [rule(`&[aria-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)]
} else {
ruleNode.nodes = [rule(`&[aria-${variant.value.value}="true"]`, ruleNode.nodes)]
}
})
variants.suggest('aria', () => [
'busy',
'checked',
'disabled',
'expanded',
'hidden',
'pressed',
'readonly',
'required',
'selected',
])
variants.functional('data', (ruleNode, variant) => {
if (!variant.value || variant.modifier) return null
ruleNode.nodes = [rule(`&[data-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)]
})
variants.functional('nth', (ruleNode, variant) => {
if (!variant.value || variant.modifier) return null
if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null
ruleNode.nodes = [rule(`&:nth-child(${variant.value.value})`, ruleNode.nodes)]
})
variants.functional('nth-last', (ruleNode, variant) => {
if (!variant.value || variant.modifier) return null
if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null
ruleNode.nodes = [rule(`&:nth-last-child(${variant.value.value})`, ruleNode.nodes)]
})
variants.functional('nth-of-type', (ruleNode, variant) => {
if (!variant.value || variant.modifier) return null
if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null
ruleNode.nodes = [rule(`&:nth-of-type(${variant.value.value})`, ruleNode.nodes)]
})
variants.functional('nth-last-of-type', (ruleNode, variant) => {
if (!variant.value || variant.modifier) return null
if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null
ruleNode.nodes = [rule(`&:nth-last-of-type(${variant.value.value})`, ruleNode.nodes)]
})
variants.functional(
'supports',
(ruleNode, variant) => {
if (!variant.value || variant.modifier) return null
let value = variant.value.value
if (value === null) return null
if (/^[\w-]*\s*\(/.test(value)) {
let query = value.replace(/\b(and|or|not)\b/g, ' $1 ')
ruleNode.nodes = [rule(`@supports ${query}`, ruleNode.nodes)]
return
}
if (!value.includes(':')) {
value = `${value}: var(--tw)`
}
if (value[0] !== '(' || value[value.length - 1] !== ')') {
value = `(${value})`
}
ruleNode.nodes = [rule(`@supports ${value}`, ruleNode.nodes)]
},
{ compounds: false },
)
staticVariant('motion-safe', ['@media (prefers-reduced-motion: no-preference)'], {
compounds: false,
})
staticVariant('motion-reduce', ['@media (prefers-reduced-motion: reduce)'], { compounds: false })
staticVariant('contrast-more', ['@media (prefers-contrast: more)'], { compounds: false })
staticVariant('contrast-less', ['@media (prefers-contrast: less)'], { compounds: false })
{
function compareBreakpoints(
a: Variant,
z: Variant,
direction: 'asc' | 'desc',
lookup: { get(v: Variant): string | null },
) {
if (a === z) return 0
let aValue = lookup.get(a)
if (aValue === null) return direction === 'asc' ? -1 : 1
let zValue = lookup.get(z)
if (zValue === null) return direction === 'asc' ? 1 : -1
if (aValue === zValue) return 0
let aIsCssFunction = aValue.indexOf('(')
let zIsCssFunction = zValue.indexOf('(')
let aBucket =
aIsCssFunction === -1
?
aValue.replace(/[\d.]+/g, '')
:
aValue.slice(0, aIsCssFunction)
let zBucket =
zIsCssFunction === -1
?
zValue.replace(/[\d.]+/g, '')
:
zValue.slice(0, zIsCssFunction)
let order =
(aBucket === zBucket ? 0 : aBucket < zBucket ? -1 : 1) ||
(direction === 'asc'
? parseInt(aValue) - parseInt(zValue)
: parseInt(zValue) - parseInt(aValue))
if (Number.isNaN(order)) {
return aValue < zValue ? -1 : 1
}
return order
}
{
let breakpoints = theme.namespace('--breakpoint')
let resolvedBreakpoints = new DefaultMap((variant: Variant) => {
switch (variant.kind) {
case 'static': {
return breakpoints.get(variant.root) ?? null
}
case 'functional': {
if (!variant.value || variant.modifier) return null
let value: string | null = null
if (variant.value.kind === 'arbitrary') {
value = variant.value.value
} else if (variant.value.kind === 'named') {
value = theme.resolveValue(variant.value.value, ['--breakpoint'])
}
if (!value) return null
if (value.includes('var(')) return null
return value
}
case 'arbitrary':
case 'compound':
return null
}
})
variants.group(
() => {
variants.functional(
'max',
(ruleNode, variant) => {
if (variant.modifier) return null
let value = resolvedBreakpoints.get(variant)
if (value === null) return null
ruleNode.nodes = [rule(`@media (width < ${value})`, ruleNode.nodes)]
},
{ compounds: false },
)
},
(a, z) => compareBreakpoints(a, z, 'desc', resolvedBreakpoints),
)
variants.suggest(
'max',
() => Array.from(breakpoints.keys()).filter((key) => key !== null) as string[],
)
variants.group(
() => {
for (let [key, value] of theme.namespace('--breakpoint')) {
if (key === null) continue
variants.static(
key,
(ruleNode) => {
ruleNode.nodes = [rule(`@media (width >= ${value})`, ruleNode.nodes)]
},
{ compounds: false },
)
}
variants.functional(
'min',
(ruleNode, variant) => {
if (variant.modifier) return null
let value = resolvedBreakpoints.get(variant)
if (value === null) return null
ruleNode.nodes = [rule(`@media (width >= ${value})`, ruleNode.nodes)]
},
{ compounds: false },
)
},
(a, z) => compareBreakpoints(a, z, 'asc', resolvedBreakpoints),
)
variants.suggest(
'min',
() => Array.from(breakpoints.keys()).filter((key) => key !== null) as string[],
)
}
{
let widths = theme.namespace('--width')
let resolvedWidths = new DefaultMap((variant: Variant) => {
switch (variant.kind) {
case 'functional': {
if (variant.value === null) return null
let value: string | null = null
if (variant.value.kind === 'arbitrary') {
value = variant.value.value
} else if (variant.value.kind === 'named') {
value = theme.resolveValue(variant.value.value, ['--width'])
}
if (!value) return null
if (value.includes('var(')) return null
return value
}
case 'static':
case 'arbitrary':
case 'compound':
return null
}
})
variants.group(
() => {
variants.functional(
'@max',
(ruleNode, variant) => {
let value = resolvedWidths.get(variant)
if (value === null) return null
ruleNode.nodes = [
rule(
variant.modifier
? `@container ${variant.modifier.value} (width < ${value})`
: `@container (width < ${value})`,
ruleNode.nodes,
),
]
},
{ compounds: false },
)
},
(a, z) => compareBreakpoints(a, z, 'desc', resolvedWidths),
)
variants.suggest(
'@max',
() => Array.from(widths.keys()).filter((key) => key !== null) as string[],
)
variants.group(
() => {
variants.functional(
'@',
(ruleNode, variant) => {
let value = resolvedWidths.get(variant)
if (value === null) return null
ruleNode.nodes = [
rule(
variant.modifier
? `@container ${variant.modifier.value} (width >= ${value})`
: `@container (width >= ${value})`,
ruleNode.nodes,
),
]
},
{ compounds: false },
)
variants.functional(
'@min',
(ruleNode, variant) => {
let value = resolvedWidths.get(variant)
if (value === null) return null
ruleNode.nodes = [
rule(
variant.modifier
? `@container ${variant.modifier.value} (width >= ${value})`
: `@container (width >= ${value})`,
ruleNode.nodes,
),
]
},
{ compounds: false },
)
},
(a, z) => compareBreakpoints(a, z, 'asc', resolvedWidths),
)
variants.suggest(
'@min',
() => Array.from(widths.keys()).filter((key) => key !== null) as string[],
)
}
}
staticVariant('portrait', ['@media (orientation: portrait)'], { compounds: false })
staticVariant('landscape', ['@media (orientation: landscape)'], { compounds: false })
staticVariant('ltr', ['&:where([dir="ltr"], [dir="ltr"] *)'])
staticVariant('rtl', ['&:where([dir="rtl"], [dir="rtl"] *)'])
staticVariant('dark', ['@media (prefers-color-scheme: dark)'], { compounds: false })
staticVariant('starting', ['@starting-style'], { compounds: false })
staticVariant('print', ['@media print'], { compounds: false })
staticVariant('forced-colors', ['@media (forced-colors: active)'], { compounds: false })
return variants
}
function quoteAttributeValue(value: string) {
if (value.includes('=')) {
value = value.replace(/(=.*)/g, (_fullMatch, match) => {
if (match[1] === "'" || match[1] === '"') {
return match
}
if (match.length > 2) {
let trailingCharacter = match[match.length - 1]
if (
match[match.length - 2] === ' ' &&
(trailingCharacter === 'i' ||
trailingCharacter === 'I' ||
trailingCharacter === 's' ||
trailingCharacter === 'S')
) {
return `="${match.slice(1, -2)}" ${match[match.length - 1]}`
}
}
return `="${match.slice(1)}"`
})
}
return value
}