import { describe, it, expect } from 'vitest'; import { parseDTCGFile, isDTCGFile } from '../dtcg-parser.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeTokenFile(obj: Record): string { return JSON.stringify(obj); } // --------------------------------------------------------------------------- // isDTCGFile // --------------------------------------------------------------------------- describe('isDTCGFile', () => { it('detects .tokens.json extension', () => { expect(isDTCGFile('design.tokens.json')).toBe(true); expect(isDTCGFile('src/tokens/brand.tokens.json')).toBe(true); }); it('detects .tokens extension', () => { expect(isDTCGFile('brand.tokens')).toBe(true); }); it('rejects non-DTCG files', () => { expect(isDTCGFile('variables.scss')).toBe(false); expect(isDTCGFile('tokens.css')).toBe(false); expect(isDTCGFile('package.json')).toBe(false); }); it('accepts common unqualified DTCG token filenames', () => { expect(isDTCGFile('tokens.json')).toBe(true); expect(isDTCGFile('design-tokens.json')).toBe(true); }); }); // --------------------------------------------------------------------------- // Basic parsing // --------------------------------------------------------------------------- describe('parseDTCGFile', () => { describe('basic token parsing', () => { it('parses a simple color token', () => { const content = makeTokenFile({ color: { $type: 'color', primary: { $value: '#ff5500', $description: 'Primary brand color', }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); expect(result.categories.colors).toBeDefined(); expect(result.categories.colors).toHaveLength(1); expect(result.categories.colors[0].value).toBe('#ff5500'); expect(result.categories.colors[0].description).toBe('Primary brand color'); }); it('parses multiple tokens across groups', () => { const content = makeTokenFile({ color: { $type: 'color', primary: { $value: '#ff5500' }, secondary: { $value: '#0055ff' }, }, spacing: { $type: 'dimension', sm: { $value: { value: 4, unit: 'px' } }, md: { $value: { value: 8, unit: 'px' } }, lg: { $value: { value: 16, unit: 'px' } }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(5); expect(result.categories.colors).toHaveLength(2); expect(result.categories.spacing).toHaveLength(3); expect(result.categories.colors[0].name).toBe('--color-primary'); expect(result.categories.spacing[0].name).toBe('--spacing-sm'); }); it('returns zero tokens for an empty file', () => { const result = parseDTCGFile('{}', 'test.tokens.json'); expect(result.total).toBe(0); expect(Object.keys(result.categories)).toHaveLength(0); }); it('skips tokens without a resolvable type', () => { const content = makeTokenFile({ mystery: { unknown: { $value: 'something' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(0); }); }); // --------------------------------------------------------------------------- // All 13 DTCG token types // --------------------------------------------------------------------------- describe('token type handling', () => { it('parses color tokens with hex string value', () => { const content = makeTokenFile({ color: { $type: 'color', brand: { $value: '#e63946' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors[0].value).toBe('#e63946'); }); it('parses color tokens with object value (hex field)', () => { const content = makeTokenFile({ color: { $type: 'color', brand: { $value: { colorSpace: 'srgb', components: [0.9, 0.22, 0.27], hex: '#e63845' }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors[0].value).toBe('#e63845'); }); it('parses color tokens with object value (components only)', () => { const content = makeTokenFile({ color: { $type: 'color', brand: { $value: { colorSpace: 'srgb', components: [1, 0, 0] }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors[0].value).toBe('rgb(255, 0, 0)'); }); it('parses color with alpha from components', () => { const content = makeTokenFile({ color: { $type: 'color', overlay: { $value: { colorSpace: 'srgb', components: [0, 0, 0], hex: '#000000', alpha: 0.5 }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors[0].value).toBe('rgba(0, 0, 0, 0.5)'); }); it('parses dimension tokens with object value', () => { const content = makeTokenFile({ spacing: { $type: 'dimension', sm: { $value: { value: 4, unit: 'px' } }, md: { $value: { value: 1, unit: 'rem' } }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.spacing).toHaveLength(2); expect(result.categories.spacing[0].value).toBe('4px'); expect(result.categories.spacing[1].value).toBe('1rem'); }); it('parses dimension tokens with string value', () => { const content = makeTokenFile({ spacing: { $type: 'dimension', gap: { $value: '16px' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.spacing[0].value).toBe('16px'); }); it('maps dimension tokens with "radius" in name to radius category', () => { const content = makeTokenFile({ radius: { $type: 'dimension', sm: { $value: { value: 4, unit: 'px' } }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.radius).toBeDefined(); expect(result.categories.radius).toHaveLength(1); }); it('parses fontFamily tokens', () => { const content = makeTokenFile({ font: { $type: 'fontFamily', sans: { $value: ['Inter', 'Helvetica', 'sans-serif'] }, mono: { $value: 'JetBrains Mono' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.typography).toHaveLength(2); expect(result.categories.typography[0].value).toBe('Inter, Helvetica, sans-serif'); expect(result.categories.typography[1].value).toBe('JetBrains Mono'); }); it('parses fontWeight tokens', () => { const content = makeTokenFile({ weight: { $type: 'fontWeight', regular: { $value: 400 }, bold: { $value: 700 }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.typography).toHaveLength(2); expect(result.categories.typography[0].value).toBe('400'); expect(result.categories.typography[1].value).toBe('700'); }); it('parses duration tokens', () => { const content = makeTokenFile({ motion: { $type: 'duration', fast: { $value: '100ms' }, slow: { $value: '500ms' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.transitions).toHaveLength(2); expect(result.categories.transitions[0].value).toBe('100ms'); }); it('parses cubicBezier tokens', () => { const content = makeTokenFile({ easing: { $type: 'cubicBezier', standard: { $value: [0.4, 0, 0.2, 1] }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.transitions).toHaveLength(1); expect(result.categories.transitions[0].value).toBe('cubic-bezier(0.4, 0, 0.2, 1)'); }); it('parses number tokens', () => { const content = makeTokenFile({ scale: { $type: 'number', ratio: { $value: 1.5 }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.other).toHaveLength(1); expect(result.categories.other[0].value).toBe('1.5'); }); it('parses shadow tokens (single)', () => { const content = makeTokenFile({ shadow: { $type: 'shadow', sm: { $value: { offsetX: '0px', offsetY: '1px', blur: '3px', spread: '0px', color: 'rgba(0, 0, 0, 0.1)', }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.shadows).toHaveLength(1); expect(result.categories.shadows[0].value).toContain('0px 1px 3px'); }); it('parses shadow tokens (multiple layers)', () => { const content = makeTokenFile({ shadow: { $type: 'shadow', lg: { $value: [ { offsetX: '0px', offsetY: '4px', blur: '8px', color: 'rgba(0, 0, 0, 0.1)' }, { offsetX: '0px', offsetY: '2px', blur: '4px', color: 'rgba(0, 0, 0, 0.06)' }, ], }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.shadows[0].value).toContain(','); }); it('parses shadow tokens with inset', () => { const content = makeTokenFile({ shadow: { $type: 'shadow', inner: { $value: { offsetX: '0px', offsetY: '2px', blur: '4px', color: 'rgba(0, 0, 0, 0.05)', inset: true, }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.shadows[0].value).toMatch(/^inset /); }); it('parses border tokens', () => { const content = makeTokenFile({ border: { $type: 'border', default: { $value: { color: '#e0e0e0', width: '1px', style: 'solid', }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.borders).toHaveLength(1); expect(result.categories.borders[0].value).toBe('1px solid #e0e0e0'); }); it('parses strokeStyle tokens', () => { const content = makeTokenFile({ stroke: { $type: 'strokeStyle', dashed: { $value: 'dashed' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.borders).toHaveLength(1); expect(result.categories.borders[0].value).toBe('dashed'); }); it('parses transition tokens', () => { const content = makeTokenFile({ motion: { $type: 'transition', fade: { $value: { duration: '200ms', timingFunction: [0.4, 0, 0.2, 1], delay: '0ms', }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.transitions).toHaveLength(1); expect(result.categories.transitions[0].value).toContain('200ms'); expect(result.categories.transitions[0].value).toContain('cubic-bezier'); }); it('parses gradient tokens', () => { const content = makeTokenFile({ gradient: { $type: 'gradient', brand: { $value: [ { color: '#ff5500', position: 0 }, { color: '#0055ff', position: 1 }, ], }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors).toHaveLength(1); expect(result.categories.colors[0].value).toContain('linear-gradient'); expect(result.categories.colors[0].value).toContain('#ff5500'); }); it('parses typography composite tokens', () => { const content = makeTokenFile({ type: { $type: 'typography', heading: { $value: { fontFamily: 'Inter', fontSize: '24px', fontWeight: 700, lineHeight: 1.2, }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.typography).toHaveLength(1); expect(result.categories.typography[0].value).toContain('700'); expect(result.categories.typography[0].value).toContain('24px'); expect(result.categories.typography[0].value).toContain('Inter'); }); }); // --------------------------------------------------------------------------- // $type inheritance // --------------------------------------------------------------------------- describe('$type inheritance', () => { it('inherits $type from parent group', () => { const content = makeTokenFile({ color: { $type: 'color', brand: { primary: { $value: '#ff5500' }, secondary: { $value: '#0055ff' }, }, surface: { default: { $value: '#ffffff' }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(3); expect(result.categories.colors).toHaveLength(3); }); it('child $type overrides inherited $type', () => { const content = makeTokenFile({ tokens: { $type: 'color', accent: { $value: '#ff5500' }, spacing: { $type: 'dimension', sm: { $value: '4px' }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors).toHaveLength(1); expect(result.categories.spacing).toHaveLength(1); }); it('inherits $type through multiple nesting levels', () => { const content = makeTokenFile({ ds: { $type: 'color', brand: { primary: { light: { $value: '#ffaa80' }, dark: { $value: '#cc4400' }, }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(2); expect(result.categories.colors).toHaveLength(2); }); }); // --------------------------------------------------------------------------- // Alias resolution // --------------------------------------------------------------------------- describe('alias resolution', () => { it('resolves simple aliases', () => { const content = makeTokenFile({ color: { $type: 'color', brand: { $value: '#ff5500' }, accent: { $value: '{color.brand}' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(2); const accent = result.categories.colors.find((t) => t.name.includes('accent')); expect(accent).toBeDefined(); expect(accent!.value).toBe('#ff5500'); }); it('resolves chained aliases', () => { const content = makeTokenFile({ color: { $type: 'color', base: { $value: '#e63946' }, brand: { $value: '{color.base}' }, primary: { $value: '{color.brand}' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); const primary = result.categories.colors.find((t) => t.name.includes('primary')); expect(primary).toBeDefined(); expect(primary!.value).toBe('#e63946'); }); it('resolves aliases across groups', () => { const content = makeTokenFile({ primitive: { $type: 'color', blue500: { $value: '#3b82f6' }, }, semantic: { $type: 'color', primary: { $value: '{primitive.blue500}' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); const primary = result.categories.colors.find((t) => t.name.includes('primary')); expect(primary!.value).toBe('#3b82f6'); }); it('detects circular aliases and throws', () => { const content = makeTokenFile({ color: { $type: 'color', a: { $value: '{color.b}' }, b: { $value: '{color.a}' }, }, }); // Should not throw — circular aliases are caught and the raw value is kept const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(2); }); it('detects self-referencing aliases', () => { const content = makeTokenFile({ color: { $type: 'color', recursive: { $value: '{color.recursive}' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); }); it('detects deeply nested circular aliases (exceeding max depth)', () => { // Create a chain of 12 aliases that will exceed MAX_ALIAS_DEPTH of 10 const tokens: Record = { $type: 'color' }; for (let i = 0; i < 12; i++) { tokens[`t${i}`] = { $value: `{chain.t${(i + 1) % 12}}` }; } const content = makeTokenFile({ chain: tokens }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(12); }); it('handles alias to non-existent path gracefully', () => { const content = makeTokenFile({ color: { $type: 'color', broken: { $value: '{does.not.exist}' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); // Value should be the unresolved alias string expect(result.categories.colors[0].value).toBe('{does.not.exist}'); }); }); // --------------------------------------------------------------------------- // $extends group inheritance // --------------------------------------------------------------------------- describe('$extends group inheritance', () => { it('resolves $extends from another group', () => { const content = makeTokenFile({ base: { $type: 'color', primary: { $value: '#ff5500' }, secondary: { $value: '#0055ff' }, }, theme: { $extends: 'base', primary: { $value: '#ee4400' }, // Override }, }); const result = parseDTCGFile(content, 'test.tokens.json'); // base has 2, theme inherits secondary and overrides primary = 2 more const themeTokens = result.categories.colors.filter((t) => t.name.includes('theme')); expect(themeTokens.length).toBeGreaterThanOrEqual(2); // The overridden primary should have the new value const themePrimary = themeTokens.find((t) => t.name.includes('primary')); expect(themePrimary).toBeDefined(); expect(themePrimary!.value).toBe('#ee4400'); }); }); // --------------------------------------------------------------------------- // $deprecated propagation // --------------------------------------------------------------------------- describe('$deprecated handling', () => { it('preserves $deprecated boolean on tokens', () => { const content = makeTokenFile({ color: { $type: 'color', old: { $value: '#ff0000', $deprecated: true }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); // Deprecated tokens are still parsed — consumers can filter }); it('preserves $deprecated string message on tokens', () => { const content = makeTokenFile({ color: { $type: 'color', legacy: { $value: '#ff0000', $deprecated: 'Use color.brand instead', }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); }); }); // --------------------------------------------------------------------------- // $extensions preservation // --------------------------------------------------------------------------- describe('$extensions handling', () => { it('reads prefix from $extensions["com.usefragments"].prefix', () => { const content = makeTokenFile({ $extensions: { 'com.usefragments': { prefix: 'mui' }, }, color: { $type: 'color', primary: { $value: '#1976d2' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.prefix).toBe('--mui-'); }); }); // --------------------------------------------------------------------------- // Prefix detection // --------------------------------------------------------------------------- describe('prefix detection', () => { it('detects prefix from single top-level group', () => { const content = makeTokenFile({ ds: { $type: 'color', primary: { $value: '#ff5500' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.prefix).toBe('--ds-'); }); it('detects prefix from $extensions over top-level group', () => { const content = makeTokenFile({ $extensions: { 'com.usefragments': { prefix: 'brand' }, }, ds: { $type: 'color', primary: { $value: '#ff5500' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.prefix).toBe('--brand-'); }); it('falls back to -- for empty files', () => { const result = parseDTCGFile('{}', 'test.tokens.json'); expect(result.prefix).toBe('--'); }); }); // --------------------------------------------------------------------------- // CSS variable naming // --------------------------------------------------------------------------- describe('CSS variable naming', () => { it('converts dot paths to hyphenated CSS names', () => { const content = makeTokenFile({ color: { $type: 'color', brand: { primary: { $value: '#ff5500' }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); const token = result.categories.colors[0]; expect(token.name).toContain('brand-primary'); expect(token.name).toMatch(/^--/); }); }); // --------------------------------------------------------------------------- // Edge cases // --------------------------------------------------------------------------- describe('edge cases', () => { it('handles deeply nested token trees', () => { const content = makeTokenFile({ level1: { $type: 'color', level2: { level3: { level4: { deep: { $value: '#001122' }, }, }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); expect(result.categories.colors[0].name).toContain('level2'); }); it('ignores non-object children', () => { const content = makeTokenFile({ color: { $type: 'color', primary: { $value: '#ff5500' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); }); it('handles large token files efficiently', () => { const tokens: Record = { $type: 'color' }; for (let i = 0; i < 500; i++) { tokens[`color-${i}`] = { $value: `#${String(i).padStart(6, '0')}` }; } const content = makeTokenFile({ palette: tokens }); const start = performance.now(); const result = parseDTCGFile(content, 'test.tokens.json'); const elapsed = performance.now() - start; expect(result.total).toBe(500); expect(elapsed).toBeLessThan(1000); // Should parse 500 tokens in under 1 second }); it('throws on invalid JSON', () => { expect(() => parseDTCGFile('not json', 'test.tokens.json')).toThrow(); }); it('handles tokens with special characters in keys', () => { const content = makeTokenFile({ 'my-brand': { $type: 'color', 'dark-mode': { 'bg-primary': { $value: '#1a1a2e' }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(1); }); it('handles mixed groups with both tokens and sub-groups', () => { const content = makeTokenFile({ color: { $type: 'color', primary: { $value: '#ff5500' }, shades: { light: { $value: '#ffaa80' }, dark: { $value: '#cc4400' }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.total).toBe(3); }); it('handles dimension tokens with zero value', () => { const content = makeTokenFile({ spacing: { $type: 'dimension', none: { $value: { value: 0, unit: 'px' } }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.spacing[0].value).toBe('0px'); }); it('handles color with all-zero components', () => { const content = makeTokenFile({ color: { $type: 'color', black: { $value: { colorSpace: 'srgb', components: [0, 0, 0] }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors[0].value).toBe('rgb(0, 0, 0)'); }); it('handles color with non-normalized components (0-255 range)', () => { const content = makeTokenFile({ color: { $type: 'color', red: { $value: { colorSpace: 'srgb', components: [255, 0, 0] }, }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); expect(result.categories.colors[0].value).toBe('rgb(255, 0, 0)'); }); }); // --------------------------------------------------------------------------- // Output shape compatibility // --------------------------------------------------------------------------- describe('TokenParseOutput compatibility', () => { it('returns the same shape as the SCSS parser', () => { const content = makeTokenFile({ ds: { $type: 'color', primary: { $value: '#ff5500' }, }, }); const result = parseDTCGFile(content, 'test.tokens.json'); // Verify shape matches TokenParseOutput expect(result).toHaveProperty('prefix'); expect(result).toHaveProperty('categories'); expect(result).toHaveProperty('total'); expect(typeof result.prefix).toBe('string'); expect(typeof result.total).toBe('number'); expect(typeof result.categories).toBe('object'); // Verify individual token shape matches ParsedToken const token = result.categories.colors[0]; expect(token).toHaveProperty('name'); expect(token).toHaveProperty('category'); expect(typeof token.name).toBe('string'); expect(typeof token.category).toBe('string'); }); }); });