#!/usr/bin/env python3 """ Token Resolution Layer for CODITECT Design System
Resolves token references (e.g., $colors.brand.primary) to concrete values from design-system.json. Part of the Protocode rendering pipeline (ADR-091).
Usage: # As a module from token_resolver import TokenResolver resolver = TokenResolver('design-system.json') value = resolver.resolve('$colors.brand.primary') # Returns "#6B9BD2"
# CLI
python token_resolver.py design-system.json '$colors.brand.primary'
python token_resolver.py design-system.json --resolve-file protocode.json
Author: CODITECT Architecture Team Version: 1.0.0 ADR: ADR-091 """
import json import re import sys from pathlib import Path from typing import Any, Dict, List, Optional, Set, Union from dataclasses import dataclass, field
@dataclass class ResolutionError: """Represents an error during token resolution.""" token: str message: str path: Optional[str] = None
@dataclass class ResolutionResult: """Result of token resolution with metadata.""" original: str resolved: Any tokens_used: Set[str] = field(default_factory=set) errors: List[ResolutionError] = field(default_factory=list)
@property
def success(self) -> bool:
return len(self.errors) == 0
class TokenResolver: """ Resolves design token references to concrete values.
Token references use the format: $category.subcategory.token
Examples:
$colors.brand.primary
$spacing.4
$typography.fontSize.lg
$shadows.card
"""
TOKEN_PATTERN = re.compile(r'\$([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+)')
def __init__(self, design_system: Union[str, Path, Dict[str, Any]]):
"""
Initialize the token resolver.
Args:
design_system: Path to design-system.json or the parsed dict
"""
if isinstance(design_system, (str, Path)):
self.design_system = self._load_design_system(design_system)
self.source_path = Path(design_system)
else:
self.design_system = design_system
self.source_path = None
self._resolution_cache: Dict[str, Any] = {}
self._resolution_stack: List[str] = [] # For circular reference detection
def _load_design_system(self, path: Union[str, Path]) -> Dict[str, Any]:
"""Load design system from JSON file."""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Design system file not found: {path}")
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def resolve(self, token_ref: str, default: Any = None) -> Any:
"""
Resolve a single token reference to its concrete value.
Args:
token_ref: Token reference (e.g., "$colors.brand.primary")
default: Default value if token not found
Returns:
The resolved value or default
Raises:
ValueError: If token not found and no default provided
RecursionError: If circular reference detected
"""
# Return non-token values as-is
if not token_ref or not isinstance(token_ref, str):
return token_ref
if not token_ref.startswith('$'):
return token_ref
# Check cache
if token_ref in self._resolution_cache:
return self._resolution_cache[token_ref]
# Detect circular references
if token_ref in self._resolution_stack:
cycle = ' -> '.join(self._resolution_stack + [token_ref])
raise RecursionError(f"Circular token reference detected: {cycle}")
self._resolution_stack.append(token_ref)
try:
# Parse token path: $colors.brand.primary -> ["colors", "brand", "primary"]
path_str = token_ref[1:] # Remove leading $
path_parts = path_str.split('.')
# Navigate the design system
value = self.design_system
for part in path_parts:
if isinstance(value, dict) and part in value:
value = value[part]
else:
if default is not None:
return default
raise ValueError(f"Token not found: {token_ref}")
# Handle nested token references
if isinstance(value, str) and value.startswith('$'):
value = self.resolve(value, default)
# Cache and return
self._resolution_cache[token_ref] = value
return value
finally:
self._resolution_stack.pop()
def resolve_all(self, obj: Any) -> Any:
"""
Recursively resolve all token references in an object.
Args:
obj: Object containing token references (dict, list, or string)
Returns:
Object with all tokens resolved to concrete values
"""
if isinstance(obj, str):
# Check if the entire string is a token
if obj.startswith('$'):
return self.resolve(obj)
# Check for embedded tokens in string
matches = self.TOKEN_PATTERN.findall(obj)
if matches:
result = obj
for match in matches:
token = f'${match}'
try:
resolved = self.resolve(token)
if isinstance(resolved, str):
result = result.replace(token, resolved)
except ValueError:
pass # Leave unresolved tokens as-is
return result
return obj
elif isinstance(obj, dict):
return {k: self.resolve_all(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [self.resolve_all(item) for item in obj]
else:
return obj
def resolve_with_result(self, token_ref: str) -> ResolutionResult:
"""
Resolve a token and return detailed result with metadata.
Args:
token_ref: Token reference to resolve
Returns:
ResolutionResult with resolved value and metadata
"""
result = ResolutionResult(original=token_ref, resolved=None)
try:
result.resolved = self.resolve(token_ref)
result.tokens_used.add(token_ref)
# Track nested tokens
if isinstance(result.resolved, str) and result.resolved.startswith('$'):
nested_result = self.resolve_with_result(result.resolved)
result.tokens_used.update(nested_result.tokens_used)
result.errors.extend(nested_result.errors)
result.resolved = nested_result.resolved
except ValueError as e:
result.errors.append(ResolutionError(
token=token_ref,
message=str(e)
))
except RecursionError as e:
result.errors.append(ResolutionError(
token=token_ref,
message=str(e)
))
return result
def validate_tokens(self, tokens: List[str]) -> Dict[str, List[ResolutionError]]:
"""
Validate a list of token references.
Args:
tokens: List of token references to validate
Returns:
Dict mapping invalid tokens to their errors
"""
errors = {}
for token in tokens:
result = self.resolve_with_result(token)
if not result.success:
errors[token] = result.errors
return errors
def list_all_tokens(self, prefix: str = '') -> List[str]:
"""
List all available token paths in the design system.
Args:
prefix: Optional prefix to filter tokens
Returns:
List of token paths (e.g., ["$colors.brand.primary", ...])
"""
tokens = []
def walk(obj: Any, path: str):
if isinstance(obj, dict):
for key, value in obj.items():
new_path = f"{path}.{key}" if path else key
walk(value, new_path)
else:
token = f"${path}"
if not prefix or token.startswith(prefix):
tokens.append(token)
walk(self.design_system, '')
return sorted(tokens)
def get_token_category(self, token_ref: str) -> Optional[str]:
"""
Get the category of a token (e.g., "colors", "spacing").
Args:
token_ref: Token reference
Returns:
Category name or None if invalid
"""
if not token_ref.startswith('$'):
return None
parts = token_ref[1:].split('.')
return parts[0] if parts else None
def export_css_variables(self, prefix: str = '') -> str:
"""
Export design system as CSS custom properties.
Args:
prefix: Optional prefix for CSS variable names
Returns:
CSS string with :root variables
"""
variables = []
def walk(obj: Any, path: str):
if isinstance(obj, dict):
for key, value in obj.items():
new_path = f"{path}-{key}" if path else key
walk(value, new_path)
elif isinstance(obj, (str, int, float)):
var_name = f"--{prefix}{path}" if prefix else f"--{path}"
# Resolve any token references
if isinstance(obj, str) and obj.startswith('$'):
try:
obj = self.resolve(obj)
except ValueError:
pass
variables.append(f" {var_name}: {obj};")
walk(self.design_system, '')
return ":root {\n" + "\n".join(sorted(variables)) + "\n}"
def export_tailwind_config(self) -> Dict[str, Any]:
"""
Export design system as Tailwind CSS theme config.
Returns:
Dict suitable for tailwind.config.js theme.extend
"""
config = {
"colors": {},
"spacing": {},
"fontSize": {},
"fontFamily": {},
"fontWeight": {},
"borderRadius": {},
"boxShadow": {}
}
# Map colors
if "colors" in self.design_system:
for category, values in self.design_system["colors"].items():
if isinstance(values, dict):
config["colors"][category] = values
else:
config["colors"][category] = values
# Map spacing
if "spacing" in self.design_system:
config["spacing"] = self.design_system["spacing"]
# Map typography
if "typography" in self.design_system:
typography = self.design_system["typography"]
if "fontSize" in typography:
config["fontSize"] = typography["fontSize"]
if "fontFamily" in typography:
config["fontFamily"] = typography["fontFamily"]
if "fontWeight" in typography:
config["fontWeight"] = typography["fontWeight"]
# Map border radius
if "borderRadius" in self.design_system:
config["borderRadius"] = self.design_system["borderRadius"]
# Map shadows
if "shadows" in self.design_system:
config["boxShadow"] = self.design_system["shadows"]
return {"extend": config}
def resolve_protocode_file( protocode_path: Union[str, Path], design_system_path: Union[str, Path], output_path: Optional[Union[str, Path]] = None ) -> Dict[str, Any]: """ Resolve all tokens in a protocode JSON file.
Args:
protocode_path: Path to protocode.json
design_system_path: Path to design-system.json
output_path: Optional output path (defaults to protocode_path.resolved.json)
Returns:
Resolved protocode dict
"""
resolver = TokenResolver(design_system_path)
with open(protocode_path, 'r', encoding='utf-8') as f:
protocode = json.load(f)
resolved = resolver.resolve_all(protocode)
if output_path is None:
protocode_path = Path(protocode_path)
output_path = protocode_path.parent / f"{protocode_path.stem}.resolved.json"
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(resolved, f, indent=2)
return resolved
def main(): """CLI entry point.""" import argparse
parser = argparse.ArgumentParser(
description='Resolve design system tokens',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
Resolve a single token
python token_resolver.py design-system.json '$colors.brand.primary'
Resolve all tokens in a file
python token_resolver.py design-system.json --resolve-file protocode.json
List all available tokens
python token_resolver.py design-system.json --list-tokens
Export as CSS variables
python token_resolver.py design-system.json --export-css > variables.css
Export as Tailwind config
python token_resolver.py design-system.json --export-tailwind > tailwind.theme.json """ )
parser.add_argument('design_system', help='Path to design-system.json')
parser.add_argument('token', nargs='?', help='Token reference to resolve')
parser.add_argument('--resolve-file', '-r', metavar='FILE',
help='Resolve all tokens in a JSON file')
parser.add_argument('--output', '-o', metavar='FILE',
help='Output file path')
parser.add_argument('--list-tokens', '-l', action='store_true',
help='List all available tokens')
parser.add_argument('--export-css', action='store_true',
help='Export as CSS custom properties')
parser.add_argument('--export-tailwind', action='store_true',
help='Export as Tailwind theme config')
parser.add_argument('--validate', '-v', metavar='TOKENS',
help='Validate comma-separated tokens')
args = parser.parse_args()
try:
resolver = TokenResolver(args.design_system)
if args.list_tokens:
tokens = resolver.list_all_tokens()
print(f"Available tokens ({len(tokens)}):\n")
for token in tokens:
print(f" {token}")
return 0
if args.export_css:
print(resolver.export_css_variables())
return 0
if args.export_tailwind:
config = resolver.export_tailwind_config()
print(json.dumps(config, indent=2))
return 0
if args.validate:
tokens = [t.strip() for t in args.validate.split(',')]
errors = resolver.validate_tokens(tokens)
if errors:
print("Validation errors:")
for token, errs in errors.items():
for err in errs:
print(f" {token}: {err.message}")
return 1
else:
print(f"All {len(tokens)} tokens valid")
return 0
if args.resolve_file:
resolved = resolve_protocode_file(
args.resolve_file,
args.design_system,
args.output
)
output_path = args.output or f"{Path(args.resolve_file).stem}.resolved.json"
print(f"Resolved protocode written to: {output_path}")
return 0
if args.token:
result = resolver.resolve(args.token)
print(result)
return 0
parser.print_help()
return 1
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
return 1
if name == 'main': sys.exit(main())