Skip to content

Commit cda9005

Browse files
committed
feat(tokens): fail the build on colliding token names with different values
Style-dictionary's name/kebab flattening can map two distinct token paths to one CSS custom property, and the later definition silently wins. The validator derives names with the same change-case library the transform uses and aborts the build on any collision whose values differ.
1 parent 077dee3 commit cda9005

5 files changed

Lines changed: 217 additions & 60 deletions

File tree

packages/tokens/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"devDependencies": {
1717
"@tokens-studio/sd-transforms": "^1.2.12",
18+
"change-case": "^5.4.4",
1819
"remeda": "^2.21.2",
1920
"style-dictionary": "^4.3.3",
2021
"tsx": "^4.19.3",

packages/tokens/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@ import fs from 'node:fs';
22

33
import { tokensToCss } from './tokens-to-css';
44

5+
import tokens from '../tokens.json';
56
import { OUTPUT_DIR } from './constants';
67
import { ejectTokens } from './eject-tokens';
78
import { generateCSSBundle } from './generate-css-bundle';
89
import { buildManifest } from './manifest';
10+
import { assertNoValueCollisions } from './validate-collisions';
911

1012
// Stale outputs must not survive a rebuild: a renamed set leaves its old file
1113
// behind, and an incremental UI build would ship it as if it were current.
1214
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
1315

1416
const manifest = buildManifest();
1517

18+
assertNoValueCollisions(
19+
tokens,
20+
[...manifest.primitives, ...manifest.themes].map((entry) => entry.key),
21+
);
22+
1623
ejectTokens(manifest);
1724
await tokensToCss(manifest);
1825
await generateCSSBundle(manifest);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { assertNoValueCollisions, findCssNameCollisions } from './validate-collisions';
4+
5+
describe('findCssNameCollisions', () => {
6+
it('finds names that kebab to the same CSS custom property', () => {
7+
const collisions = findCssNameCollisions({
8+
ax: {
9+
colors: {
10+
'acc7- 100': { value: '#cfd0d6', type: 'color' },
11+
'acc7-100': { value: '#cfd0d6', type: 'color' },
12+
'acc7-200': { value: '#a0a1ad', type: 'color' },
13+
},
14+
},
15+
});
16+
17+
expect(collisions).toEqual([
18+
{
19+
cssName: '--ax-colors-acc7-100',
20+
entries: [
21+
{ path: 'ax/colors/acc7- 100', value: '#cfd0d6' },
22+
{ path: 'ax/colors/acc7-100', value: '#cfd0d6' },
23+
],
24+
sameValue: true,
25+
},
26+
]);
27+
});
28+
29+
it('returns nothing for distinct names', () => {
30+
expect(
31+
findCssNameCollisions({
32+
ax: { colors: { 'gray-100': { value: '#eee', type: 'color' } } },
33+
}),
34+
).toEqual([]);
35+
});
36+
});
37+
38+
describe('assertNoValueCollisions', () => {
39+
it('only warns when the colliding values are identical', () => {
40+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
41+
const tokens = {
42+
'Primitives/Mode 1': {
43+
ax: {
44+
colors: {
45+
'acc7- 100': { value: '#cfd0d6', type: 'color' },
46+
'acc7-100': { value: '#cfd0d6', type: 'color' },
47+
},
48+
},
49+
},
50+
};
51+
52+
expect(() => assertNoValueCollisions(tokens, ['Primitives/Mode 1'])).not.toThrow();
53+
expect(warn).toHaveBeenCalledOnce();
54+
warn.mockRestore();
55+
});
56+
57+
it('throws when one CSS name would carry different values', () => {
58+
const tokens = {
59+
'Primitives/Mode 1': {
60+
ax: {
61+
colors: {
62+
'acc7- 100': { value: '#ffffff', type: 'color' },
63+
'acc7-100': { value: '#cfd0d6', type: 'color' },
64+
},
65+
},
66+
},
67+
};
68+
69+
expect(() => assertNoValueCollisions(tokens, ['Primitives/Mode 1'])).toThrow(/--ax-colors-acc7-100/);
70+
});
71+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Detects token names that become the same CSS custom property after the
3+
* Style Dictionary kebab transform — e.g. the Figma-side duplicate
4+
* 'ax/colors/acc7- 100' (stray space) vs 'ax/colors/acc7-100', which both
5+
* emit `--ax-colors-acc7-100` and silently overwrite each other in the
6+
* built CSS.
7+
*
8+
* Same-value collisions warn (today's export carries ten of them, all
9+
* benign); different-value collisions throw, because the emitted value
10+
* would then depend on object iteration order.
11+
*/
12+
import { kebabCase } from 'change-case';
13+
14+
type TokenLeaf = { value: unknown; type: string };
15+
type TokenNode = TokenLeaf | { [key: string]: TokenNode };
16+
17+
type CssNameCollision = {
18+
cssName: string;
19+
entries: { path: string; value: unknown }[];
20+
sameValue: boolean;
21+
};
22+
23+
function isLeaf(node: TokenNode): node is TokenLeaf {
24+
return typeof node === 'object' && node !== null && 'value' in node && 'type' in node;
25+
}
26+
27+
function collectLeaves(node: TokenNode, path: string[], out: { path: string; value: unknown }[]) {
28+
if (isLeaf(node)) {
29+
out.push({ path: path.join('/'), value: node.value });
30+
return;
31+
}
32+
for (const [key, child] of Object.entries(node)) {
33+
collectLeaves(child as TokenNode, [...path, key], out);
34+
}
35+
}
36+
37+
/** Must match Style Dictionary's `name/kebab` output — same function
38+
* (change-case), same input shape, so the two cannot drift. */
39+
function toCssName(tokenPath: string): string {
40+
return `--${kebabCase(tokenPath.split('/').join(' '))}`;
41+
}
42+
43+
export function findCssNameCollisions(tokenSet: Record<string, TokenNode>): CssNameCollision[] {
44+
const leaves: { path: string; value: unknown }[] = [];
45+
for (const [key, node] of Object.entries(tokenSet)) {
46+
collectLeaves(node, [key], leaves);
47+
}
48+
49+
const byCssName = new Map<string, { path: string; value: unknown }[]>();
50+
for (const leaf of leaves) {
51+
const cssName = toCssName(leaf.path);
52+
byCssName.set(cssName, [...(byCssName.get(cssName) ?? []), leaf]);
53+
}
54+
55+
return [...byCssName.entries()]
56+
.filter(([, entries]) => entries.length > 1)
57+
.map(([cssName, entries]) => ({
58+
cssName,
59+
entries,
60+
sameValue: new Set(entries.map((entry) => JSON.stringify(entry.value))).size === 1,
61+
}));
62+
}
63+
64+
/**
65+
* Validates every configured set in the raw tokens.json export. Different
66+
* values behind one CSS name fail the build; identical values only warn so
67+
* the known Figma-side duplicates don't block builds until design removes
68+
* them at the source.
69+
*/
70+
export function assertNoValueCollisions(tokens: Record<string, unknown>, setKeys: string[]): void {
71+
for (const setKey of setKeys) {
72+
const tokenSet = tokens[setKey];
73+
if (!tokenSet) {
74+
throw new Error(`tokens.json does not export a '${setKey}' set`);
75+
}
76+
const collisions = findCssNameCollisions(tokenSet as Record<string, TokenNode>);
77+
const conflicting = collisions.filter((collision) => !collision.sameValue);
78+
79+
for (const collision of collisions.filter((c) => c.sameValue)) {
80+
console.warn(
81+
`tokens: '${setKey}' exports duplicate names for ${collision.cssName} ` +
82+
`(${collision.entries.map((entry) => `'${entry.path}'`).join(', ')}) — same value, ` +
83+
'the built CSS keeps one copy; remove the duplicate in Figma.',
84+
);
85+
}
86+
87+
if (conflicting.length > 0) {
88+
throw new Error(
89+
conflicting
90+
.map(
91+
(collision) =>
92+
`'${setKey}': ${collision.entries.map((entry) => `'${entry.path}' (${JSON.stringify(entry.value)})`).join(' and ')} ` +
93+
`all emit ${collision.cssName} with different values — the winner would depend on iteration order`,
94+
)
95+
.join('\n'),
96+
);
97+
}
98+
}
99+
}

pnpm-lock.yaml

Lines changed: 39 additions & 60 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)