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 { isPositiveInteger } from './utils/infer-data-type'
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 {
public compareFns = new Map<number, CompareFn>()
public 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, order }: { compounds?: boolean; order?: number } = {},
) {
this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true, order })
}
fromAst(name: string, ast: AstNode[]) {
this.static(name, (r) => {
let body = structuredClone(ast)
substituteAtSlot(body, r.nodes)
r.nodes = body
})
}
functional(
name: string,
applyFn: VariantFn<'functional'>,
{ compounds, order }: { compounds?: boolean; order?: number } = {},
) {
this.set(name, { kind: 'functional', applyFn, compounds: compounds ?? true, order })
}
compound(
name: string,
applyFn: VariantFn<'compound'>,
{ compounds, order }: { compounds?: boolean; order?: number } = {},
) {
this.set(name, { kind: 'compound', applyFn, compounds: compounds ?? true, order })
}
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') {
let order = this.compare(a.variant, z.variant)
if (order === 0) {
if (a.modifier && z.modifier) {
return a.modifier.value < z.modifier.value ? -1 : 1
} else if (a.modifier) {
return 1
} else if (z.modifier) {
return -1
}
}
return order
}
let compareFn = this.compareFns.get(aOrder)
if (compareFn === undefined) return a.root < z.root ? -1 : 1
return compareFn(a, z)
}
keys() {
return this.variants.keys()
}
entries() {
return this.variants.entries()
}
private set<T extends Variant['kind']>(
name: string,
{
kind,
applyFn,
compounds,
order,
}: { kind: T; applyFn: VariantFn<T>; compounds: boolean; order?: number },
) {
let existing = this.variants.get(name)
if (existing) {
Object.assign(existing, { kind, applyFn, compounds })
} else {
if (order === undefined) {
this.lastOrder = this.nextOrder()
order = this.lastOrder
}
this.variants.set(name, {
kind,
applyFn,
order,
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 },
)
}
staticVariant('first', ['&:first-child'])
staticVariant('last', ['&:last-child'])
staticVariant('only', ['&:only-child'])
staticVariant('odd', ['&:nth-child(odd)'])
staticVariant('even', ['&:nth-child(even)'])
staticVariant('first-of-type', ['&:first-of-type'])
staticVariant('last-of-type', ['&:last-of-type'])
staticVariant('only-of-type', ['&:only-of-type'])
staticVariant('visited', ['&:visited'])
staticVariant('target', ['&:target'])
staticVariant('open', ['&:is([open], :popover-open)'])
staticVariant('default', ['&:default'])
staticVariant('checked', ['&:checked'])
staticVariant('indeterminate', ['&:indeterminate'])
staticVariant('placeholder-shown', ['&:placeholder-shown'])
staticVariant('autofill', ['&:autofill'])
staticVariant('optional', ['&:optional'])
staticVariant('required', ['&:required'])
staticVariant('valid', ['&:valid'])
staticVariant('invalid', ['&:invalid'])
staticVariant('in-range', ['&:in-range'])
staticVariant('out-of-range', ['&:out-of-range'])
staticVariant('read-only', ['&:read-only'])
staticVariant('empty', ['&:empty'])
staticVariant('focus-within', ['&:focus-within'])
variants.static('hover', (r) => {
r.nodes = [rule('&:hover', [rule('@media (hover: hover)', r.nodes)])]
})
staticVariant('focus', ['&:focus'])
staticVariant('focus-visible', ['&:focus-visible'])
staticVariant('active', ['&:active'])
staticVariant('enabled', ['&:enabled'])
staticVariant('disabled', ['&:disabled'])
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' && !isPositiveInteger(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' && !isPositiveInteger(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' && !isPositiveInteger(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' && !isPositiveInteger(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 theme.resolveValue(variant.root, ['--breakpoint']) ?? 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"], [dir="ltr"] *)'])
staticVariant('rtl', ['&:where(:dir(rtl), [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
}
export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) {
walk(ast, (node, { replaceWith }) => {
if (node.kind === 'rule' && node.selector === '@slot') {
replaceWith(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
}
})
}