import { Polyfills } from '.'
import { parseAtRule } from './css-parser'
import type { DesignSystem } from './design-system'
import type { Source, SourceLocation } from './source-maps/source'
import { Theme, ThemeOptions } from './theme'
import { DefaultMap } from './utils/default-map'
import { extractUsedVariables } from './utils/variables'
import * as ValueParser from './value-parser'
import { walk, WalkAction, type VisitContext } from './walk'
const AT_SIGN = 0x40
export type StyleRule = {
kind: 'rule'
selector: string
nodes: AstNode[]
src?: SourceLocation
dst?: SourceLocation
}
export type AtRule = {
kind: 'at-rule'
name: string
params: string
nodes: AstNode[]
src?: SourceLocation
dst?: SourceLocation
}
export type Declaration = {
kind: 'declaration'
property: string
value: string | undefined
important: boolean
src?: SourceLocation
dst?: SourceLocation
}
export type Comment = {
kind: 'comment'
value: string
src?: SourceLocation
dst?: SourceLocation
}
export type Context = {
kind: 'context'
context: Record<string, string | boolean>
nodes: AstNode[]
src?: undefined
dst?: undefined
}
export type AtRoot = {
kind: 'at-root'
nodes: AstNode[]
src?: undefined
dst?: undefined
}
export type Rule = StyleRule | AtRule
export type AstNode = StyleRule | AtRule | Declaration | Comment | Context | AtRoot
export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule {
return {
kind: 'rule',
selector,
nodes,
}
}
export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule {
return {
kind: 'at-rule',
name,
params,
nodes,
}
}
export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule {
if (selector.charCodeAt(0) === AT_SIGN) {
return parseAtRule(selector, nodes)
}
return styleRule(selector, nodes)
}
export function decl(property: string, value: string | undefined, important = false): Declaration {
return {
kind: 'declaration',
property,
value,
important,
}
}
export function comment(value: string): Comment {
return {
kind: 'comment',
value: value,
}
}
export function context(context: Record<string, string | boolean>, nodes: AstNode[]): Context {
return {
kind: 'context',
context,
nodes,
}
}
export function atRoot(nodes: AstNode[]): AtRoot {
return {
kind: 'at-root',
nodes,
}
}
export function cloneAstNode<T extends AstNode>(node: T): T {
switch (node.kind) {
case 'rule':
return {
kind: node.kind,
selector: node.selector,
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies StyleRule as T
case 'at-rule':
return {
kind: node.kind,
name: node.name,
params: node.params,
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies AtRule as T
case 'at-root':
return {
kind: node.kind,
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies AtRoot as T
case 'context':
return {
kind: node.kind,
context: { ...node.context },
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies Context as T
case 'declaration':
return {
kind: node.kind,
property: node.property,
value: node.value,
important: node.important,
src: node.src,
dst: node.dst,
} satisfies Declaration as T
case 'comment':
return {
kind: node.kind,
value: node.value,
src: node.src,
dst: node.dst,
} satisfies Comment as T
default:
node satisfies never
throw new Error(`Unknown node kind: ${(node as any).kind}`)
}
}
export function cssContext(
ctx: VisitContext<AstNode>,
): VisitContext<AstNode> & { context: Record<string, string | boolean> } {
return {
depth: ctx.depth,
get context() {
let context: Record<string, string | boolean> = {}
for (let child of ctx.path()) {
if (child.kind === 'context') {
Object.assign(context, child.context)
}
}
Object.defineProperty(this, 'context', { value: context })
return context
},
get parent() {
let parent = (this.path().pop() as Extract<AstNode, { nodes: AstNode[] }>) ?? null
Object.defineProperty(this, 'parent', { value: parent })
return parent
},
path() {
return ctx.path().filter((n) => n.kind !== 'context')
},
}
}
export function optimizeAst(
ast: AstNode[],
designSystem: DesignSystem,
polyfills: Polyfills = Polyfills.All,
) {
let atRoots: AstNode[] = []
let seenAtProperties = new Set<string>()
let cssThemeVariables = new DefaultMap<AstNode[], Set<Declaration>>(() => new Set())
let colorMixDeclarations = new DefaultMap<AstNode[], Set<Declaration>>(() => new Set())
let keyframes = new Set<AtRule>()
let usedKeyframeNames = new Set()
let propertyFallbacksRoot: Declaration[] = []
let propertyFallbacksUniversal: Declaration[] = []
let variableDependencies = new DefaultMap<string, Set<string>>(() => new Set())
function transform(
node: AstNode,
parent: AstNode[],
context: Record<string, string | boolean> = {},
depth = 0,
) {
if (node.kind === 'declaration') {
if (node.property === '--tw-sort' || node.value === undefined || node.value === null) {
return
}
if (context.theme && node.property[0] === '-' && node.property[1] === '-') {
if (node.value === 'initial') {
node.value = undefined
return
}
if (!context.keyframes) {
cssThemeVariables.get(parent).add(node)
}
}
if (node.value.includes('var(')) {
if (context.theme && node.property[0] === '-' && node.property[1] === '-') {
for (let variable of extractUsedVariables(node.value)) {
variableDependencies.get(variable).add(node.property)
}
} else {
designSystem.trackUsedVariables(node.value)
}
}
if (node.property === 'animation') {
for (let keyframeName of extractKeyframeNames(node.value)) {
usedKeyframeNames.add(keyframeName)
}
}
if (
polyfills & Polyfills.ColorMix &&
node.value.includes('color-mix(') &&
!context.supportsColorMix &&
!context.keyframes
) {
colorMixDeclarations.get(parent).add(node)
}
parent.push(node)
}
else if (node.kind === 'rule') {
let nodes: AstNode[] = []
for (let child of node.nodes) {
transform(child, nodes, context, depth + 1)
}
let seen: Record<string, AstNode[]> = {}
let toRemove = new Set<AstNode>()
for (let child of nodes) {
if (child.kind !== 'declaration') continue
let key = `${child.property}:${child.value}:${child.important}`
seen[key] ??= []
seen[key].push(child)
}
for (let key in seen) {
for (let i = 0; i < seen[key].length - 1; ++i) {
toRemove.add(seen[key][i])
}
}
if (toRemove.size > 0) {
nodes = nodes.filter((node) => !toRemove.has(node))
}
if (nodes.length === 0) return
if (node.selector === '&') {
parent.push(...nodes)
} else {
parent.push({ ...node, nodes })
}
}
else if (node.kind === 'at-rule' && node.name === '@property' && depth === 0) {
if (seenAtProperties.has(node.params)) {
return
}
if (polyfills & Polyfills.AtProperty) {
let property = node.params
let initialValue = null
let inherits = false
for (let prop of node.nodes) {
if (prop.kind !== 'declaration') continue
if (prop.property === 'initial-value') {
initialValue = prop.value
} else if (prop.property === 'inherits') {
inherits = prop.value === 'true'
}
}
let fallback = decl(property, initialValue ?? 'initial')
fallback.src = node.src
if (inherits) {
propertyFallbacksRoot.push(fallback)
} else {
propertyFallbacksUniversal.push(fallback)
}
}
seenAtProperties.add(node.params)
let copy = { ...node, nodes: [] }
for (let child of node.nodes) {
transform(child, copy.nodes, context, depth + 1)
}
parent.push(copy)
}
else if (node.kind === 'at-rule') {
if (node.name === '@keyframes') {
context = { ...context, keyframes: true }
} else if (node.name === '@supports' && node.params.includes('color-mix(')) {
context = { ...context, supportsColorMix: true }
}
let copy = { ...node, nodes: [] }
for (let child of node.nodes) {
transform(child, copy.nodes, context, depth + 1)
}
if (node.name === '@keyframes' && context.theme) {
keyframes.add(copy)
}
if (
copy.nodes.length > 0 ||
copy.name === '@layer' ||
copy.name === '@charset' ||
copy.name === '@custom-media' ||
copy.name === '@namespace' ||
copy.name === '@import'
) {
parent.push(copy)
}
}
else if (node.kind === 'at-root') {
for (let child of node.nodes) {
let newParent: AstNode[] = []
transform(child, newParent, context, 0)
for (let child of newParent) {
atRoots.push(child)
}
}
}
else if (node.kind === 'context') {
if (node.context.reference) {
return
} else {
for (let child of node.nodes) {
transform(child, parent, { ...context, ...node.context }, depth)
}
}
}
else if (node.kind === 'comment') {
parent.push(node)
}
else {
node satisfies never
}
}
let newAst: AstNode[] = []
for (let node of ast) {
transform(node, newAst, {}, 0)
}
next: for (let [parent, declarations] of cssThemeVariables) {
for (let declaration of declarations) {
let variableUsed = isVariableUsed(
declaration.property,
designSystem.theme,
variableDependencies,
)
if (variableUsed) {
if (declaration.property.startsWith(designSystem.theme.prefixKey('--animate-'))) {
for (let keyframeName of extractKeyframeNames(declaration.value!))
usedKeyframeNames.add(keyframeName)
}
continue
}
let idx = parent.indexOf(declaration)
parent.splice(idx, 1)
if (parent.length === 0) {
let path = findNode(newAst, (node) => node.kind === 'rule' && node.nodes === parent)
if (!path || path.length === 0) continue next
path.unshift({
kind: 'at-root',
nodes: newAst,
})
do {
let nodeToRemove = path.pop()
if (!nodeToRemove) break
let removeFrom = path[path.length - 1]
if (!removeFrom) break
if (removeFrom.kind !== 'at-root' && removeFrom.kind !== 'at-rule') break
let idx = removeFrom.nodes.indexOf(nodeToRemove)
if (idx === -1) break
removeFrom.nodes.splice(idx, 1)
} while (true)
continue next
}
}
}
for (let keyframe of keyframes) {
if (!usedKeyframeNames.has(keyframe.params)) {
let idx = atRoots.indexOf(keyframe)
atRoots.splice(idx, 1)
}
}
newAst = newAst.concat(atRoots)
if (polyfills & Polyfills.ColorMix) {
for (let [parent, declarations] of colorMixDeclarations) {
for (let declaration of declarations) {
let idx = parent.indexOf(declaration)
if (idx === -1 || declaration.value == null) continue
let ast = ValueParser.parse(declaration.value)
let requiresPolyfill = false
walk(ast, (node) => {
if (node.kind !== 'function' || node.value !== 'color-mix') return
let containsUnresolvableVars = false
let containsCurrentcolor = false
walk(node.nodes, (node) => {
if (node.kind == 'word' && node.value.toLowerCase() === 'currentcolor') {
containsCurrentcolor = true
requiresPolyfill = true
return
}
let varNode: ValueParser.ValueAstNode | null = node
let inlinedColor: string | null = null
let seenVariables = new Set<string>()
do {
if (varNode.kind !== 'function' || varNode.value !== 'var') return
let firstChild = varNode.nodes[0]
if (!firstChild || firstChild.kind !== 'word') return
let variableName = firstChild.value
if (seenVariables.has(variableName)) {
containsUnresolvableVars = true
return
}
seenVariables.add(variableName)
requiresPolyfill = true
inlinedColor = designSystem.theme.resolveValue(null, [firstChild.value as any])
if (!inlinedColor) {
containsUnresolvableVars = true
return
}
if (inlinedColor.toLowerCase() === 'currentcolor') {
containsCurrentcolor = true
return
}
if (inlinedColor.startsWith('var(')) {
let subAst = ValueParser.parse(inlinedColor)
varNode = subAst[0]
} else {
varNode = null
}
} while (varNode)
return WalkAction.Replace({ kind: 'word', value: inlinedColor } as const)
})
if (containsUnresolvableVars || containsCurrentcolor) {
let separatorIndex = node.nodes.findIndex(
(node) => node.kind === 'separator' && node.value.trim().includes(','),
)
if (separatorIndex === -1) return
let firstColorValue =
node.nodes.length > separatorIndex ? node.nodes[separatorIndex + 1] : null
if (!firstColorValue) return
return WalkAction.Replace(firstColorValue)
} else if (requiresPolyfill) {
let colorspace = node.nodes[2]
if (
colorspace.kind === 'word' &&
(colorspace.value === 'oklab' ||
colorspace.value === 'oklch' ||
colorspace.value === 'lab' ||
colorspace.value === 'lch')
) {
colorspace.value = 'srgb'
}
}
})
if (!requiresPolyfill) continue
let fallback = {
...declaration,
value: ValueParser.toCss(ast),
}
let colorMixQuery = rule('@supports (color: color-mix(in lab, red, red))', [declaration])
colorMixQuery.src = declaration.src
parent.splice(idx, 1, fallback, colorMixQuery)
}
}
}
if (polyfills & Polyfills.AtProperty) {
let fallbackAst = []
if (propertyFallbacksRoot.length > 0) {
let wrapper = rule(':root, :host', propertyFallbacksRoot)
wrapper.src = propertyFallbacksRoot[0].src
fallbackAst.push(wrapper)
}
if (propertyFallbacksUniversal.length > 0) {
let wrapper = rule('*, ::before, ::after, ::backdrop', propertyFallbacksUniversal)
wrapper.src = propertyFallbacksUniversal[0].src
fallbackAst.push(wrapper)
}
if (fallbackAst.length > 0) {
let firstValidNodeIndex = newAst.findIndex((node) => {
if (node.kind === 'comment') return false
if (node.kind === 'at-rule') {
if (node.name === '@charset') return false
if (node.name === '@import') return false
}
return true
})
let layerPropertiesStatement = atRule('@layer', 'properties', [])
layerPropertiesStatement.src = fallbackAst[0].src
newAst.splice(
firstValidNodeIndex < 0 ? newAst.length : firstValidNodeIndex,
0,
layerPropertiesStatement,
)
let block = rule('@layer properties', [
atRule(
'@supports',
'((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b))))',
fallbackAst,
),
])
block.src = fallbackAst[0].src
block.nodes[0].src = fallbackAst[0].src
newAst.push(block)
}
}
return newAst
}
export function toCss(ast: AstNode[], track?: boolean) {
let pos = 0
let source: Source = {
file: null,
code: '',
}
function stringify(node: AstNode, depth = 0): string {
let css = ''
let indent = ' '.repeat(depth)
if (node.kind === 'declaration') {
css += `${indent}${node.property}: ${node.value}${node.important ? ' !important' : ''};\n`
if (track) {
pos += indent.length
let start = pos
pos += node.property.length
pos += 2
pos += node.value?.length ?? 0
if (node.important) {
pos += 11
}
let end = pos
pos += 2
node.dst = [source, start, end]
}
}
else if (node.kind === 'rule') {
css += `${indent}${node.selector} {\n`
if (track) {
pos += indent.length
let start = pos
pos += node.selector.length
pos += 1
let end = pos
node.dst = [source, start, end]
pos += 2
}
for (let child of node.nodes) {
css += stringify(child, depth + 1)
}
css += `${indent}}\n`
if (track) {
pos += indent.length
pos += 2
}
}
else if (node.kind === 'at-rule') {
if (node.nodes.length === 0) {
let css = `${indent}${node.name} ${node.params};\n`
if (track) {
pos += indent.length
let start = pos
pos += node.name.length
pos += 1
pos += node.params.length
let end = pos
pos += 2
node.dst = [source, start, end]
}
return css
}
css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n`
if (track) {
pos += indent.length
let start = pos
pos += node.name.length
if (node.params) {
pos += 1
pos += node.params.length
}
pos += 1
let end = pos
node.dst = [source, start, end]
pos += 2
}
for (let child of node.nodes) {
css += stringify(child, depth + 1)
}
css += `${indent}}\n`
if (track) {
pos += indent.length
pos += 2
}
}
else if (node.kind === 'comment') {
css += `${indent}/*${node.value}*/\n`
if (track) {
pos += indent.length
let start = pos
pos += 2 + node.value.length + 2
let end = pos
node.dst = [source, start, end]
pos += 1
}
}
else if (node.kind === 'context' || node.kind === 'at-root') {
return ''
}
else {
node satisfies never
}
return css
}
let css = ''
for (let node of ast) {
css += stringify(node, 0)
}
source.code = css
return css
}
function findNode(ast: AstNode[], fn: (node: AstNode) => boolean): AstNode[] | null {
let foundPath: AstNode[] = []
walk(ast, (node, ctx) => {
if (fn(node)) {
foundPath = ctx.path()
foundPath.push(node)
return WalkAction.Stop
}
})
return foundPath
}
function isVariableUsed(
variable: string,
theme: Theme,
variableDependencies: Map<string, Set<string>>,
alreadySeenVariables: Set<string> = new Set(),
): boolean {
if (alreadySeenVariables.has(variable)) {
return true
} else {
alreadySeenVariables.add(variable)
}
let options = theme.getOptions(variable)
if (options & (ThemeOptions.STATIC | ThemeOptions.USED)) {
return true
} else {
let dependencies = variableDependencies.get(variable) ?? []
for (let dependency of dependencies) {
if (isVariableUsed(dependency, theme, variableDependencies, alreadySeenVariables)) {
return true
}
}
}
return false
}
function extractKeyframeNames(value: string): string[] {
return value.split(/[\s,]+/)
}