How DESIGN.md Token Reference Resolution Works Internally
DESIGN.md token reference resolution operates through a three-phase pipeline where the ModelHandler class first builds a symbol table of primitive values, then recursively resolves chained references like {colors.primary} while detecting cycles, and finally applies these tokens to component properties with full error reporting.
The google-labs-code/design.md repository implements a sophisticated token resolution system that transforms curly-brace references in DESIGN.md files into concrete design values. This article examines how the DESIGN.md token reference resolution mechanism processes {path.to.token} syntax through the CLI linter's model handler, from initial parsing to final component property assignment.
The Three-Phase Resolution Pipeline
The resolution logic lives in the ModelHandler class within packages/cli/src/linter/model/handler.ts. This handler orchestrates the transformation of raw YAML into a fully resolved design system through distinct phases.
Phase 1: Primitive Token Parsing and Symbol Table Construction
The process begins by loading spec limits from spec-config.yaml via getSpecConfig() in packages/cli/src/linter/spec-config.ts (lines 24‑27). The handler then iterates over top-level token groups—colors, typography, rounded, and spacing—to construct an initial symbol table.
For each entry in the parsed YAML (lines 54‑124 in handler.ts):
- Primitive values (hex colors, dimensions) are parsed using type-specific functions like
parseColororparseDimensionand inserted as resolved objects. - Reference strings (e.g.,
"{colors.primary}") are inserted as-is for later resolution.
This creates a hybrid symbol table where some keys point to concrete ResolvedColor or ResolvedDimension objects, while others point to raw reference strings awaiting the second phase.
Phase 2: Chained Reference Resolution with Cycle Detection
Once the initial table is built, the handler invokes resolveReference(symbolTable, path, visited, depth) for every entry that matches the reference pattern (lines 73‑96 in handler.ts). This recursive function implements the core DESIGN.md token reference resolution algorithm:
- Depth checking: Immediately returns
nullifdepthexceedsMAX_REFERENCE_DEPTH(default 10). - Cycle detection: Maintains a
visitedSet to track paths already traversed; detects circular references likea → b → c → a. - Path lookup: Retrieves the current value from
symbolTableusing the dot-separated path. - Recursive resolution: If the looked-up value is another reference string, the function recurses with the inner path.
- Object return: Returns the final resolved primitive object or
nullon failure.
When resolution succeeds, the symbol table entry is replaced with the resolved object (lines 128‑176 in handler.ts). Failures—whether from missing tokens, cycles, or depth violations—are recorded in the findings array for error reporting.
Phase 3: Component Property Resolution
The final phase processes component definitions (lines 78‑112 in handler.ts). For each property in a component:
- Numeric and boolean values pass through unchanged.
- Values matching the reference pattern trigger another call to
resolveReferenceagainst the fully populated symbol table. - Successful lookups replace the raw reference with the resolved token object.
- Unresolved references are collected in
unresolvedRefsfor reporting.
This ensures that component properties like backgroundColor or padding contain fully resolved objects (e.g., ResolvedColor) rather than reference strings, making them ready for downstream generators like Tailwind configs.
Configuration Limits and Safety Guards
The resolution engine enforces strict boundaries to prevent runaway recursion and excessive nesting, defined in spec-config.yaml and exposed through packages/cli/src/linter/spec-config.ts:
MAX_REFERENCE_DEPTH(default 10): Limits the number of hops in a reference chain. Prevents infinite recursion when tokens reference each other in long chains.MAX_TOKEN_NESTING_DEPTH(default 20): Restricts how deep objects can nest in the YAML tree, enforced byforEachLeafto maintain sane token structures.
These constants are loaded at initialization and checked during the resolution process to ensure the linter terminates predictably even with malformed input.
Practical Implementation Examples
Resolving a Simple Color Reference
Consider a DESIGN.md file with a reference:
colors:
primary: "#1A1C1E"
accent: "{colors.primary}"
The ModelHandler executes:
// Phase 1: Build symbol table
symbolTable.set('colors.primary', resolvedPrimary); // ResolvedColor
symbolTable.set('colors.accent', '{colors.primary}'); // Raw string
// Phase 2: Resolve reference
const resolved = resolveReference(
symbolTable,
'colors.primary',
new Set(),
0
);
// Returns ResolvedColor for "#1A1C1E"
symbolTable.set('colors.accent', resolved);
The accent token now points to the same ResolvedColor instance as primary.
Detecting Circular Reference Chains
The resolver detects cycles through the visited Set:
colors:
a: "{colors.b}"
b: "{colors.c}"
c: "{colors.a}" # Creates cycle
Execution trace:
resolveReference('colors.a', visited=[], depth=0)
→ sees '{colors.b}' → recurse('colors.b', visited=['a'], depth=1)
→ sees '{colors.c}' → recurse('colors.c', visited=['a','b'], depth=2)
→ sees '{colors.a}' → 'a' in visited → return null
The model generates a finding:
{
severity: 'error',
path: 'colors.a',
message: 'Reference could not be resolved (missing or circular).'
}
Processing Component Properties
Component definitions resolve against the complete symbol table:
components:
card:
backgroundColor: "{colors.primary}"
padding: "{spacing.md}"
During component handling (lines 78‑112 in handler.ts):
if (isTokenReference(rawValue)) {
const refPath = rawValue.slice(1, -1); // "colors.primary"
const resolved = resolveReference(symbolTable, refPath, new Set(), 0);
if (resolved !== null) {
properties.set(propName, resolved);
} else {
unresolvedRefs.push(rawValue);
}
}
The resulting ComponentDef contains resolved objects ready for code generation.
Programmatic CLI Usage
Invoke the resolver directly in TypeScript:
import { ModelHandler } from './packages/cli/src/linter/model/handler.js';
import { parseDesignSystem } from './packages/cli/src/linter/parser/spec.js';
const rawDesign = await readFile('DESIGN.md', 'utf8');
const parsed = parseDesignSystem(rawDesign);
const result = new ModelHandler().execute(parsed);
if (result.findings.length) {
console.error('Validation errors:', result.findings);
}
console.log('Resolved tokens:', [...result.designSystem.colors.entries()]);
This demonstrates the complete pipeline: parsing YAML → building the model → resolving references → emitting findings.
Summary
- DESIGN.md token reference resolution uses a three-phase pipeline in the
ModelHandlerclass to transform{path.to.token}syntax into concrete values. - The system builds a symbol table in
packages/cli/src/linter/model/handler.ts(lines 54‑124), then recursively resolves chained references viaresolveReference()(lines 73‑96) with cycle detection and depth limits. - Safety guards (
MAX_REFERENCE_DEPTH,MAX_TOKEN_NESTING_DEPTH) prevent infinite recursion and excessive nesting, configured inspec-config.yamland loaded viaspec-config.ts. - Component properties resolve against the fully populated symbol table, with failures tracked in
unresolvedRefsfor reporting. - All errors—including invalid formats, missing references, and circular dependencies—are accumulated in the
findingsarray and returned with the model result.
Frequently Asked Questions
How does the resolver prevent infinite loops with circular token references?
The resolveReference function in packages/cli/src/linter/model/handler.ts tracks visited paths using a Set parameter. Before recursing into a new reference, it checks if the path already exists in the set. If detected, the function returns null immediately and the model records a circular reference error in the findings array.
What is the maximum depth for token reference chains?
The default maximum reference depth is 10 hops, defined by the MAX_REFERENCE_DEPTH constant in packages/cli/src/linter/spec-config.ts. This limit prevents runaway recursion when tokens reference each other in long chains. You can modify this value in spec-config.yaml if your design system requires deeper nesting.
Can component properties reference tokens that themselves reference other tokens?
Yes. The resolution pipeline handles chained references transparently. When processing component properties in phase 3, the resolver calls the same resolveReference function used for top-level tokens. This function recursively follows reference chains (e.g., {colors.accent} → {colors.primary} → #1A1C1E) until reaching a primitive value or hitting a cycle/depth limit.
Where are the resolved token values stored after processing?
Resolved values replace the original entries in the symbol table maintained by the ModelHandler. Specifically, lines 128‑176 in packages/cli/src/linter/model/handler.ts update the symbol table and dedicated maps (colors, rounded, spacing) with the resolved objects (ResolvedColor, ResolvedDimension, etc.), ensuring downstream generators receive concrete values rather than reference strings.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →