Skip to content

Commit 95b2233

Browse files
committed
ref: Added new css var function parser
1 parent 030e4ad commit 95b2233

File tree

4 files changed

+260
-61
lines changed

4 files changed

+260
-61
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { parseCssVariableExpression } from './css-var-func-parser';
2+
3+
describe('css-var-func-parser', () => {
4+
it('basic css variable resolution', () => {
5+
const cssVal = 'red';
6+
const value = parseCssVariableExpression('var(--bg-color)', (cssVarName) => cssVal);
7+
8+
expect(value).toBe('red');
9+
});
10+
11+
it('css expression with list of values', () => {
12+
const cssVal = 'red';
13+
const value = parseCssVariableExpression('25 solid var(--bg-color)', (cssVarName) => cssVal);
14+
15+
expect(value).toBe('25 solid red');
16+
});
17+
18+
it('basic css variable resolution failure', () => {
19+
const value = parseCssVariableExpression('var(--bg-color)', null);
20+
21+
expect(value).toBe('unset');
22+
});
23+
24+
it('css expression with list of values failure', () => {
25+
expect(() => parseCssVariableExpression('25 solid var(--bg-color)', null)).toThrowError('var(--bg-color)');
26+
});
27+
28+
it('css variable with basic fallback', () => {
29+
const value = parseCssVariableExpression('var(--bg-color, yellow)', null);
30+
31+
expect(value).toBe('yellow');
32+
});
33+
34+
it('css variable with css variable fallback', () => {
35+
const fallbackVal = 'blue';
36+
const value = parseCssVariableExpression('var(--bg-color, var(--test))', (cssVarName) => {
37+
if (cssVarName === '--test') {
38+
return fallbackVal;
39+
}
40+
});
41+
42+
expect(value).toBe(fallbackVal);
43+
});
44+
45+
it('css variable with multiple fallbacks', () => {
46+
const value = parseCssVariableExpression('var(--bg-color, var(--test), var(--test2), purple)', null);
47+
48+
expect(value).toBe('purple');
49+
});
50+
51+
it('css variable with deeply nested css variable fallback', () => {
52+
const fallbackVal = 'black';
53+
const value = parseCssVariableExpression('var(--bg-color, var(--test, var(--test2, var(--test3, red))))', (cssVarName) => {
54+
if (cssVarName === '--test3') {
55+
return fallbackVal;
56+
}
57+
});
58+
59+
expect(value).toBe(fallbackVal);
60+
});
61+
62+
it('css expression with list of comma-separated values', () => {
63+
const expression = 'var(--shadow-color) var(--shadow-offset-x) var(--shadow-offset-y), var(--shadow-inset-color) var(--shadow-inset-x) var(--shadow-inset-y)';
64+
const value = parseCssVariableExpression(expression, (cssVarName) => {
65+
switch (cssVarName) {
66+
case '--shadow-color':
67+
return 'green';
68+
case '--shadow-offset-x':
69+
return '5';
70+
case '--shadow-offset-y':
71+
return '5';
72+
case '--shadow-inset-color':
73+
return 'yellow';
74+
case '--shadow-inset-x':
75+
return '15';
76+
case '--shadow-inset-y':
77+
return '15';
78+
}
79+
});
80+
81+
expect(value).toBe('green 5 5, yellow 15 15');
82+
});
83+
84+
it('css expression with list of comma-separated values failure', () => {
85+
const expression = 'var(--shadow-color) var(--shadow-offset-x) var(--shadow-offset-y), var(--shadow-inset-color) var(--shadow-inset-x) var(--shadow-inset-y)';
86+
87+
expect(() =>
88+
parseCssVariableExpression(expression, (cssVarName) => {
89+
switch (cssVarName) {
90+
case '--shadow-color':
91+
return 'green';
92+
case '--shadow-offset-x':
93+
return '5';
94+
case '--shadow-offset-y':
95+
return '5';
96+
}
97+
}),
98+
).toThrowError('var(--shadow-inset-color)');
99+
});
100+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { tokenize, TokenType } from '@csstools/css-tokenizer';
2+
import { ComponentValue, FunctionNode, isCommentNode, isFunctionNode, isTokenNode, parseCommaSeparatedListOfComponentValues, parseListOfComponentValues, stringify, walk } from '@csstools/css-parser-algorithms';
3+
4+
const functionName: string = 'var';
5+
6+
export function parseCssVariableExpression(value: string, replaceWithCallback: (cssVarName: string) => string): string {
7+
const componentValueSet = parseCommaSeparatedListOfComponentValues(
8+
tokenize({
9+
css: value,
10+
}),
11+
);
12+
13+
for (const componentValues of componentValueSet) {
14+
let unresolvedNode: ComponentValue = null;
15+
16+
walk(componentValues, (entry, index) => {
17+
const componentValue = entry.node;
18+
19+
if (isFunctionNode(componentValue) && componentValue.getName() === functionName) {
20+
const newNode = _parseCssVarFunctionArguments(componentValue, replaceWithCallback);
21+
const parentNodes = entry.parent.value;
22+
23+
if (!newNode || !_replaceNode(index, newNode, parentNodes)) {
24+
unresolvedNode = componentValue;
25+
return false;
26+
}
27+
}
28+
});
29+
30+
if (unresolvedNode) {
31+
const isSingleNode = componentValueSet.length === 1 && componentValueSet[0].length === 1 && componentValueSet[0][0] === unresolvedNode;
32+
if (!isSingleNode) {
33+
throw new Error(`Failed to resolve CSS variable '${unresolvedNode}' for expression: '${value}'`);
34+
}
35+
36+
return 'unset';
37+
}
38+
}
39+
40+
return stringify(componentValueSet).trim();
41+
}
42+
43+
/**
44+
* Parses css variable functions and their fallback values.
45+
*
46+
* @param node
47+
* @param replaceWithCallback
48+
* @returns
49+
*/
50+
function _parseCssVarFunctionArguments(node: FunctionNode, replaceWithCallback: (cssVarName: string) => string): ComponentValue | ComponentValue[] {
51+
let cssVarName: string = null;
52+
let finalValue: ComponentValue | ComponentValue[] = null;
53+
let currentFallbackValues: ComponentValue[];
54+
55+
const fallbackValueSet: ComponentValue[][] = [];
56+
57+
node.forEach((entry) => {
58+
const childNode = entry.node;
59+
60+
if (isCommentNode(childNode)) {
61+
return;
62+
}
63+
64+
if (isTokenNode(childNode)) {
65+
const tokens = childNode.value;
66+
67+
if (tokens[0] === TokenType.Ident) {
68+
if (tokens[1].startsWith('--')) {
69+
if (fallbackValueSet.length) {
70+
throw new Error('Invalid css variable function fallback value: ' + childNode);
71+
} else {
72+
// This is the first parsable parameter
73+
cssVarName = tokens[1];
74+
}
75+
return;
76+
}
77+
}
78+
79+
// Track the fallback comma-separated values
80+
if (tokens[0] === TokenType.Comma) {
81+
currentFallbackValues = [];
82+
fallbackValueSet.push(currentFallbackValues);
83+
return;
84+
}
85+
}
86+
87+
if (currentFallbackValues) {
88+
currentFallbackValues.push(childNode);
89+
}
90+
});
91+
92+
// Resolve the css variable here and use a fallback value if it fails
93+
const cssVarValue = replaceWithCallback?.(cssVarName);
94+
95+
if (cssVarValue) {
96+
finalValue = parseListOfComponentValues(
97+
tokenize({
98+
css: cssVarValue,
99+
}),
100+
);
101+
} else {
102+
// Switch to the first available fallback value if any
103+
for (const componentValues of fallbackValueSet) {
104+
let isValidFallback: boolean = true;
105+
106+
walk(componentValues, (entry, index) => {
107+
const componentValue = entry.node;
108+
109+
if (isFunctionNode(componentValue) && componentValue.getName() === functionName) {
110+
const nodeResult = _parseCssVarFunctionArguments(componentValue, replaceWithCallback);
111+
const parentNodes = entry.parent.value;
112+
113+
if (!nodeResult || !_replaceNode(index, nodeResult, parentNodes)) {
114+
isValidFallback = false;
115+
return false; // Invalid fallback
116+
}
117+
}
118+
});
119+
120+
if (isValidFallback) {
121+
finalValue = componentValues;
122+
break;
123+
}
124+
}
125+
}
126+
127+
return finalValue;
128+
}
129+
130+
function _replaceNode(index: string | number, newNode: ComponentValue | ComponentValue[], nodes: ComponentValue[]): boolean {
131+
if (typeof index !== 'number') {
132+
return false;
133+
}
134+
135+
if (Array.isArray(newNode)) {
136+
nodes.splice(index, 1, ...newNode);
137+
} else {
138+
nodes.splice(index, 1, newNode);
139+
}
140+
141+
return true;
142+
}

packages/core/ui/core/properties/index.ts

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Style } from '../../styling/style';
66

77
import { profile } from '../../../profiling';
88
import { CoreTypes } from '../../enums';
9+
import { parseCssVariableExpression } from '../../../css/css-var-func-parser';
910

1011
/**
1112
* Value specifying that Property should be set to its initial value.
@@ -108,56 +109,8 @@ export function isCssWideKeyword(value: any): value is CoreTypes.CSSWideKeywords
108109
return value === 'initial' || value === 'inherit' || isCssUnsetValue(value);
109110
}
110111

111-
export function _evaluateCssVariableExpression(view: ViewBase, value: string, onCssVarExpressionParse?: (cssVarName: string) => void): string {
112-
let output = value.trim();
113-
114-
// Evaluate every (and nested) css-variables in the value
115-
let lastValue: string;
116-
117-
while (lastValue !== output) {
118-
lastValue = output;
119-
120-
const idx = output.lastIndexOf('var(');
121-
if (idx === -1) {
122-
continue;
123-
}
124-
125-
const endIdx = output.indexOf(')', idx);
126-
if (endIdx === -1) {
127-
continue;
128-
}
129-
130-
const matched = output
131-
.substring(idx + 4, endIdx)
132-
.split(',')
133-
.map((v) => v.trim())
134-
.filter((v) => !!v);
135-
const cssVariableName = matched.shift();
136-
137-
// Execute the callback early to allow operations like preloading missing variables
138-
if (onCssVarExpressionParse) {
139-
onCssVarExpressionParse(cssVariableName);
140-
}
141-
142-
let cssVariableValue = view.style.getCssVariable(cssVariableName);
143-
if (cssVariableValue == null && matched.length) {
144-
for (const cssVal of matched) {
145-
if (cssVal && !cssVal.includes(cssErrorVarPlaceHolder)) {
146-
cssVariableValue = cssVal;
147-
break;
148-
}
149-
}
150-
}
151-
152-
if (!cssVariableValue) {
153-
cssVariableValue = cssErrorVarPlaceHolder;
154-
}
155-
156-
output = `${output.substring(0, idx)}${cssVariableValue}${output.substring(endIdx + 1)}`;
157-
}
158-
159-
// If at least one variable failed to resolve, discard the whole expression
160-
return output.includes(cssErrorVarPlaceHolder) ? undefined : output;
112+
export function _evaluateCssVariableExpression(value: string, cssVarResolveCallback?: (cssVarName: string) => string): string {
113+
return parseCssVariableExpression(value, cssVarResolveCallback);
161114
}
162115

163116
export function _evaluateCssCalcExpression(value: string) {

packages/core/ui/styling/style-scope.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,25 +65,29 @@ const pattern = /('|")(.*?)\1/;
6565
/**
6666
* Evaluate css-variable and css-calc expressions.
6767
*/
68-
function evaluateCssExpressions(view: ViewBase, property: string, value: string, onCssVarExpressionParse?: (cssVarName: string) => void) {
68+
function evaluateCssExpressions(view: ViewBase, property: string, value: string, cssVarResolveCallback: (cssVarName: string) => string) {
6969
if (typeof value !== 'string') {
7070
return value;
7171
}
7272

7373
if (isCssVariableExpression(value)) {
74-
const newValue = _evaluateCssVariableExpression(view, value, onCssVarExpressionParse);
75-
if (newValue === undefined) {
74+
try {
75+
value = _evaluateCssVariableExpression(value, cssVarResolveCallback);
76+
} catch (e) {
77+
if (e instanceof Error) {
78+
Trace.write(`Failed to evaluate css-var for property [${property}] for expression [${value}] to ${view}. ${e.message}`, Trace.categories.Style, Trace.messageType.warn);
79+
}
7680
return unsetValue;
7781
}
78-
79-
value = newValue;
8082
}
8183

8284
if (isCssCalcExpression(value)) {
8385
try {
8486
value = _evaluateCssCalcExpression(value);
8587
} catch (e) {
86-
Trace.write(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.stack}`, Trace.categories.Error, Trace.messageType.error);
88+
if (e instanceof Error) {
89+
Trace.write(`Failed to evaluate css-calc for property [${property}] for expression [${value}] to ${view}. ${e.message}`, Trace.categories.Style, Trace.messageType.error);
90+
}
8791
return unsetValue;
8892
}
8993
}
@@ -717,11 +721,12 @@ export class CssState {
717721
const valuesToApply = {};
718722
const cssExpsProperties = {};
719723
const replacementFunc = (g) => g[1].toUpperCase();
720-
const onCssVarExpressionParse = (cssVarName: string) => {
724+
const cssVarResolveCallback = (cssVarName: string) => {
721725
// If variable name is still in the property bag, parse its value and apply it to the view
722726
if (cssVarName in cssExpsProperties) {
723727
cssExpsPropsCallback(cssVarName);
724728
}
729+
return view.style.getCssVariable(cssVarName);
725730
};
726731
// This callback is also used for handling nested css variable expressions
727732
const cssExpsPropsCallback = (property: string) => {
@@ -730,7 +735,7 @@ export class CssState {
730735
// Remove the property first to avoid recalculating it in a later step using lazy loading
731736
delete cssExpsProperties[property];
732737

733-
value = evaluateCssExpressions(view, property, value, onCssVarExpressionParse);
738+
value = evaluateCssExpressions(view, property, value, cssVarResolveCallback);
734739

735740
if (value === unsetValue) {
736741
delete newPropertyValues[property];
@@ -765,9 +770,8 @@ export class CssState {
765770
valuesToApply[property] = value;
766771
}
767772

773+
// Keep a copy of css expressions keys and iterate that while removing properties from the bag
768774
const cssExpsPropKeys = Object.keys(cssExpsProperties);
769-
770-
// We need to parse CSS vars first before evaluating css expressions
771775
for (const property of cssExpsPropKeys) {
772776
if (property in cssExpsProperties) {
773777
cssExpsPropsCallback(property);
@@ -1159,7 +1163,7 @@ export const applyInlineStyle = profile(function applyInlineStyle(view: ViewBase
11591163
return;
11601164
}
11611165

1162-
const value = evaluateCssExpressions(view, property, d.value);
1166+
const value = evaluateCssExpressions(view, property, d.value, (cssVarName) => view.style.getCssVariable(cssVarName));
11631167
if (property in view.style) {
11641168
view.style[property] = value;
11651169
} else {

0 commit comments

Comments
 (0)