Skip to content

Commit cbf14f8

Browse files
committed
chore(themes): add additional tests for css variable completions
Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent 931f033 commit cbf14f8

8 files changed

Lines changed: 326 additions & 93 deletions

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 91 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
importfsfrom'fs';
2-
importpathfrom'path';
1+
importfsfrom'node:fs';
2+
importpathfrom'node:path';
33
importprocessfrom'process';
4+
import{fileURLToPath}from'node:url';
45

56
constbuildPath='dist/';
67
constsourcePath='src/';
@@ -11,7 +12,7 @@ function resolve(relativePath) {
1112

1213
// ---
1314

14-
functionreadJSONFile(jsonFilePath){
15+
exportfunctionreadJSONFile(jsonFilePath){
1516
try{
1617
constfileContents=fs.readFileSync(jsonFilePath,'utf-8');
1718
returnJSON.parse(fileContents);
@@ -20,7 +21,7 @@ function readJSONFile(jsonFilePath) {
2021
}
2122
}
2223

23-
functionwriteJSONFile(jsonFilePath,data){
24+
exportfunctionwriteJSONFile(jsonFilePath,data){
2425
try{
2526
fs.writeFileSync(jsonFilePath,JSON.stringify(data,null,2));
2627
}catch(error){
@@ -30,13 +31,13 @@ function writeJSONFile(jsonFilePath, data) {
3031

3132
// ---
3233

33-
functionisObject(value){
34+
exportfunctionisObject(value){
3435
returntypeofvalue==='object'&&value!==null;
3536
}
3637

3738
// ---
3839

39-
functionvisitTokenTree(tokens,visitor,prefix=''){
40+
exportfunctionvisitTokenTree(tokens,visitor,prefix=''){
4041
for(const[key,value]ofObject.entries(tokens)){
4142
constpath=key!=='@' ? `${prefix}${key}` : prefix.slice(0,-1);
4243
if(isObject(value)){
@@ -49,7 +50,7 @@ function visitTokenTree(tokens, visitor, prefix = '') {
4950
}
5051
}
5152

52-
functionloadTokenDictionary(tokenJsonFilePath){
53+
exportfunctionloadTokenDictionary(tokenJsonFilePath){
5354
consttokensByPath={};
5455
consttokensJson=readJSONFile(resolve(tokenJsonFilePath));
5556
visitTokenTree(tokensJson,(path,token)=>{
@@ -64,31 +65,25 @@ function loadTokenDictionary(tokenJsonFilePath) {
6465
constREFERENCE_PATTERN=/\{([^}]*)\}/g;
6566

6667
// Resolve all of the token value's path references (including resolution of their resolved values' path references).
67-
functionresolveTokenValue(value,tokenDictionary){
68-
while(value.match(REFERENCE_PATTERN)){
69-
value=value.replaceAll(REFERENCE_PATTERN,(_,referencedPath)=>{
70-
constreferencedValue=tokenDictionary[referencedPath]?.value;
71-
if(referencedValue===undefined){
72-
thrownewError(`Unable to resolve a referenced token for path: "${referencedPath}"`);
73-
}
74-
returnreferencedValue;
75-
});
76-
}
77-
returnvalue;
78-
}
68+
exportfunctionresolveTokenValue(value,tokenDictionary,referencePath=[]){
69+
returnvalue.replaceAll(REFERENCE_PATTERN,(_,referencedPath)=>{
70+
if(referencePath.includes(referencedPath)){
71+
thrownewError(`Cyclic token reference: ${[...referencePath,referencedPath].join(' -> ')}`);
72+
}
7973

80-
// ---
74+
constreferencedValue=tokenDictionary[referencedPath]?.value;
75+
if(referencedValue===undefined){
76+
thrownewError(`Unable to resolve a referenced token for path: "${referencedPath}"`);
77+
}
8178

82-
constbaseTokenDictionary=loadTokenDictionary(`${sourcePath}/index.json`);
83-
constcompactThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/compact.json`);
84-
constdarkThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/dark.json`);
85-
consthighContrastThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/high-contrast.json`);
86-
constreducedMotionThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/reduced-motion.json`);
79+
returnresolveTokenValue(referencedValue,tokenDictionary,[...referencePath,referencedPath]);
80+
});
81+
}
8782

88-
constcategorizedTokens={};
83+
// ---
8984

9085
// Collect a categorized token value for the specified category and path.
91-
functioncollectCategorizedToken(path,details,category,value){
86+
functioncollectCategorizedToken(categorizedTokens,path,details,category,value){
9287
if(categorizedTokens[path]===undefined){
9388
categorizedTokens[path]={ ...details,values: {[category]: value}};
9489
}else{
@@ -97,22 +92,16 @@ function collectCategorizedToken(path, details, category, value) {
9792
}
9893

9994
// Collect categorized token values relative to the specified token dictionaries (last definition wins).
100-
functioncollectCategorizedTokens(category, ...tokenDictionaries){
95+
functioncollectCategorizedTokens(categorizedTokens,category, ...tokenDictionaries){
10196
constmergedTokenDictionary=Object.assign({}, ...tokenDictionaries);
10297
for(const[path,token]ofObject.entries(mergedTokenDictionary)){
10398
const{ value, ...details}=token;
104-
constresolvedValue=resolveTokenValue(value,mergedTokenDictionary);
105-
collectCategorizedToken(path,details,category,resolvedValue);
99+
constresolvedValue=resolveTokenValue(value,mergedTokenDictionary,[path]);
100+
collectCategorizedToken(categorizedTokens,path,details,category,resolvedValue);
106101
}
107102
}
108103

109-
collectCategorizedTokens('light',baseTokenDictionary);
110-
collectCategorizedTokens('dark',baseTokenDictionary,darkThemeTokenDictionary);
111-
collectCategorizedTokens('high-contrast',baseTokenDictionary,highContrastThemeTokenDictionary);
112-
collectCategorizedTokens('compact',baseTokenDictionary,compactThemeTokenDictionary);
113-
collectCategorizedTokens('reduced-motion',baseTokenDictionary,reducedMotionThemeTokenDictionary);
114-
115-
functionvaluesMatch(values){
104+
exportfunctionvaluesMatch(values){
116105
if(values.length===0){
117106
returntrue;
118107
}
@@ -125,37 +114,78 @@ function valuesMatch(values) {
125114
returntrue;
126115
}
127116

128-
functioncategoryValuesMatch(values,categories){
117+
exportfunctioncategoryValuesMatch(values,categories){
129118
returnvaluesMatch(categories.map(category=>values[category]));
130119
}
131120

132-
// Consolidate categorized token values that are the same.
133-
for(consttokenofObject.values(categorizedTokens)){
134-
constvalues=token.values;
135-
if(categoryValuesMatch(values,['light','dark'])){
136-
values['']=values['light'];
137-
deletevalues['light'];
138-
deletevalues['dark'];
139-
}
140-
if(categoryValuesMatch(values,['','high-contrast'])||categoryValuesMatch(values,['light','high-contrast'])){
141-
deletevalues['high-contrast'];
142-
}
143-
if(categoryValuesMatch(values,['','compact'])||categoryValuesMatch(values,['light','compact'])){
144-
deletevalues['compact'];
121+
exportfunctioncreateCssVarCompletions({
122+
baseTokenDictionary,
123+
compactThemeTokenDictionary,
124+
darkThemeTokenDictionary,
125+
highContrastThemeTokenDictionary,
126+
reducedMotionThemeTokenDictionary
127+
}){
128+
constcategorizedTokens={};
129+
130+
collectCategorizedTokens(categorizedTokens,'light',baseTokenDictionary);
131+
collectCategorizedTokens(categorizedTokens,'dark',baseTokenDictionary,darkThemeTokenDictionary);
132+
collectCategorizedTokens(categorizedTokens,'high-contrast',baseTokenDictionary,highContrastThemeTokenDictionary);
133+
collectCategorizedTokens(categorizedTokens,'compact',baseTokenDictionary,compactThemeTokenDictionary);
134+
collectCategorizedTokens(categorizedTokens,'reduced-motion',baseTokenDictionary,reducedMotionThemeTokenDictionary);
135+
136+
// Consolidate categorized token values that are the same.
137+
for(consttokenofObject.values(categorizedTokens)){
138+
constvalues=token.values;
139+
if(categoryValuesMatch(values,['light','dark'])){
140+
values['']=values['light'];
141+
deletevalues['light'];
142+
deletevalues['dark'];
143+
}
144+
if(categoryValuesMatch(values,['','high-contrast'])||categoryValuesMatch(values,['light','high-contrast'])){
145+
deletevalues['high-contrast'];
146+
}
147+
if(categoryValuesMatch(values,['','compact'])||categoryValuesMatch(values,['light','compact'])){
148+
deletevalues['compact'];
149+
}
150+
if(
151+
categoryValuesMatch(values,['','reduced-motion'])||
152+
categoryValuesMatch(values,['light','reduced-motion'])
153+
){
154+
deletevalues['reduced-motion'];
155+
}
145156
}
146-
if(categoryValuesMatch(values,['','reduced-motion'])||categoryValuesMatch(values,['light','reduced-motion'])){
147-
deletevalues['reduced-motion'];
157+
158+
// Collect all tokens with their paths transformed to css variable identifiers.
159+
constcssVarCompletions={};
160+
for(const[path,token]ofObject.entries(categorizedTokens)){
161+
cssVarCompletions[`--nve-${path.replaceAll('.','-')}`]=token;
148162
}
149-
}
150163

151-
// Collect all tokens with their paths transformed to css variable identifiers.
152-
constcssVarCompletions={};
153-
for(const[path,token]ofObject.entries(categorizedTokens)){
154-
cssVarCompletions[`--nve-${path.replaceAll('.','-')}`]=token;
164+
returncssVarCompletions;
155165
}
156166

157-
if(!fs.existsSync(`${buildPath}`)){
158-
fs.mkdirSync(`${buildPath}`);
167+
exportfunctionbuildCssVarCompletions(){
168+
constbaseTokenDictionary=loadTokenDictionary(`${sourcePath}/index.json`);
169+
constcompactThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/compact.json`);
170+
constdarkThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/dark.json`);
171+
consthighContrastThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/high-contrast.json`);
172+
constreducedMotionThemeTokenDictionary=loadTokenDictionary(`${sourcePath}/reduced-motion.json`);
173+
174+
constcssVarCompletions=createCssVarCompletions({
175+
baseTokenDictionary,
176+
compactThemeTokenDictionary,
177+
darkThemeTokenDictionary,
178+
highContrastThemeTokenDictionary,
179+
reducedMotionThemeTokenDictionary
180+
});
181+
182+
if(!fs.existsSync(`${buildPath}`)){
183+
fs.mkdirSync(`${buildPath}`);
184+
}
185+
186+
writeJSONFile('./dist/data.css-vars.json',cssVarCompletions);
159187
}
160188

161-
writeJSONFile('./dist/data.css-vars.json',cssVarCompletions);
189+
if(process.argv[1]===fileURLToPath(import.meta.url)){
190+
buildCssVarCompletions();
191+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import{describe,expect,it}from'vitest';
5+
import{createCssVarCompletions,resolveTokenValue}from'./css-var-completions.js';
6+
7+
describe('css variable completions',()=>{
8+
it('should resolve references and collapse matching theme values',()=>{
9+
constcompletions=createCssVarCompletions({
10+
baseTokenDictionary: {
11+
'ref.color.base': {value: 'white',type: 'color'},
12+
'ref.scale.space': {value: '1'},
13+
'ref.space.sm': {value: '{ref.scale.space} * 8px',type: 'spacing'},
14+
'sys.layer.canvas.background': {value: '{ref.color.base}',type: 'color'}
15+
},
16+
darkThemeTokenDictionary: {
17+
'ref.color.base': {value: 'black',type: 'color'}
18+
},
19+
highContrastThemeTokenDictionary: {
20+
'ref.color.base': {value: 'white',type: 'color'}
21+
},
22+
compactThemeTokenDictionary: {
23+
'ref.scale.space': {value: '0.8'}
24+
},
25+
reducedMotionThemeTokenDictionary: {}
26+
});
27+
28+
expect(completions['--nve-sys-layer-canvas-background'].values).toEqual({
29+
light: 'white',
30+
dark: 'black'
31+
});
32+
expect(completions['--nve-ref-space-sm'].values).toEqual({
33+
'': '1 * 8px',
34+
compact: '0.8 * 8px'
35+
});
36+
});
37+
38+
it('should fail when a token reference cannot be resolved',()=>{
39+
expect(()=>resolveTokenValue('{ref.color.missing}',{},['sys.color.text'])).toThrow(
40+
'Unable to resolve a referenced token for path: "ref.color.missing"'
41+
);
42+
});
43+
44+
it('should fail when token references are cyclic',()=>{
45+
consttokenDictionary={
46+
'ref.color.a': {value: '{ref.color.b}'},
47+
'ref.color.b': {value: '{ref.color.a}'}
48+
};
49+
50+
expect(()=>resolveTokenValue('{ref.color.a}',tokenDictionary,['sys.color.text'])).toThrow(
51+
'Cyclic token reference: sys.color.text -> ref.color.a -> ref.color.b -> ref.color.a'
52+
);
53+
});
54+
});

‎projects/themes/build/style-dictionary.config.js‎

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
importStyleDictionaryfrom'style-dictionary';
22
import{formattedVariables}from'style-dictionary/utils';
33
import{globSync}from'glob';
4+
importprocessfrom'node:process';
5+
import{fileURLToPath}from'node:url';
46

57
constbuildPath='dist/';
68
constsourcePath='src/';
@@ -68,46 +70,48 @@ StyleDictionary.registerTransform({
6870
name: 'custom/validate',
6971
type: 'value',
7072
transitive: true,
71-
transform: obj=>{
72-
const{ value, type, name, original, filePath }=obj;
73-
constisHighContrast=filePath.includes('high-contrast');
74-
constisReferenceToken=name.includes('nve-ref');
75-
constisVisualizationToken=name?.includes('nve-sys-visualization');
76-
constisColorToken=type==='color';
77-
constisRawValue=!original.value.startsWith('{');
78-
constisPxValue=original.value.endsWith('px');
79-
constisSizeToken=name?.includes('nve-ref-size');
80-
constisSpaceToken=name?.includes('nve-ref-space');
81-
constisBorderToken=name?.includes('nve-ref-border');
82-
constisOutlineToken=name?.includes('nve-ref-outline');
83-
84-
if(isColorToken&&isRawValue&&!isReferenceToken&&!isVisualizationToken&&!isHighContrast){
85-
console.error(
86-
'\x1b[31m',
87-
`Token ${name} is a invalid color. Color must implement a reference to a {ref.*} token to prevent cross theme color divergence`
88-
);
89-
thrownewError();
90-
}
73+
transform: validateTokenValue
74+
});
9175

92-
if(isPxValue&&isRawValue&&!isSizeToken&&!isSpaceToken&&!isBorderToken&&!isOutlineToken){
93-
console.error(
94-
'\x1b[31m',
95-
`Token ${name} is a invalid size/space value. Value must implement a reference to a {ref.space-*} or {ref.size-*} token to prevent cross theme layout divergence`
96-
);
97-
thrownewError();
98-
}
76+
exportfunctionvalidateTokenValue(obj){
77+
const{ value, type, name, original, filePath }=obj;
78+
constisHighContrast=filePath.includes('high-contrast');
79+
constisReferenceToken=name.includes('nve-ref');
80+
constisVisualizationToken=name?.includes('nve-sys-visualization');
81+
constisColorToken=type==='color';
82+
constisRawValue=!original.value.startsWith('{');
83+
constisPxValue=original.value.endsWith('px');
84+
constisSizeToken=name?.includes('nve-ref-size');
85+
constisSpaceToken=name?.includes('nve-ref-space');
86+
constisBorderToken=name?.includes('nve-ref-border');
87+
constisOutlineToken=name?.includes('nve-ref-outline');
88+
89+
if(isColorToken&&isRawValue&&!isReferenceToken&&!isVisualizationToken&&!isHighContrast){
90+
thrownewError(
91+
`Token ${name} is an invalid color. Color must use a {ref.*} token reference to prevent cross-theme color divergence`
92+
);
93+
}
9994

100-
returnvalue;
95+
if(isPxValue&&isRawValue&&!isSizeToken&&!isSpaceToken&&!isBorderToken&&!isOutlineToken){
96+
thrownewError(
97+
`Token ${name} is an invalid size or space value. Value must use a {ref.space-*} or {ref.size-*} token reference to prevent cross-theme layout divergence`
98+
);
10199
}
102-
});
100+
101+
returnvalue;
102+
}
103+
104+
exportfunctiongetThemeSelector(theme){
105+
returntheme!=='index' ? `[nve-theme~='${theme}']` : `:root, [nve-theme~='light']`;
106+
}
103107

104108
StyleDictionary.registerFormat({
105109
name: 'custom/css',
106110
format: async({ dictionary, options })=>{
107111
constexperimental=dictionary.allTokens.find(t=>t.name.includes('experimental'))
108112
? '/*!\n * @experimental\n */'
109113
: '';
110-
constselector=options.theme!=='index' ? `[nve-theme*='${options.theme}']` : `:root, [nve-theme~='light']`;
114+
constselector=getThemeSelector(options.theme);
111115
constconfig=dictionary.allTokens.filter(t=>t.name.includes('config')&&!t.name.includes('experimental'));
112116
constconfigString=`:root{${config.map(t=>`--${t.name}: ${t.value}`).join(';\n')}}`;
113117
constformatted=formattedVariables({format: 'css', dictionary,outputReferences: options.outputReferences})
@@ -195,7 +199,7 @@ StyleDictionary.registerFormat({
195199
}
196200
});
197201

198-
asyncfunctionbuildTokens(){
202+
exportasyncfunctionbuildTokens(){
199203
constthemes=globSync(`${sourcePath}*.json`).filter(path=>!path.includes('index'));
200204

201205
constsd=newStyleDictionary({
@@ -328,4 +332,6 @@ function getTheme(path) {
328332
returnpath.replace('dist/','').replace(`src/`,'').split('.')[0];
329333
}
330334

331-
buildTokens();
335+
if(process.argv[1]===fileURLToPath(import.meta.url)){
336+
awaitbuildTokens();
337+
}

0 commit comments

Comments
 (0)