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-
import fs from 'fs';
2-
import path from 'path';
1+
import fs from 'node:fs';
2+
import path from 'node:path';
33
import process from 'process';
4+
import { fileURLToPath } from 'node:url';
45

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

1213
// ---
1314

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

23-
function writeJSONFile(jsonFilePath, data) {
24+
export function writeJSONFile(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-
function isObject(value) {
34+
export function isObject(value) {
3435
return typeof value === 'object' && value !== null;
3536
}
3637

3738
// ---
3839

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

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

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

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

82-
const baseTokenDictionary = loadTokenDictionary(`${sourcePath}/index.json`);
83-
const compactThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/compact.json`);
84-
const darkThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/dark.json`);
85-
const highContrastThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/high-contrast.json`);
86-
const reducedMotionThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/reduced-motion.json`);
79+
return resolveTokenValue(referencedValue, tokenDictionary, [...referencePath, referencedPath]);
80+
});
81+
}
8782

88-
const categorizedTokens = {};
83+
// ---
8984

9085
// Collect a categorized token value for the specified category and path.
91-
function collectCategorizedToken(path, details, category, value) {
86+
function collectCategorizedToken(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-
function collectCategorizedTokens(category, ...tokenDictionaries) {
95+
function collectCategorizedTokens(categorizedTokens, category, ...tokenDictionaries) {
10196
const mergedTokenDictionary = Object.assign({}, ...tokenDictionaries);
10297
for (const [path, token] of Object.entries(mergedTokenDictionary)) {
10398
const { value, ...details } = token;
104-
const resolvedValue = resolveTokenValue(value, mergedTokenDictionary);
105-
collectCategorizedToken(path, details, category, resolvedValue);
99+
const resolvedValue = 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-
function valuesMatch(values) {
104+
export function valuesMatch(values) {
116105
if (values.length === 0) {
117106
return true;
118107
}
@@ -125,37 +114,78 @@ function valuesMatch(values) {
125114
return true;
126115
}
127116

128-
function categoryValuesMatch(values, categories) {
117+
export function categoryValuesMatch(values, categories) {
129118
return valuesMatch(categories.map(category => values[category]));
130119
}
131120

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

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

157-
if (!fs.existsSync(`${buildPath}`)) {
158-
fs.mkdirSync(`${buildPath}`);
167+
export function buildCssVarCompletions() {
168+
const baseTokenDictionary = loadTokenDictionary(`${sourcePath}/index.json`);
169+
const compactThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/compact.json`);
170+
const darkThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/dark.json`);
171+
const highContrastThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/high-contrast.json`);
172+
const reducedMotionThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/reduced-motion.json`);
173+
174+
const cssVarCompletions = 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+
const completions = 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+
const tokenDictionary = {
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
import StyleDictionary from 'style-dictionary';
22
import { formattedVariables } from 'style-dictionary/utils';
33
import { globSync } from 'glob';
4+
import process from 'node:process';
5+
import { fileURLToPath } from 'node:url';
46

57
const buildPath = 'dist/';
68
const sourcePath = '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-
const isHighContrast = filePath.includes('high-contrast');
74-
const isReferenceToken = name.includes('nve-ref');
75-
const isVisualizationToken = name?.includes('nve-sys-visualization');
76-
const isColorToken = type === 'color';
77-
const isRawValue = !original.value.startsWith('{');
78-
const isPxValue = original.value.endsWith('px');
79-
const isSizeToken = name?.includes('nve-ref-size');
80-
const isSpaceToken = name?.includes('nve-ref-space');
81-
const isBorderToken = name?.includes('nve-ref-border');
82-
const isOutlineToken = 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-
throw new Error();
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-
throw new Error();
98-
}
76+
export function validateTokenValue(obj) {
77+
const { value, type, name, original, filePath } = obj;
78+
const isHighContrast = filePath.includes('high-contrast');
79+
const isReferenceToken = name.includes('nve-ref');
80+
const isVisualizationToken = name?.includes('nve-sys-visualization');
81+
const isColorToken = type === 'color';
82+
const isRawValue = !original.value.startsWith('{');
83+
const isPxValue = original.value.endsWith('px');
84+
const isSizeToken = name?.includes('nve-ref-size');
85+
const isSpaceToken = name?.includes('nve-ref-space');
86+
const isBorderToken = name?.includes('nve-ref-border');
87+
const isOutlineToken = name?.includes('nve-ref-outline');
88+
89+
if (isColorToken && isRawValue && !isReferenceToken && !isVisualizationToken && !isHighContrast) {
90+
throw new Error(
91+
`Token ${name} is an invalid color. Color must use a {ref.*} token reference to prevent cross-theme color divergence`
92+
);
93+
}
9994

100-
return value;
95+
if (isPxValue && isRawValue && !isSizeToken && !isSpaceToken && !isBorderToken && !isOutlineToken) {
96+
throw new Error(
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+
return value;
102+
}
103+
104+
export function getThemeSelector(theme) {
105+
return theme !== 'index' ? `[nve-theme~='${theme}']` : `:root, [nve-theme~='light']`;
106+
}
103107

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

198-
async function buildTokens() {
202+
export async function buildTokens() {
199203
const themes = globSync(`${sourcePath}*.json`).filter(path => !path.includes('index'));
200204

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

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

0 commit comments

Comments
 (0)