Skip to content

Commit f4fe942

Browse files
committed
fix: support nested token declarations in frontmatter (#102)
1 parent 780fa38 commit f4fe942

3 files changed

Lines changed: 105 additions & 19 deletions

File tree

packages/cli/src/linter/model/handler.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,61 @@ describe('ModelHandler', () => {
7676
expect(semitransparent?.a).toBeCloseTo(166 / 255, 5);
7777
});
7878

79+
it('successfully parses nested color declarations (Issue #102)', () => {
80+
const result = handler.execute(makeParsed({
81+
colors: {
82+
background: {
83+
light: '#fbfaf1',
84+
dark: '#11140e'
85+
}
86+
}
87+
}));
88+
89+
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
90+
expect(result.designSystem.colors.has('background.light')).toBe(true);
91+
expect(result.designSystem.colors.has('background.dark')).toBe(true);
92+
expect(result.designSystem.colors.get('background.light')?.hex).toBe('#fbfaf1');
93+
expect(result.designSystem.symbolTable.has('colors.background.light')).toBe(true);
94+
});
95+
96+
it('successfully parses 3-level nested color declarations', () => {
97+
const result = handler.execute(makeParsed({
98+
colors: {
99+
background: {
100+
light: {
101+
primary: '#fbfaf1',
102+
secondary: '#f0f0f0'
103+
}
104+
}
105+
}
106+
}));
107+
108+
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
109+
expect(result.designSystem.colors.has('background.light.primary')).toBe(true);
110+
expect(result.designSystem.colors.has('background.light.secondary')).toBe(true);
111+
expect(result.designSystem.colors.get('background.light.primary')?.hex).toBe('#fbfaf1');
112+
expect(result.designSystem.symbolTable.has('colors.background.light.primary')).toBe(true);
113+
});
114+
115+
it('successfully parses 4-level nested color declarations', () => {
116+
const result = handler.execute(makeParsed({
117+
colors: {
118+
theme: {
119+
surface: {
120+
background: {
121+
base: '#fbfaf1'
122+
}
123+
}
124+
}
125+
}
126+
}));
127+
128+
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
129+
expect(result.designSystem.colors.has('theme.surface.background.base')).toBe(true);
130+
expect(result.designSystem.colors.get('theme.surface.background.base')?.hex).toBe('#fbfaf1');
131+
expect(result.designSystem.symbolTable.has('colors.theme.surface.background.base')).toBe(true);
132+
});
133+
79134
it('resolves standard CSS named colors and converts them to hex/sRGB', () => {
80135
const result = handler.execute(makeParsed({
81136
colors: { c1: 'red', c2: 'transparent', c3: 'aliceblue' },
@@ -204,6 +259,22 @@ describe('ModelHandler', () => {
204259
expect(bg.hex).toBe('#647d66');
205260
}
206261
});
262+
263+
it('resolves references to nested colors', () => {
264+
const result = handler.execute(makeParsed({
265+
colors: {
266+
background: {
267+
light: '#fbfaf1',
268+
dark: '#11140e'
269+
},
270+
page: '{colors.background.light}'
271+
}
272+
}));
273+
274+
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
275+
const page = result.designSystem.colors.get('page');
276+
expect(page?.hex).toBe('#fbfaf1');
277+
});
207278
});
208279

209280
// ── Cycle 12: Detect circular reference ───────────────────────────

packages/cli/src/linter/model/handler.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export class ModelHandler implements ModelSpec {
5151
// ── Phase 1: Resolve primitive tokens ──────────────────────────
5252
// Colors
5353
if (input.colors) {
54-
for (const [name, raw] of Object.entries(input.colors)) {
55-
if (isTokenReference(raw)) {
54+
forEachLeaf(input.colors, (name, raw) => {
55+
if (typeof raw === 'string' && isTokenReference(raw)) {
5656
// Store raw reference for later resolution
5757
symbolTable.set(`colors.${name}`, raw);
5858
} else if (isValidColor(raw)) {
@@ -68,7 +68,7 @@ export class ModelHandler implements ModelSpec {
6868
// Store as-is for fallback
6969
symbolTable.set(`colors.${name}`, raw);
7070
}
71-
}
71+
});
7272
}
7373

7474
// Typography
@@ -82,7 +82,7 @@ export class ModelHandler implements ModelSpec {
8282

8383
// Rounded
8484
if (input.rounded) {
85-
for (const [name, raw] of Object.entries(input.rounded)) {
85+
forEachLeaf(input.rounded, (name, raw) => {
8686
if (typeof raw === 'string') {
8787
if (isParseableDimension(raw)) {
8888
const resolved = parseDimension(raw);
@@ -106,39 +106,39 @@ export class ModelHandler implements ModelSpec {
106106
symbolTable.set(`rounded.${name}`, raw);
107107
}
108108
}
109-
}
109+
});
110110
}
111111

112112
// Spacing
113113
if (input.spacing) {
114-
for (const [name, raw] of Object.entries(input.spacing)) {
114+
forEachLeaf(input.spacing, (name, raw) => {
115115
if (isParseableDimension(raw)) {
116116
const resolved = parseDimension(raw);
117117
spacing.set(name, resolved);
118118
symbolTable.set(`spacing.${name}`, resolved);
119119
} else {
120120
symbolTable.set(`spacing.${name}`, raw);
121121
}
122-
}
122+
});
123123
}
124124

125125
// ── Phase 2: Resolve chained color references ──────────────────
126126
// Iterate color entries that are still raw references and resolve them
127127
if (input.colors) {
128-
for (const [name, raw] of Object.entries(input.colors)) {
129-
if (isTokenReference(raw)) {
128+
forEachLeaf(input.colors, (name, raw) => {
129+
if (typeof raw === 'string' && isTokenReference(raw)) {
130130
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
131131
if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'color') {
132132
colors.set(name, resolved as ResolvedColor);
133133
symbolTable.set(`colors.${name}`, resolved);
134134
}
135135
}
136-
}
136+
});
137137
}
138138

139139
// Resolve chained rounded references
140140
if (input.rounded) {
141-
for (const [name, raw] of Object.entries(input.rounded)) {
141+
forEachLeaf(input.rounded, (name, raw) => {
142142
if (typeof raw === 'string' && isTokenReference(raw)) {
143143
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
144144
if (
@@ -151,12 +151,12 @@ export class ModelHandler implements ModelSpec {
151151
symbolTable.set(`rounded.${name}`, resolved);
152152
}
153153
}
154-
}
154+
});
155155
}
156156

157157
// Resolve chained spacing references
158158
if (input.spacing) {
159-
for (const [name, raw] of Object.entries(input.spacing)) {
159+
forEachLeaf(input.spacing, (name, raw) => {
160160
if (typeof raw === 'string' && isTokenReference(raw)) {
161161
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
162162
if (
@@ -169,7 +169,7 @@ export class ModelHandler implements ModelSpec {
169169
symbolTable.set(`spacing.${name}`, resolved);
170170
}
171171
}
172-
}
172+
});
173173
}
174174

175175
// ── Phase 3: Build components ──────────────────────────────────
@@ -390,4 +390,19 @@ export function contrastRatio(a: ResolvedColor, b: ResolvedColor): number {
390390
const L1 = Math.max(a.luminance, b.luminance);
391391
const L2 = Math.min(a.luminance, b.luminance);
392392
return (L1 + 0.05) / (L2 + 0.05);
393+
}
394+
395+
/**
396+
* Recursively iterate over an object and call a function for each leaf node.
397+
* Leaf node paths are dot-separated (e.g. "background.light").
398+
*/
399+
function forEachLeaf(obj: Record<string, any>, fn: (path: string, value: any) => void, prefix = '') {
400+
for (const [key, value] of Object.entries(obj)) {
401+
const fullPath = prefix ? `${prefix}.${key}` : key;
402+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
403+
forEachLeaf(value, fn, fullPath);
404+
} else {
405+
fn(fullPath, value);
406+
}
407+
}
393408
}

packages/cli/src/linter/parser/spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ export interface ParsedDesignSystem {
4242
version?: string | undefined;
4343
name?: string | undefined;
4444
description?: string | undefined;
45-
colors?: Record<string, string> | undefined;
46-
typography?: Record<string, Record<string, string | number>> | undefined;
47-
rounded?: Record<string, string> | undefined;
48-
spacing?: Record<string, string> | undefined;
49-
components?: Record<string, Record<string, string>> | undefined;
45+
colors?: Record<string, any> | undefined;
46+
typography?: Record<string, Record<string, any>> | undefined;
47+
rounded?: Record<string, any> | undefined;
48+
spacing?: Record<string, any> | undefined;
49+
components?: Record<string, Record<string, any>> | undefined;
5050
sourceMap: Map<string, SourceLocation>;
5151
/** Markdown heading names found in the document (e.g., 'Colors', 'Typography') */
5252
sections?: string[] | undefined;

0 commit comments

Comments
 (0)