Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/tokens/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@ import fs from 'node:fs';

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

import tokens from '../tokens.json';
import { OUTPUT_DIR } from './constants';
import { ejectTokens } from './eject-tokens';
import { generateCSSBundle } from './generate-css-bundle';
import { buildManifest } from './manifest';
import { assertNoValueCollisions } from './validate-collisions';

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

const manifest = buildManifest();

assertNoValueCollisions(
tokens,
[...manifest.primitives, ...manifest.themes].map((entry) => entry.key),
);

ejectTokens(manifest);
await tokensToCss(manifest);
await generateCSSBundle(manifest);
71 changes: 71 additions & 0 deletions packages/tokens/src/validate-collisions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';

import { assertNoValueCollisions, findCssNameCollisions } from './validate-collisions';

describe('findCssNameCollisions', () => {
it('finds names that kebab to the same CSS custom property', () => {
const collisions = findCssNameCollisions({
ax: {
colors: {
'acc7- 100': { value: '#cfd0d6', type: 'color' },
'acc7-100': { value: '#cfd0d6', type: 'color' },
'acc7-200': { value: '#a0a1ad', type: 'color' },
},
},
});

expect(collisions).toEqual([
{
cssName: '--ax-colors-acc7-100',
entries: [
{ path: 'ax/colors/acc7- 100', value: '#cfd0d6' },
{ path: 'ax/colors/acc7-100', value: '#cfd0d6' },
],
sameValue: true,
},
]);
});

it('returns nothing for distinct names', () => {
expect(
findCssNameCollisions({
ax: { colors: { 'gray-100': { value: '#eee', type: 'color' } } },
}),
).toEqual([]);
});
});

describe('assertNoValueCollisions', () => {
it('only warns when the colliding values are identical', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const tokens = {
'Primitives/Mode 1': {
ax: {
colors: {
'acc7- 100': { value: '#cfd0d6', type: 'color' },
'acc7-100': { value: '#cfd0d6', type: 'color' },
},
},
},
};

expect(() => assertNoValueCollisions(tokens, ['Primitives/Mode 1'])).not.toThrow();
expect(warn).toHaveBeenCalledOnce();
warn.mockRestore();
});

it('throws when one CSS name would carry different values', () => {
const tokens = {
'Primitives/Mode 1': {
ax: {
colors: {
'acc7- 100': { value: '#ffffff', type: 'color' },
'acc7-100': { value: '#cfd0d6', type: 'color' },
},
},
},
};

expect(() => assertNoValueCollisions(tokens, ['Primitives/Mode 1'])).toThrow(/--ax-colors-acc7-100/);
});
});
101 changes: 101 additions & 0 deletions packages/tokens/src/validate-collisions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Detects token names that become the same CSS custom property after the
* Style Dictionary kebab transform — e.g. the Figma-side duplicate
* 'ax/colors/acc7- 100' (stray space) vs 'ax/colors/acc7-100', which both
* emit `--ax-colors-acc7-100` and silently overwrite each other in the
* built CSS.
*
* Same-value collisions warn (today's export carries ten of them, all
* benign); different-value collisions throw, because the emitted value
* would then depend on object iteration order.
*/
import StyleDictionary, { TransformedToken } from 'style-dictionary';

type TokenLeaf = { value: unknown; type: string };
type TokenNode = TokenLeaf | { [key: string]: TokenNode };

type CssNameCollision = {
cssName: string;
entries: { path: string; value: unknown }[];
sameValue: boolean;
};

function isLeaf(node: TokenNode): node is TokenLeaf {
return typeof node === 'object' && node !== null && 'value' in node && 'type' in node;
}

function collectLeaves(node: TokenNode, path: string[], out: { path: string; value: unknown }[]) {
if (isLeaf(node)) {
out.push({ path: path.join('/'), value: node.value });
return;
}
for (const [key, child] of Object.entries(node)) {
collectLeaves(child as TokenNode, [...path, key], out);
}
}

const kebabName = StyleDictionary.hooks.transforms['name/kebab'].transform;

/** Must match Style Dictionary's `name/kebab` output — it IS that transform,
* invoked directly, so the two cannot drift. */
function toCssName(tokenPath: string): string {
return `--${kebabName({ path: tokenPath.split('/') } as TransformedToken, {}, {})}`;
}

export function findCssNameCollisions(tokenSet: Record<string, TokenNode>): CssNameCollision[] {
const leaves: { path: string; value: unknown }[] = [];
for (const [key, node] of Object.entries(tokenSet)) {
collectLeaves(node, [key], leaves);
}

const byCssName = new Map<string, { path: string; value: unknown }[]>();
for (const leaf of leaves) {
const cssName = toCssName(leaf.path);
byCssName.set(cssName, [...(byCssName.get(cssName) ?? []), leaf]);
}

return [...byCssName.entries()]
.filter(([, entries]) => entries.length > 1)
.map(([cssName, entries]) => ({
cssName,
entries,
sameValue: new Set(entries.map((entry) => JSON.stringify(entry.value))).size === 1,
}));
}

/**
* Validates every configured set in the raw tokens.json export. Different
* values behind one CSS name fail the build; identical values only warn so
* the known Figma-side duplicates don't block builds until design removes
* them at the source.
*/
export function assertNoValueCollisions(tokens: Record<string, unknown>, setKeys: string[]): void {
for (const setKey of setKeys) {
const tokenSet = tokens[setKey];
if (!tokenSet) {
throw new Error(`tokens.json does not export a '${setKey}' set`);
}
const collisions = findCssNameCollisions(tokenSet as Record<string, TokenNode>);
const conflicting = collisions.filter((collision) => !collision.sameValue);

for (const collision of collisions.filter((c) => c.sameValue)) {
console.warn(
`tokens: '${setKey}' exports duplicate names for ${collision.cssName} ` +
`(${collision.entries.map((entry) => `'${entry.path}'`).join(', ')}) — same value, ` +
'the built CSS keeps one copy; remove the duplicate in Figma.',
);
}

if (conflicting.length > 0) {
throw new Error(
conflicting
.map(
(collision) =>
`'${setKey}': ${collision.entries.map((entry) => `'${entry.path}' (${JSON.stringify(entry.value)})`).join(' and ')} ` +
`all emit ${collision.cssName} with different values — the winner would depend on iteration order`,
)
.join('\n'),
);
}
}
}
Loading