Skip to content
133 changes: 133 additions & 0 deletions src/lib/resolve-css-variables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { test, expect } from 'vitest'
import { extract_root_custom_properties, resolve_css_value } from './resolve-css-variables'

test('extracts custom properties declared on :root', () => {
let result = extract_root_custom_properties(':root { --primary-color: blue; --radius: 4px; }')

expect(result).toEqual(
new Map([
['--primary-color', 'blue'],
['--radius', '4px']
])
)
})

test('extracts custom properties declared on html', () => {
let result = extract_root_custom_properties('html { --primary-color: green; }')

expect(result).toEqual(new Map([['--primary-color', 'green']]))
})

test('ignores custom properties declared on any other selector', () => {
let result = extract_root_custom_properties('.theme-dark { --primary-color: black; }')

expect(result.size).toBe(0)
})

test('ignores custom properties nested inside a conditional at-rule', () => {
let result = extract_root_custom_properties(`
@media (prefers-color-scheme: dark) {
:root { --primary-color: black; }
}
`)

expect(result.size).toBe(0)
})

test('ignores non-custom-property declarations', () => {
let result = extract_root_custom_properties(':root { --primary-color: blue; font-size: 16px; }')

expect(result).toEqual(new Map([['--primary-color', 'blue']]))
})

test('resolves the cascade between multiple :root/html declarations of the same property', () => {
// html has lower specificity than :root, so :root should win regardless of source order
let result = extract_root_custom_properties(`
:root { --primary-color: blue; }
html { --primary-color: green; }
`)

expect(result.get('--primary-color')).toBe('blue')
})

test('equal specificity: the later declaration wins', () => {
let result = extract_root_custom_properties(`
:root { --primary-color: blue; }
:root { --primary-color: green; }
`)

expect(result.get('--primary-color')).toBe('green')
})

test('!important beats higher specificity', () => {
let result = extract_root_custom_properties(`
:root { --primary-color: blue !important; }
:root { --primary-color: green; }
`)

expect(result.get('--primary-color')).toBe('blue')
})

test('resolve_css_value substitutes a known variable', () => {
let custom_properties = new Map([['--primary-color', 'blue']])

expect(resolve_css_value('var(--primary-color)', custom_properties)).toBe('blue')
expect(resolve_css_value('1px solid var(--primary-color)', custom_properties)).toBe('1px solid blue')
})

test('resolve_css_value leaves an unknown variable without a fallback untouched', () => {
expect(resolve_css_value('var(--unknown)', new Map())).toBe('var(--unknown)')
})

test('resolve_css_value uses the fallback when the variable is unknown', () => {
expect(resolve_css_value('var(--unknown, red)', new Map())).toBe('red')
})

test('resolve_css_value ignores the fallback when the variable is known', () => {
let custom_properties = new Map([['--primary-color', 'blue']])

expect(resolve_css_value('var(--primary-color, red)', custom_properties)).toBe('blue')
})

test('resolve_css_value resolves a fallback that itself contains commas inside a function', () => {
expect(resolve_css_value('var(--unknown, rgb(1, 2, 3))', new Map())).toBe('rgb(1, 2, 3)')
})

test('resolve_css_value resolves chained variable references', () => {
let custom_properties = new Map([
['--space', 'var(--space-2)'],
['--space-2', '8px']
])

expect(resolve_css_value('var(--space)', custom_properties)).toBe('8px')
})

test('resolve_css_value resolves a fallback that is itself a variable reference', () => {
let custom_properties = new Map([['--space-2', '8px']])

expect(resolve_css_value('var(--gap, var(--space-2))', custom_properties)).toBe('8px')
})

test('resolve_css_value resolves multiple variables within one value', () => {
let custom_properties = new Map([
['--c1', 'red'],
['--c2', 'blue']
])

expect(resolve_css_value('linear-gradient(90deg, var(--c1), var(--c2))', custom_properties)).toBe(
'linear-gradient(90deg, red, blue)'
)
})

test('resolve_css_value breaks a circular reference instead of looping forever', () => {
let custom_properties = new Map([
['--a', 'var(--b)'],
['--b', 'var(--a)']
])

expect(resolve_css_value('var(--a)', custom_properties)).toBe('var(--a)')
})

test('resolve_css_value leaves values without var() untouched', () => {
expect(resolve_css_value('16px', new Map())).toBe('16px')
})
168 changes: 168 additions & 0 deletions src/lib/resolve-css-variables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { parse, traverse, parse_selector_list, STYLE_RULE, AT_RULE, DECLARATION } from '@projectwallace/css-parser'
import { calculateSpecificity, type Specificity } from '@projectwallace/css-analyzer'
import { compareSpecificity } from '@projectwallace/css-analyzer/selectors'

const ROOT_SELECTORS = new Set([':root', 'html'])

type CustomPropertyDeclaration = {
value: string
specificity: Specificity
important: boolean
order_index: number
}

/**
* Resolve the winning value for every custom property declared on `:root` or `html`,
* ignoring anything nested inside a conditional at-rule (@media, @supports, ...) since
* its value can't be known without evaluating that condition. This intentionally only
* covers the cases we can be fully sure of - not every custom property on the page.
*/
export function extract_root_custom_properties(css: string): Map<string, string> {
let ast
try {
ast = parse(css, { parse_selectors: false })
} catch {
return new Map()
}

let declarations_by_property = new Map<string, CustomPropertyDeclaration[]>()
let order_index = 0
let at_rule_depth = 0

traverse(ast, {
enter(node) {
if (node.type === AT_RULE) {
at_rule_depth++
return
}

if (node.type !== STYLE_RULE || at_rule_depth > 0 || !node.has_prelude || !node.has_block) {
return
}

let selector_ast
try {
selector_ast = parse_selector_list(node.prelude.text)
} catch {
return
}
if (!selector_ast.has_children) return

let specificities = calculateSpecificity(selector_ast)
let root_specificity: Specificity | undefined

for (let i = 0; i < selector_ast.children.length; i++) {
if (ROOT_SELECTORS.has(selector_ast.children[i].text.trim().toLowerCase())) {
root_specificity = specificities[i]
break
}
}
if (!root_specificity) return

for (let child of node.block.children) {
if (child.type === DECLARATION && child.property.startsWith('--')) {
let entries = declarations_by_property.get(child.property)
if (!entries) {
entries = []
declarations_by_property.set(child.property, entries)
}
entries.push({
value: child.value?.text ?? '',
specificity: root_specificity,
important: child.is_important,
order_index: order_index++
})
}
}
},
leave(node) {
if (node.type === AT_RULE) {
at_rule_depth--
}
}
})

let resolved = new Map<string, string>()
for (let [property, entries] of declarations_by_property) {
let winner = entries.toSorted((a, b) => {
if (a.important !== b.important) return a.important ? 1 : -1
let specificity_comparison = compareSpecificity(a.specificity, b.specificity)
if (specificity_comparison !== 0) return specificity_comparison
return a.order_index - b.order_index
})[entries.length - 1]!
resolved.set(property, winner.value)
}

return resolved
}

function find_top_level_comma(text: string): number {
let depth = 0
for (let i = 0; i < text.length; i++) {
if (text[i] === '(') depth++
else if (text[i] === ')') depth--
else if (text[i] === ',' && depth === 0) return i
}
return -1
}

/**
* Substitute every var(--name) / var(--name, fallback) occurrence in `value` using
* `custom_properties`, recursively resolving chained references (a variable whose own
* value contains another var() call). Falls back to the fallback expression - resolving
* it too - when the variable isn't in the map, and leaves a var() call untouched when it
* can't be resolved at all (unknown property, no fallback, or a circular reference).
*/
export function resolve_css_value(
value: string,
custom_properties: Map<string, string>,
seen: Set<string> = new Set()
): string {
let result = ''
let index = 0

while (index < value.length) {
let var_start = value.indexOf('var(', index)
if (var_start === -1) {
result += value.slice(index)
break
}

result += value.slice(index, var_start)

let depth = 0
let content_start = var_start + 'var('.length
let content_end = content_start
for (; content_end < value.length; content_end++) {
if (value[content_end] === '(') depth++
else if (value[content_end] === ')') {
if (depth === 0) break
depth--
}
}

if (content_end >= value.length) {
// Unterminated var(...) - nothing sensible to do but keep the rest verbatim.
result += value.slice(var_start)
break
}

let inner = value.slice(content_start, content_end)
let comma_index = find_top_level_comma(inner)
let name = (comma_index === -1 ? inner : inner.slice(0, comma_index)).trim()
let fallback = comma_index === -1 ? undefined : inner.slice(comma_index + 1).trim()

if (custom_properties.has(name) && !seen.has(name)) {
let next_seen = new Set([...seen, name])
result += resolve_css_value(custom_properties.get(name)!, custom_properties, next_seen)
} else if (fallback === undefined) {
result += value.slice(var_start, content_end + 1)
} else {
result += resolve_css_value(fallback, custom_properties, seen)
}

index = content_end + 1
}

return result
}
Loading
Loading