import { type CssNode, type Declaration, generate, type List, type ListItem, parse, type Raw, type SelectorList, type Value, walk, } from 'css-tree'; interface VariableUse { declaration: Declaration; path: CssNode[]; fallback?: string; variableName: string; raw: string; } export interface VariableDefinition { declaration: Declaration; path: CssNode[]; variableName: string; definition: string; } function doSelectorsIntersect( first: SelectorList | Raw, second: SelectorList | Raw, ): boolean { const firstStringified = generate(first); const secondStringified = generate(second); if (firstStringified === secondStringified) { return true; } let hasSomeUniversal = false; const walker = ( node: CssNode, _parentListItem: ListItem, parentList: List, ) => { if (hasSomeUniversal) return; if (node.type === 'PseudoClassSelector' && node.name === 'root') { hasSomeUniversal = true; } if ( node.type === 'TypeSelector' && node.name === '*' && parentList.size === 1 ) { hasSomeUniversal = true; } }; walk(first, walker); walk(second, walker); if (hasSomeUniversal) { return true; } return false; } export function resolveAllCssVariables(node: CssNode) { const variableDefinitions = new Set(); const variableUses = new Set(); const path: CssNode[] = []; walk(node, { leave() { path.shift(); }, enter(node: CssNode) { if (node.type === 'Declaration') { const declaration = node; // Ignores @layer (properties) { ... } to avoid variable resolution conflicts if ( path.some( (ancestor) => ancestor.type === 'Atrule' && ancestor.name === 'layer' && ancestor.prelude !== null && generate(ancestor.prelude).includes('properties'), ) ) { path.unshift(node); return; } if (/--[\S]+/.test(declaration.property)) { variableDefinitions.add({ declaration, path: [...path], variableName: declaration.property, definition: generate(declaration.value), }); } else { function parseVariableUsesFrom(node: CssNode) { walk(node, { visit: 'Function', enter(funcNode) { if (funcNode.name === 'var') { const children = funcNode.children.toArray(); const name = generate(children[0]!); const fallback = // The second argument should be an "," Operator Node, // such that the actual fallback is only in the third argument children[2] ? generate(children[2]) : undefined; variableUses.add({ declaration, path: [...path], fallback, variableName: name, raw: generate(funcNode), }); if (fallback?.includes('var(')) { const parsedFallback = parse(fallback, { context: 'value', }); parseVariableUsesFrom(parsedFallback); } } }, }); } parseVariableUsesFrom(declaration.value); } } path.unshift(node); }, }); for (const use of variableUses) { let hasReplaced = false; for (const definition of variableDefinitions) { if (use.variableName !== definition.variableName) { continue; } if ( use.path[0]?.type === 'Block' && use.path[1]?.type === 'Atrule' && use.path[2]?.type === 'Block' && use.path[3]?.type === 'Rule' && definition.path[0]!.type === 'Block' && definition.path[1]!.type === 'Rule' && doSelectorsIntersect(use.path[3].prelude, definition.path[1].prelude) ) { use.declaration.value = parse( generate(use.declaration.value).replaceAll( use.raw, definition.definition, ), { context: 'value', }, ) as Raw | Value; hasReplaced = true; break; } if ( use.path[0]?.type === 'Block' && use.path[1]?.type === 'Rule' && definition.path[0]?.type === 'Block' && definition.path[1]?.type === 'Rule' && doSelectorsIntersect(use.path[1].prelude, definition.path[1].prelude) ) { use.declaration.value = parse( generate(use.declaration.value).replaceAll( use.raw, definition.definition, ), { context: 'value', }, ) as Raw | Value; hasReplaced = true; break; } // Both use and definition live inside the same nested @media (or other // at-rule) block of the same rule — e.g. Tailwind v4's // .print_invert { @media print { --tw-invert: invert(100%); filter: var(--tw-invert,) ... } } // The previous two checks only cover a Rule directly containing the // declaration; this one covers Rule → Block → Atrule → Block → Declaration // on both sides. if ( use.path[0]?.type === 'Block' && use.path[1]?.type === 'Atrule' && use.path[2]?.type === 'Block' && use.path[3]?.type === 'Rule' && definition.path[0]?.type === 'Block' && definition.path[1]?.type === 'Atrule' && definition.path[2]?.type === 'Block' && definition.path[3]?.type === 'Rule' && use.path[1].name === definition.path[1].name && (use.path[1].prelude ? definition.path[1].prelude ? generate(use.path[1].prelude) === generate(definition.path[1].prelude) : false : definition.path[1].prelude === null) && doSelectorsIntersect(use.path[3].prelude, definition.path[3].prelude) ) { use.declaration.value = parse( generate(use.declaration.value).replaceAll( use.raw, definition.definition, ), { context: 'value', }, ) as Raw | Value; hasReplaced = true; break; } } if (!hasReplaced && use.fallback) { use.declaration.value = parse( generate(use.declaration.value).replaceAll(use.raw, use.fallback), { context: 'value' }, ) as Raw | Value; } } }