@cdx-ui/styles 0.0.1-beta.87 → 0.0.1-beta.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/useCdxFonts.js +3 -6
- package/lib/commonjs/useCdxFonts.js.map +1 -1
- package/lib/module/useCdxFonts.js +3 -1
- package/lib/module/useCdxFonts.js.map +1 -1
- package/lib/typescript/useCdxFonts.d.ts +2 -1
- package/lib/typescript/useCdxFonts.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/presetJson.test.ts +120 -0
- package/src/__tests__/sd.config.test.ts +856 -0
- package/src/__tests__/types.test.ts +72 -0
- package/src/__tests__/useCdxFonts.test.ts +27 -0
- package/src/__tests__/useForgeFonts.test.ts +226 -0
- package/src/useCdxFonts.ts +3 -1
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import type { TransformedToken } from 'style-dictionary/types';
|
|
2
|
+
import {
|
|
3
|
+
SHADOW_CATEGORIES,
|
|
4
|
+
composeShadowLayer,
|
|
5
|
+
createConfig,
|
|
6
|
+
formatTokenValue,
|
|
7
|
+
groupByPlatform,
|
|
8
|
+
groupByTypographyStyle,
|
|
9
|
+
isCompositeTypography,
|
|
10
|
+
isModePath,
|
|
11
|
+
isPlatformPath,
|
|
12
|
+
isShadowSubProperty,
|
|
13
|
+
isTailwindThemeToken,
|
|
14
|
+
pathToVarName,
|
|
15
|
+
register,
|
|
16
|
+
renderVariantBlock,
|
|
17
|
+
roundAlpha,
|
|
18
|
+
tokenValue,
|
|
19
|
+
} from '../../sd.config';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Token fixture factory
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface MakeTokenInput {
|
|
26
|
+
path: string[];
|
|
27
|
+
name?: string;
|
|
28
|
+
value?: unknown;
|
|
29
|
+
type?: string;
|
|
30
|
+
$value?: unknown;
|
|
31
|
+
$type?: string;
|
|
32
|
+
$extensions?: Record<string, unknown>;
|
|
33
|
+
original?: Partial<TransformedToken['original']>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeToken({
|
|
37
|
+
path,
|
|
38
|
+
name,
|
|
39
|
+
value,
|
|
40
|
+
type,
|
|
41
|
+
$value,
|
|
42
|
+
$type,
|
|
43
|
+
$extensions,
|
|
44
|
+
original,
|
|
45
|
+
}: MakeTokenInput): TransformedToken {
|
|
46
|
+
// Minimal `TransformedToken` — only the fields helpers actually read.
|
|
47
|
+
return {
|
|
48
|
+
name: name ?? path.join('-'),
|
|
49
|
+
path,
|
|
50
|
+
value,
|
|
51
|
+
type,
|
|
52
|
+
$value,
|
|
53
|
+
$type,
|
|
54
|
+
...($extensions ? { $extensions } : {}),
|
|
55
|
+
original: {
|
|
56
|
+
value,
|
|
57
|
+
$value,
|
|
58
|
+
...original,
|
|
59
|
+
},
|
|
60
|
+
filePath: 'test.json',
|
|
61
|
+
isSource: true,
|
|
62
|
+
} as unknown as TransformedToken;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// pathToVarName
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('pathToVarName', () => {
|
|
70
|
+
it('joins a flat dot-path with dashes', () => {
|
|
71
|
+
expect(pathToVarName(['color', 'brand', '500'])).toBe('color-brand-500');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('strips a leading platform.<name> prefix', () => {
|
|
75
|
+
expect(pathToVarName(['platform', 'web', 'font', 'sans'])).toBe('font-sans');
|
|
76
|
+
expect(pathToVarName(['platform', 'ios', 'font', 'mono'])).toBe('font-mono');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('strips a leading modes.light or modes.dark prefix', () => {
|
|
80
|
+
expect(pathToVarName(['modes', 'light', 'color', 'surface', 'background'])).toBe(
|
|
81
|
+
'color-surface-background',
|
|
82
|
+
);
|
|
83
|
+
expect(pathToVarName(['modes', 'dark', 'color', 'content', 'primary'])).toBe(
|
|
84
|
+
'color-content-primary',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('leaves modes.<other> untouched (only light/dark are stripped)', () => {
|
|
89
|
+
expect(pathToVarName(['modes', 'sepia', 'color', 'foo'])).toBe('modes-sepia-color-foo');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('collapses spacing.base to the literal "spacing"', () => {
|
|
93
|
+
expect(pathToVarName(['spacing', 'base'])).toBe('spacing');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('maps text.<size>.font-size to text-<size>', () => {
|
|
97
|
+
expect(pathToVarName(['text', 'xl', 'font-size'])).toBe('text-xl');
|
|
98
|
+
expect(pathToVarName(['text', 'sm', 'font-size'])).toBe('text-sm');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('maps text.<size>.line-height to text-<size>--line-height', () => {
|
|
102
|
+
expect(pathToVarName(['text', 'md', 'line-height'])).toBe('text-md--line-height');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not mutate the input array', () => {
|
|
106
|
+
const input = ['platform', 'web', 'font', 'sans'];
|
|
107
|
+
pathToVarName(input);
|
|
108
|
+
expect(input).toEqual(['platform', 'web', 'font', 'sans']);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// SHADOW_CATEGORIES + shadow helpers
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
describe('SHADOW_CATEGORIES', () => {
|
|
117
|
+
it('lists every shadow-style token category', () => {
|
|
118
|
+
expect(SHADOW_CATEGORIES).toEqual(['drop-shadow', 'shadow', 'inset-shadow', 'text-shadow']);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('isShadowSubProperty', () => {
|
|
123
|
+
it.each(['offset-x', 'offset-y', 'blur-radius', 'spread-radius', 'color'])(
|
|
124
|
+
'returns true for shadow leaf %s under a shadow category',
|
|
125
|
+
(leaf) => {
|
|
126
|
+
const token = makeToken({ path: ['shadow', 'md', leaf] });
|
|
127
|
+
expect(isShadowSubProperty(token)).toBe(true);
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it.each(SHADOW_CATEGORIES)(
|
|
132
|
+
'detects sub-properties under any shadow category (%s)',
|
|
133
|
+
(category) => {
|
|
134
|
+
const token = makeToken({ path: [category, 'lg', 'offset-y'] });
|
|
135
|
+
expect(isShadowSubProperty(token)).toBe(true);
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
it('returns false for non-shadow categories', () => {
|
|
140
|
+
expect(isShadowSubProperty(makeToken({ path: ['color', 'brand', '500'] }))).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns false for non-sub-property leaves under a shadow category', () => {
|
|
144
|
+
expect(isShadowSubProperty(makeToken({ path: ['shadow', 'md', 'not-a-shadow-prop'] }))).toBe(
|
|
145
|
+
false,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// isTailwindThemeToken
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe('isTailwindThemeToken', () => {
|
|
155
|
+
it('returns true for tokens with one of the Tailwind theme prefixes', () => {
|
|
156
|
+
expect(isTailwindThemeToken(makeToken({ name: 'color-brand-500', path: [] }))).toBe(true);
|
|
157
|
+
expect(isTailwindThemeToken(makeToken({ name: 'spacing', path: [] }))).toBe(true);
|
|
158
|
+
expect(isTailwindThemeToken(makeToken({ name: 'text-xl', path: [] }))).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns false for tokens whose name does not match any prefix', () => {
|
|
162
|
+
expect(isTailwindThemeToken(makeToken({ name: 'something-arbitrary', path: [] }))).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('returns false for shadow sub-properties even when name would match', () => {
|
|
166
|
+
expect(
|
|
167
|
+
isTailwindThemeToken(makeToken({ path: ['shadow', 'md', 'offset-x'], name: 'shadow-md' })),
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('returns true for tokens marked as composed via $extensions', () => {
|
|
172
|
+
expect(
|
|
173
|
+
isTailwindThemeToken(
|
|
174
|
+
makeToken({
|
|
175
|
+
path: ['shadow', 'md'],
|
|
176
|
+
name: 'shadow-md',
|
|
177
|
+
$extensions: { composed: true },
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('returns true when original.$extensions.composed is set', () => {
|
|
184
|
+
expect(
|
|
185
|
+
isTailwindThemeToken(
|
|
186
|
+
makeToken({
|
|
187
|
+
path: ['shadow', 'md'],
|
|
188
|
+
name: 'arbitrary-name',
|
|
189
|
+
original: { $extensions: { composed: true } as Record<string, unknown> },
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// isCompositeTypography / isModePath / isPlatformPath
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
describe('isCompositeTypography', () => {
|
|
201
|
+
it('returns true when path[0] is "typography"', () => {
|
|
202
|
+
expect(isCompositeTypography(makeToken({ path: ['typography', 'heading', 'xl'] }))).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('returns false otherwise', () => {
|
|
206
|
+
expect(isCompositeTypography(makeToken({ path: ['color', 'brand'] }))).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('isModePath', () => {
|
|
211
|
+
it('returns true for any modes.* token when no mode arg is passed', () => {
|
|
212
|
+
expect(isModePath(makeToken({ path: ['modes', 'light', 'color'] }))).toBe(true);
|
|
213
|
+
expect(isModePath(makeToken({ path: ['modes', 'dark', 'color'] }))).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('returns true only when path[1] matches the given mode arg', () => {
|
|
217
|
+
const lightToken = makeToken({ path: ['modes', 'light', 'color'] });
|
|
218
|
+
const darkToken = makeToken({ path: ['modes', 'dark', 'color'] });
|
|
219
|
+
expect(isModePath(lightToken, 'light')).toBe(true);
|
|
220
|
+
expect(isModePath(lightToken, 'dark')).toBe(false);
|
|
221
|
+
expect(isModePath(darkToken, 'light')).toBe(false);
|
|
222
|
+
expect(isModePath(darkToken, 'dark')).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns false when path[0] is not "modes"', () => {
|
|
226
|
+
expect(isModePath(makeToken({ path: ['color', 'brand'] }))).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('isPlatformPath', () => {
|
|
231
|
+
it('returns true when path[0] is "platform"', () => {
|
|
232
|
+
expect(isPlatformPath(makeToken({ path: ['platform', 'web', 'font'] }))).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('returns false otherwise', () => {
|
|
236
|
+
expect(isPlatformPath(makeToken({ path: ['color'] }))).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// formatTokenValue / tokenValue
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
describe('formatTokenValue', () => {
|
|
245
|
+
it('wraps string-typed values in single quotes', () => {
|
|
246
|
+
const token = makeToken({ path: ['font'], $type: 'string', $value: 'Inter' });
|
|
247
|
+
expect(formatTokenValue(token)).toBe("'Inter'");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('falls back to the legacy `type` field when $type is absent', () => {
|
|
251
|
+
const token = makeToken({ path: ['font'], type: 'string', value: 'Roboto' });
|
|
252
|
+
expect(formatTokenValue(token)).toBe("'Roboto'");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('does not quote string values that are already var() references', () => {
|
|
256
|
+
const token = makeToken({
|
|
257
|
+
path: ['font', 'display'],
|
|
258
|
+
$type: 'string',
|
|
259
|
+
$value: 'var(--font-display)',
|
|
260
|
+
});
|
|
261
|
+
expect(formatTokenValue(token)).toBe('var(--font-display)');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('returns non-string values stringified without quoting', () => {
|
|
265
|
+
const token = makeToken({ path: ['spacing', '4'], $type: 'dimension', $value: '16px' });
|
|
266
|
+
expect(formatTokenValue(token)).toBe('16px');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('tokenValue', () => {
|
|
271
|
+
it('prefers $value over value', () => {
|
|
272
|
+
expect(tokenValue(makeToken({ path: ['x'], $value: 'new', value: 'old' }))).toBe('new');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('falls back to value when $value is absent', () => {
|
|
276
|
+
expect(tokenValue(makeToken({ path: ['x'], value: 'legacy' }))).toBe('legacy');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// composeShadowLayer / roundAlpha
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
describe('composeShadowLayer', () => {
|
|
285
|
+
it('renders a non-inset layer with offsets, blur, and string color', () => {
|
|
286
|
+
const layer = {
|
|
287
|
+
'offset-x': { $value: 0 },
|
|
288
|
+
'offset-y': { $value: 2 },
|
|
289
|
+
'blur-radius': { $value: 4 },
|
|
290
|
+
'spread-radius': { $value: 0 },
|
|
291
|
+
color: { $value: 'rgba(0, 0, 0, 0.1)' },
|
|
292
|
+
};
|
|
293
|
+
expect(composeShadowLayer(layer, false)).toBe('0px 2px 4px rgba(0, 0, 0, 0.1)');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('omits spread when it equals 0', () => {
|
|
297
|
+
const layer = {
|
|
298
|
+
'offset-x': { $value: 1 },
|
|
299
|
+
'offset-y': { $value: 1 },
|
|
300
|
+
'blur-radius': { $value: 2 },
|
|
301
|
+
'spread-radius': { $value: 0 },
|
|
302
|
+
color: { $value: '#000' },
|
|
303
|
+
};
|
|
304
|
+
expect(composeShadowLayer(layer, false)).toBe('1px 1px 2px #000');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('includes a non-zero spread', () => {
|
|
308
|
+
const layer = {
|
|
309
|
+
'offset-x': { $value: 0 },
|
|
310
|
+
'offset-y': { $value: 0 },
|
|
311
|
+
'blur-radius': { $value: 8 },
|
|
312
|
+
'spread-radius': { $value: 4 },
|
|
313
|
+
color: { $value: '#111' },
|
|
314
|
+
};
|
|
315
|
+
expect(composeShadowLayer(layer, false)).toBe('0px 0px 8px 4px #111');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('prepends "inset " when isInset=true', () => {
|
|
319
|
+
const layer = {
|
|
320
|
+
'offset-x': { $value: 0 },
|
|
321
|
+
'offset-y': { $value: 1 },
|
|
322
|
+
'blur-radius': { $value: 3 },
|
|
323
|
+
'spread-radius': { $value: 0 },
|
|
324
|
+
color: { $value: '#222' },
|
|
325
|
+
};
|
|
326
|
+
expect(composeShadowLayer(layer, true)).toBe('inset 0px 1px 3px #222');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('renders object colors with hex+alpha as rgb(0 0 0 / alpha)', () => {
|
|
330
|
+
const layer = {
|
|
331
|
+
'offset-x': { $value: 0 },
|
|
332
|
+
'offset-y': { $value: 2 },
|
|
333
|
+
'blur-radius': { $value: 4 },
|
|
334
|
+
'spread-radius': { $value: 0 },
|
|
335
|
+
color: { $value: { hex: '#000000', alpha: 0.25 } },
|
|
336
|
+
};
|
|
337
|
+
expect(composeShadowLayer(layer, false)).toBe('0px 2px 4px rgb(0 0 0 / 0.25)');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('falls back to a sensible default when color is absent', () => {
|
|
341
|
+
const layer = {
|
|
342
|
+
'offset-x': { $value: 0 },
|
|
343
|
+
'offset-y': { $value: 0 },
|
|
344
|
+
'blur-radius': { $value: 0 },
|
|
345
|
+
'spread-radius': { $value: 0 },
|
|
346
|
+
// color intentionally omitted
|
|
347
|
+
} as Record<string, { $value: string | number | Record<string, unknown> }>;
|
|
348
|
+
expect(composeShadowLayer(layer, false)).toBe('0px 0px 0px rgb(0 0 0 / 0.1)');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('treats missing offsets/blur/spread as 0', () => {
|
|
352
|
+
const layer = {
|
|
353
|
+
color: { $value: '#333' },
|
|
354
|
+
} as Record<string, { $value: string | number | Record<string, unknown> }>;
|
|
355
|
+
expect(composeShadowLayer(layer, false)).toBe('0px 0px 0px #333');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('roundAlpha', () => {
|
|
360
|
+
it('rounds to 4 fractional digits without trailing zeros', () => {
|
|
361
|
+
expect(roundAlpha(0.123456)).toBe('0.1235');
|
|
362
|
+
expect(roundAlpha(0.5)).toBe('0.5');
|
|
363
|
+
expect(roundAlpha(1)).toBe('1');
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// renderVariantBlock
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
describe('renderVariantBlock', () => {
|
|
372
|
+
it('returns an empty string when no tokens are passed', () => {
|
|
373
|
+
expect(renderVariantBlock('light', [])).toBe('');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('emits an @variant block with one declaration per token', () => {
|
|
377
|
+
const tokens = [
|
|
378
|
+
makeToken({
|
|
379
|
+
path: ['modes', 'light', 'color', 'surface', 'background'],
|
|
380
|
+
name: 'color-surface-background',
|
|
381
|
+
$type: 'color',
|
|
382
|
+
$value: '#ffffff',
|
|
383
|
+
}),
|
|
384
|
+
makeToken({
|
|
385
|
+
path: ['modes', 'light', 'color', 'content', 'primary'],
|
|
386
|
+
name: 'color-content-primary',
|
|
387
|
+
$type: 'color',
|
|
388
|
+
$value: '#000000',
|
|
389
|
+
}),
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
const out = renderVariantBlock('light', tokens);
|
|
393
|
+
expect(out).toContain('@variant light {');
|
|
394
|
+
expect(out).toContain('--color-surface-background: #ffffff;');
|
|
395
|
+
expect(out).toContain('--color-content-primary: #000000;');
|
|
396
|
+
expect(out.trim().endsWith('}')).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// groupByPlatform / groupByTypographyStyle
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
describe('groupByPlatform', () => {
|
|
405
|
+
it('groups tokens by path[1] (the platform name)', () => {
|
|
406
|
+
const tokens = [
|
|
407
|
+
makeToken({ path: ['platform', 'web', 'font', 'sans'] }),
|
|
408
|
+
makeToken({ path: ['platform', 'ios', 'font', 'sans'] }),
|
|
409
|
+
makeToken({ path: ['platform', 'web', 'font', 'mono'] }),
|
|
410
|
+
];
|
|
411
|
+
const grouped = groupByPlatform(tokens);
|
|
412
|
+
expect(Object.keys(grouped).sort()).toEqual(['ios', 'web']);
|
|
413
|
+
expect(grouped.web).toHaveLength(2);
|
|
414
|
+
expect(grouped.ios).toHaveLength(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('returns an empty object when given no tokens', () => {
|
|
418
|
+
expect(groupByPlatform([])).toEqual({});
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
describe('groupByTypographyStyle', () => {
|
|
423
|
+
it('groups typography tokens by category-size and emits property maps', () => {
|
|
424
|
+
const tokens = [
|
|
425
|
+
makeToken({
|
|
426
|
+
path: ['typography', 'heading', 'xl', 'font-family'],
|
|
427
|
+
$value: 'Crimson Pro',
|
|
428
|
+
}),
|
|
429
|
+
makeToken({
|
|
430
|
+
path: ['typography', 'heading', 'xl', 'font-size'],
|
|
431
|
+
$value: '40px',
|
|
432
|
+
}),
|
|
433
|
+
makeToken({
|
|
434
|
+
path: ['typography', 'body', 'md', 'font-family'],
|
|
435
|
+
$value: 'var(--font-body)',
|
|
436
|
+
}),
|
|
437
|
+
];
|
|
438
|
+
const grouped = groupByTypographyStyle(tokens);
|
|
439
|
+
|
|
440
|
+
expect(grouped['heading-xl']['font-family']).toBe("'Crimson Pro'");
|
|
441
|
+
expect(grouped['heading-xl']['font-size']).toBe('40px');
|
|
442
|
+
expect(grouped['body-md']['font-family']).toBe('var(--font-body)');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('skips non-typography tokens', () => {
|
|
446
|
+
const tokens = [
|
|
447
|
+
makeToken({ path: ['color', 'brand', '500'], $value: '#abc' }),
|
|
448
|
+
makeToken({
|
|
449
|
+
path: ['typography', 'label', 'sm', 'font-size'],
|
|
450
|
+
$value: '12px',
|
|
451
|
+
}),
|
|
452
|
+
];
|
|
453
|
+
const grouped = groupByTypographyStyle(tokens);
|
|
454
|
+
expect(Object.keys(grouped)).toEqual(['label-sm']);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('JSON-stringifies object-typed values', () => {
|
|
458
|
+
const tokens = [
|
|
459
|
+
makeToken({
|
|
460
|
+
path: ['typography', 'heading', 'lg', 'shadow'],
|
|
461
|
+
$value: { x: 1, y: 2 } as unknown as string,
|
|
462
|
+
}),
|
|
463
|
+
];
|
|
464
|
+
const grouped = groupByTypographyStyle(tokens);
|
|
465
|
+
expect(grouped['heading-lg'].shadow).toBe('{"x":1,"y":2}');
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// createConfig
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
describe('createConfig', () => {
|
|
474
|
+
it('returns a SD config object with source pointing at the requested preset', () => {
|
|
475
|
+
const cfg = createConfig('poise');
|
|
476
|
+
expect(cfg.source).toEqual(['tokens/presets/poise.json']);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('prefixes paths when a basePath is provided (trailing slash normalized)', () => {
|
|
480
|
+
expect(createConfig('pulse', '/abs/path/').source).toEqual([
|
|
481
|
+
'/abs/path/tokens/presets/pulse.json',
|
|
482
|
+
]);
|
|
483
|
+
expect(createConfig('pulse', '/abs/path').source).toEqual([
|
|
484
|
+
'/abs/path/tokens/presets/pulse.json',
|
|
485
|
+
]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('enables DTCG and broken-reference logging to console', () => {
|
|
489
|
+
const cfg = createConfig('poise');
|
|
490
|
+
expect(cfg.usesDtcg).toBe(true);
|
|
491
|
+
expect(cfg.log.errors.brokenReferences).toBe('console');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('declares the two preprocessors in order: fonts/promote then shadow/compose', () => {
|
|
495
|
+
expect(createConfig('poise').preprocessors).toEqual(['fonts/promote', 'shadow/compose']);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('declares css-theme, css-utilities, and css-vanilla platforms', () => {
|
|
499
|
+
const platforms = createConfig('poise').platforms;
|
|
500
|
+
expect(Object.keys(platforms).sort()).toEqual(['css-theme', 'css-utilities', 'css-vanilla']);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('css-theme platform uses the cdx custom format and the expected transforms', () => {
|
|
504
|
+
const cssTheme = createConfig('poise').platforms['css-theme'];
|
|
505
|
+
expect(cssTheme.transforms).toEqual([
|
|
506
|
+
'name/tailwind',
|
|
507
|
+
'color/css',
|
|
508
|
+
'size/pxToRem',
|
|
509
|
+
'value/cssReferences',
|
|
510
|
+
]);
|
|
511
|
+
expect(cssTheme.files).toEqual([{ destination: 'css/theme.css', format: 'css/cdx-theme' }]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('css-utilities platform filters to composite typography tokens', () => {
|
|
515
|
+
const cssUtil = createConfig('poise').platforms['css-utilities'];
|
|
516
|
+
expect(cssUtil.files[0].format).toBe('css/tailwind-utilities');
|
|
517
|
+
expect(cssUtil.files[0].filter).toBe(isCompositeTypography);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('css-vanilla platform emits vanilla.css with the cdx-vanilla format', () => {
|
|
521
|
+
const cssVanilla = createConfig('poise').platforms['css-vanilla'];
|
|
522
|
+
expect(cssVanilla.files).toEqual([
|
|
523
|
+
{ destination: 'css/vanilla.css', format: 'css/cdx-vanilla' },
|
|
524
|
+
]);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// register
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
describe('register', () => {
|
|
533
|
+
it('registers two transforms, two preprocessors, and three formats on the SD instance', () => {
|
|
534
|
+
const calls: Record<string, string[]> = {
|
|
535
|
+
registerTransform: [],
|
|
536
|
+
registerPreprocessor: [],
|
|
537
|
+
registerFormat: [],
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const mockSd = {
|
|
541
|
+
registerTransform: jest.fn((arg: { name: string }) => calls.registerTransform.push(arg.name)),
|
|
542
|
+
registerPreprocessor: jest.fn((arg: { name: string }) =>
|
|
543
|
+
calls.registerPreprocessor.push(arg.name),
|
|
544
|
+
),
|
|
545
|
+
registerFormat: jest.fn((arg: { name: string }) => calls.registerFormat.push(arg.name)),
|
|
546
|
+
} as unknown as Parameters<typeof register>[0];
|
|
547
|
+
|
|
548
|
+
register(mockSd);
|
|
549
|
+
|
|
550
|
+
expect(calls.registerTransform.sort()).toEqual(['name/tailwind', 'value/cssReferences']);
|
|
551
|
+
expect(calls.registerPreprocessor.sort()).toEqual(['fonts/promote', 'shadow/compose']);
|
|
552
|
+
expect(calls.registerFormat.sort()).toEqual([
|
|
553
|
+
'css/cdx-theme',
|
|
554
|
+
'css/cdx-vanilla',
|
|
555
|
+
'css/tailwind-utilities',
|
|
556
|
+
]);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// register — captured callback behaviour (transforms, preprocessors, formats)
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
interface Registration {
|
|
565
|
+
name: string;
|
|
566
|
+
transform?: (token: TransformedToken) => unknown;
|
|
567
|
+
preprocessor?: (dictionary: Record<string, unknown>) => Record<string, unknown>;
|
|
568
|
+
format?: (args: {
|
|
569
|
+
dictionary: { allTokens: TransformedToken[] };
|
|
570
|
+
file: Record<string, unknown>;
|
|
571
|
+
}) => Promise<string>;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Runs `register` against a recording stub and returns the captured config objects by name. */
|
|
575
|
+
function captureRegistrations() {
|
|
576
|
+
const transforms: Record<string, Registration> = {};
|
|
577
|
+
const preprocessors: Record<string, Registration> = {};
|
|
578
|
+
const formats: Record<string, Registration> = {};
|
|
579
|
+
|
|
580
|
+
const mockSd = {
|
|
581
|
+
registerTransform: (arg: Registration) => {
|
|
582
|
+
transforms[arg.name] = arg;
|
|
583
|
+
},
|
|
584
|
+
registerPreprocessor: (arg: Registration) => {
|
|
585
|
+
preprocessors[arg.name] = arg;
|
|
586
|
+
},
|
|
587
|
+
registerFormat: (arg: Registration) => {
|
|
588
|
+
formats[arg.name] = arg;
|
|
589
|
+
},
|
|
590
|
+
} as unknown as Parameters<typeof register>[0];
|
|
591
|
+
|
|
592
|
+
register(mockSd);
|
|
593
|
+
|
|
594
|
+
return { transforms, preprocessors, formats };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
describe('register — name/tailwind transform', () => {
|
|
598
|
+
it('maps a token path to its Tailwind variable name', () => {
|
|
599
|
+
const { transforms } = captureRegistrations();
|
|
600
|
+
const token = makeToken({ path: ['platform', 'web', 'font', 'sans'] });
|
|
601
|
+
expect(transforms['name/tailwind'].transform?.(token)).toBe('font-sans');
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
describe('register — value/cssReferences transform', () => {
|
|
606
|
+
it('rewrites a DTCG alias reference to a var() of the resolved variable name', () => {
|
|
607
|
+
const { transforms } = captureRegistrations();
|
|
608
|
+
const token = makeToken({
|
|
609
|
+
path: ['color', 'accent'],
|
|
610
|
+
$value: '#resolved',
|
|
611
|
+
original: { $value: '{color.brand.500}' },
|
|
612
|
+
});
|
|
613
|
+
expect(transforms['value/cssReferences'].transform?.(token)).toBe('var(--color-brand-500)');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('returns the token value unchanged when original.$value is not an alias', () => {
|
|
617
|
+
const { transforms } = captureRegistrations();
|
|
618
|
+
const token = makeToken({
|
|
619
|
+
path: ['color', 'brand', '500'],
|
|
620
|
+
$value: '#112233',
|
|
621
|
+
original: { $value: '#112233' },
|
|
622
|
+
});
|
|
623
|
+
expect(transforms['value/cssReferences'].transform?.(token)).toBe('#112233');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('falls back to value when original.$value is absent', () => {
|
|
627
|
+
const { transforms } = captureRegistrations();
|
|
628
|
+
const token = makeToken({ path: ['color', 'brand'], value: 'legacy' });
|
|
629
|
+
expect(transforms['value/cssReferences'].transform?.(token)).toBe('legacy');
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('register — fonts/promote preprocessor', () => {
|
|
634
|
+
it('promotes semantic fonts from modes.light.font to the top-level font group', () => {
|
|
635
|
+
const { preprocessors } = captureRegistrations();
|
|
636
|
+
const dictionary = {
|
|
637
|
+
modes: { light: { font: { body: { $value: 'DM Sans' }, display: { $value: 'Bitter' } } } },
|
|
638
|
+
} as Record<string, unknown>;
|
|
639
|
+
|
|
640
|
+
const result = preprocessors['fonts/promote'].preprocessor?.(dictionary) as Record<
|
|
641
|
+
string,
|
|
642
|
+
{ body: { $value: string }; display: { $value: string } }
|
|
643
|
+
>;
|
|
644
|
+
|
|
645
|
+
expect(result.font.body).toEqual({ $value: 'DM Sans' });
|
|
646
|
+
expect(result.font.display).toEqual({ $value: 'Bitter' });
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('does not overwrite an existing top-level font entry', () => {
|
|
650
|
+
const { preprocessors } = captureRegistrations();
|
|
651
|
+
const dictionary = {
|
|
652
|
+
font: { body: { $value: 'existing' } },
|
|
653
|
+
modes: { light: { font: { body: { $value: 'DM Sans' } } } },
|
|
654
|
+
} as Record<string, unknown>;
|
|
655
|
+
|
|
656
|
+
const result = preprocessors['fonts/promote'].preprocessor?.(dictionary) as Record<
|
|
657
|
+
string,
|
|
658
|
+
{ body: { $value: string } }
|
|
659
|
+
>;
|
|
660
|
+
|
|
661
|
+
expect(result.font.body).toEqual({ $value: 'existing' });
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('returns the dictionary untouched when modes.light.font is absent', () => {
|
|
665
|
+
const { preprocessors } = captureRegistrations();
|
|
666
|
+
const dictionary = { color: { brand: { $value: '#000' } } } as Record<string, unknown>;
|
|
667
|
+
const result = preprocessors['fonts/promote'].preprocessor?.(dictionary);
|
|
668
|
+
expect(result).toBe(dictionary);
|
|
669
|
+
expect(result).not.toHaveProperty('font');
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe('register — shadow/compose preprocessor', () => {
|
|
674
|
+
it('composes a multi-layer shadow into a comma-joined shorthand', () => {
|
|
675
|
+
const { preprocessors } = captureRegistrations();
|
|
676
|
+
const dictionary = {
|
|
677
|
+
shadow: {
|
|
678
|
+
md: {
|
|
679
|
+
'0': {
|
|
680
|
+
'offset-x': { $value: 0 },
|
|
681
|
+
'offset-y': { $value: 1 },
|
|
682
|
+
'blur-radius': { $value: 2 },
|
|
683
|
+
'spread-radius': { $value: 0 },
|
|
684
|
+
color: { $value: '#000' },
|
|
685
|
+
},
|
|
686
|
+
'1': {
|
|
687
|
+
'offset-x': { $value: 0 },
|
|
688
|
+
'offset-y': { $value: 2 },
|
|
689
|
+
'blur-radius': { $value: 4 },
|
|
690
|
+
'spread-radius': { $value: 0 },
|
|
691
|
+
color: { $value: '#111' },
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
} as Record<string, unknown>;
|
|
696
|
+
|
|
697
|
+
const result = preprocessors['shadow/compose'].preprocessor?.(dictionary) as {
|
|
698
|
+
shadow: { md: { $value: string; $extensions: { composed: boolean } } };
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
expect(result.shadow.md.$value).toBe('0px 1px 2px #000, 0px 2px 4px #111');
|
|
702
|
+
expect(result.shadow.md.$extensions.composed).toBe(true);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('composes a single-layer shadow declared with flat sub-properties', () => {
|
|
706
|
+
const { preprocessors } = captureRegistrations();
|
|
707
|
+
const dictionary = {
|
|
708
|
+
shadow: {
|
|
709
|
+
sm: {
|
|
710
|
+
'offset-x': { $value: 0 },
|
|
711
|
+
'offset-y': { $value: 1 },
|
|
712
|
+
'blur-radius': { $value: 2 },
|
|
713
|
+
'spread-radius': { $value: 0 },
|
|
714
|
+
color: { $value: '#111' },
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
} as Record<string, unknown>;
|
|
718
|
+
|
|
719
|
+
const result = preprocessors['shadow/compose'].preprocessor?.(dictionary) as {
|
|
720
|
+
shadow: { sm: { $value: string } };
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
expect(result.shadow.sm.$value).toBe('0px 1px 2px #111');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('prefixes inset for the inset-shadow category', () => {
|
|
727
|
+
const { preprocessors } = captureRegistrations();
|
|
728
|
+
const dictionary = {
|
|
729
|
+
'inset-shadow': {
|
|
730
|
+
sm: {
|
|
731
|
+
'offset-x': { $value: 0 },
|
|
732
|
+
'offset-y': { $value: 1 },
|
|
733
|
+
'blur-radius': { $value: 3 },
|
|
734
|
+
'spread-radius': { $value: 0 },
|
|
735
|
+
color: { $value: '#222' },
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
} as Record<string, unknown>;
|
|
739
|
+
|
|
740
|
+
const result = preprocessors['shadow/compose'].preprocessor?.(dictionary) as {
|
|
741
|
+
'inset-shadow': { sm: { $value: string } };
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
expect(result['inset-shadow'].sm.$value).toBe('inset 0px 1px 3px #222');
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('leaves size groups without shadow sub-properties untouched', () => {
|
|
748
|
+
const { preprocessors } = captureRegistrations();
|
|
749
|
+
const untouched = { note: 'not a shadow layer' };
|
|
750
|
+
const dictionary = { shadow: { weird: untouched } } as Record<string, unknown>;
|
|
751
|
+
|
|
752
|
+
const result = preprocessors['shadow/compose'].preprocessor?.(dictionary) as {
|
|
753
|
+
shadow: { weird: unknown };
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
expect(result.shadow.weird).toBe(untouched);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('ignores shadow categories that are not present in the dictionary', () => {
|
|
760
|
+
const { preprocessors } = captureRegistrations();
|
|
761
|
+
const dictionary = { color: { brand: { $value: '#000' } } } as Record<string, unknown>;
|
|
762
|
+
const result = preprocessors['shadow/compose'].preprocessor?.(dictionary);
|
|
763
|
+
expect(result).toBe(dictionary);
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
// register — format functions
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
/** Token fixtures shared by the theme + vanilla format assertions. */
|
|
772
|
+
const FORMAT_TOKENS: TransformedToken[] = [
|
|
773
|
+
makeToken({
|
|
774
|
+
path: ['color', 'brand', '500'],
|
|
775
|
+
name: 'color-brand-500',
|
|
776
|
+
$type: 'color',
|
|
777
|
+
$value: '#112233',
|
|
778
|
+
}),
|
|
779
|
+
makeToken({
|
|
780
|
+
path: ['modes', 'light', 'color', 'surface', 'background'],
|
|
781
|
+
name: 'color-surface-background',
|
|
782
|
+
$type: 'color',
|
|
783
|
+
$value: '#ffffff',
|
|
784
|
+
}),
|
|
785
|
+
makeToken({
|
|
786
|
+
path: ['modes', 'dark', 'color', 'surface', 'background'],
|
|
787
|
+
name: 'color-surface-background',
|
|
788
|
+
$type: 'color',
|
|
789
|
+
$value: '#000000',
|
|
790
|
+
}),
|
|
791
|
+
makeToken({
|
|
792
|
+
path: ['platform', 'web', 'font', 'sans'],
|
|
793
|
+
name: 'font-sans',
|
|
794
|
+
$type: 'string',
|
|
795
|
+
$value: 'Inter',
|
|
796
|
+
}),
|
|
797
|
+
makeToken({
|
|
798
|
+
path: ['platform', 'ios', 'font', 'sans'],
|
|
799
|
+
name: 'font-sans',
|
|
800
|
+
$type: 'string',
|
|
801
|
+
$value: 'SF Pro',
|
|
802
|
+
}),
|
|
803
|
+
makeToken({ path: ['typography', 'heading', 'xl', 'font-size'], $value: '40px' }),
|
|
804
|
+
makeToken({ path: ['typography', 'heading', 'xl', 'font-family'], $value: 'Crimson Pro' }),
|
|
805
|
+
];
|
|
806
|
+
|
|
807
|
+
describe('register — css/cdx-theme format', () => {
|
|
808
|
+
it('emits @theme static, mode variants, and platform variants', async () => {
|
|
809
|
+
const { formats } = captureRegistrations();
|
|
810
|
+
const css = await formats['css/cdx-theme'].format?.({
|
|
811
|
+
dictionary: { allTokens: FORMAT_TOKENS },
|
|
812
|
+
file: {},
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
expect(css).toContain('@theme static {');
|
|
816
|
+
expect(css).toContain('--color-brand-500: #112233;');
|
|
817
|
+
expect(css).toContain('--color-surface-background: #ffffff;');
|
|
818
|
+
expect(css).toContain('@layer theme {');
|
|
819
|
+
expect(css).toContain('@variant light {');
|
|
820
|
+
expect(css).toContain('@variant dark {');
|
|
821
|
+
expect(css).toContain('@variant web {');
|
|
822
|
+
expect(css).toContain('@variant ios {');
|
|
823
|
+
expect(css).toContain("--font-sans: 'Inter';");
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
describe('register — css/tailwind-utilities format', () => {
|
|
828
|
+
it('emits an @utility block per composite typography style', async () => {
|
|
829
|
+
const { formats } = captureRegistrations();
|
|
830
|
+
const css = await formats['css/tailwind-utilities'].format?.({
|
|
831
|
+
dictionary: { allTokens: FORMAT_TOKENS },
|
|
832
|
+
file: {},
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
expect(css).toContain('@utility heading-xl {');
|
|
836
|
+
expect(css).toContain('font-size: 40px;');
|
|
837
|
+
expect(css).toContain("font-family: 'Crimson Pro';");
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
describe('register — css/cdx-vanilla format', () => {
|
|
842
|
+
it('emits :root primitives, a dark-mode media block, and typography classes', async () => {
|
|
843
|
+
const { formats } = captureRegistrations();
|
|
844
|
+
const css = await formats['css/cdx-vanilla'].format?.({
|
|
845
|
+
dictionary: { allTokens: FORMAT_TOKENS },
|
|
846
|
+
file: {},
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
expect(css).toContain(':root {');
|
|
850
|
+
expect(css).toContain('--color-brand-500: #112233;');
|
|
851
|
+
expect(css).toContain("--font-sans: 'Inter';");
|
|
852
|
+
expect(css).toContain('@media (prefers-color-scheme: dark) {');
|
|
853
|
+
expect(css).toContain('.heading-xl {');
|
|
854
|
+
expect(css).toContain('font-size: 40px;');
|
|
855
|
+
});
|
|
856
|
+
});
|