395 lines
12 KiB
Python
Executable File
395 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Extract design tokens from Figma variables and convert to DTCG format.
|
|
Compares with existing tokens and generates diff summary.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import argparse
|
|
from typing import Dict, List, Any, Tuple
|
|
|
|
|
|
def normalize_token_name(figma_name: str) -> str:
|
|
"""
|
|
Normalize Figma variable name to DTCG semantic naming.
|
|
|
|
Examples:
|
|
"Primary 500" → "color.primary.500"
|
|
"Spacing MD" → "spacing.md"
|
|
"Font Heading Large" → "typography.heading.large"
|
|
|
|
Args:
|
|
figma_name: Original Figma variable name
|
|
|
|
Returns:
|
|
Normalized DTCG token path
|
|
"""
|
|
name = figma_name.strip()
|
|
|
|
# Convert to lowercase and split
|
|
parts = name.lower().replace('-', ' ').replace('_', ' ').split()
|
|
|
|
# Detect token type from name
|
|
if any(keyword in parts for keyword in ['color', 'colour']):
|
|
token_type = 'color'
|
|
parts = [p for p in parts if p not in ['color', 'colour']]
|
|
elif any(keyword in parts for keyword in ['spacing', 'space', 'gap', 'padding', 'margin']):
|
|
token_type = 'spacing'
|
|
parts = [p for p in parts if p not in ['spacing', 'space', 'gap', 'padding', 'margin']]
|
|
elif any(keyword in parts for keyword in ['font', 'typography', 'text']):
|
|
token_type = 'typography'
|
|
parts = [p for p in parts if p not in ['font', 'typography', 'text']]
|
|
elif any(keyword in parts for keyword in ['radius', 'border']):
|
|
token_type = 'radius'
|
|
parts = [p for p in parts if p not in ['radius', 'border']]
|
|
elif any(keyword in parts for keyword in ['shadow', 'elevation']):
|
|
token_type = 'shadow'
|
|
parts = [p for p in parts if p not in ['shadow', 'elevation']]
|
|
else:
|
|
# Infer from first part
|
|
first_part = parts[0] if parts else ''
|
|
if first_part in ['primary', 'secondary', 'success', 'error', 'warning', 'info']:
|
|
token_type = 'color'
|
|
elif first_part in ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl']:
|
|
token_type = 'spacing'
|
|
else:
|
|
token_type = 'other'
|
|
|
|
# Build token path
|
|
if parts:
|
|
return f"{token_type}.{'.'.join(parts)}"
|
|
else:
|
|
return token_type
|
|
|
|
|
|
def detect_token_type(name: str, value: Any) -> str:
|
|
"""
|
|
Detect DTCG token type from name and value.
|
|
|
|
Args:
|
|
name: Token name
|
|
value: Token value
|
|
|
|
Returns:
|
|
DTCG type string
|
|
"""
|
|
name_lower = name.lower()
|
|
|
|
# Check by name first
|
|
if 'color' in name_lower or 'colour' in name_lower:
|
|
return 'color'
|
|
elif 'spacing' in name_lower or 'gap' in name_lower or 'padding' in name_lower or 'margin' in name_lower:
|
|
return 'dimension'
|
|
elif 'font' in name_lower or 'typography' in name_lower:
|
|
if isinstance(value, dict):
|
|
return 'typography'
|
|
else:
|
|
return 'fontFamily' if 'family' in name_lower else 'dimension'
|
|
elif 'radius' in name_lower or 'border' in name_lower:
|
|
return 'dimension'
|
|
elif 'shadow' in name_lower or 'elevation' in name_lower:
|
|
return 'shadow'
|
|
elif 'duration' in name_lower or 'transition' in name_lower:
|
|
return 'duration'
|
|
elif 'opacity' in name_lower or 'alpha' in name_lower:
|
|
return 'number'
|
|
|
|
# Infer from value
|
|
if isinstance(value, str):
|
|
if value.startswith('#') or value.startswith('rgb'):
|
|
return 'color'
|
|
elif value.endswith('px') or value.endswith('rem') or value.endswith('em'):
|
|
return 'dimension'
|
|
elif value.endswith('ms') or value.endswith('s'):
|
|
return 'duration'
|
|
elif isinstance(value, (int, float)):
|
|
return 'number'
|
|
elif isinstance(value, dict):
|
|
if 'fontFamily' in value or 'fontSize' in value:
|
|
return 'typography'
|
|
elif 'x' in value and 'y' in value:
|
|
return 'shadow'
|
|
|
|
return 'other'
|
|
|
|
|
|
def convert_to_dtcg(figma_variables: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Convert Figma variables to DTCG format.
|
|
|
|
Args:
|
|
figma_variables: Figma get_variable_defs response
|
|
|
|
Returns:
|
|
DTCG formatted tokens
|
|
"""
|
|
dtcg_tokens = {}
|
|
|
|
for var_name, var_data in figma_variables.items():
|
|
# Extract value and type
|
|
if isinstance(var_data, dict):
|
|
value = var_data.get('$value') or var_data.get('value')
|
|
var_type = var_data.get('$type') or var_data.get('type')
|
|
description = var_data.get('$description') or var_data.get('description', '')
|
|
else:
|
|
value = var_data
|
|
var_type = None
|
|
description = ''
|
|
|
|
# Detect type if not provided
|
|
if not var_type:
|
|
var_type = detect_token_type(var_name, value)
|
|
|
|
# Normalize token name to DTCG path
|
|
token_path = normalize_token_name(var_name)
|
|
|
|
# Build nested structure
|
|
path_parts = token_path.split('.')
|
|
current = dtcg_tokens
|
|
|
|
for i, part in enumerate(path_parts):
|
|
if i == len(path_parts) - 1:
|
|
# Last part - add token definition
|
|
current[part] = {
|
|
'$value': value,
|
|
'$type': var_type
|
|
}
|
|
if description:
|
|
current[part]['$description'] = description
|
|
else:
|
|
# Intermediate path - create nested dict
|
|
if part not in current:
|
|
current[part] = {}
|
|
current = current[part]
|
|
|
|
return dtcg_tokens
|
|
|
|
|
|
def generate_diff(new_tokens: Dict[str, Any],
|
|
existing_tokens: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
|
|
"""
|
|
Generate diff between new and existing tokens.
|
|
|
|
Args:
|
|
new_tokens: New tokens from Figma (DTCG format)
|
|
existing_tokens: Existing tokens from design-tokens.json
|
|
|
|
Returns:
|
|
Diff summary with added, modified, removed, unchanged
|
|
"""
|
|
diff = {
|
|
'added': [],
|
|
'modified': [],
|
|
'removed': [],
|
|
'unchanged': []
|
|
}
|
|
|
|
# Flatten tokens for comparison
|
|
new_flat = flatten_tokens(new_tokens)
|
|
existing_flat = flatten_tokens(existing_tokens)
|
|
|
|
# Find added and modified
|
|
for token_path, token_data in new_flat.items():
|
|
if token_path not in existing_flat:
|
|
diff['added'].append({
|
|
'path': token_path,
|
|
'value': token_data.get('$value'),
|
|
'type': token_data.get('$type')
|
|
})
|
|
else:
|
|
existing_value = existing_flat[token_path].get('$value')
|
|
new_value = token_data.get('$value')
|
|
|
|
if existing_value != new_value:
|
|
diff['modified'].append({
|
|
'path': token_path,
|
|
'old_value': existing_value,
|
|
'new_value': new_value,
|
|
'type': token_data.get('$type')
|
|
})
|
|
else:
|
|
diff['unchanged'].append({
|
|
'path': token_path,
|
|
'value': new_value
|
|
})
|
|
|
|
# Find removed
|
|
for token_path, token_data in existing_flat.items():
|
|
if token_path not in new_flat:
|
|
diff['removed'].append({
|
|
'path': token_path,
|
|
'value': token_data.get('$value'),
|
|
'type': token_data.get('$type')
|
|
})
|
|
|
|
return diff
|
|
|
|
|
|
def flatten_tokens(tokens: Dict[str, Any], prefix: str = '') -> Dict[str, Any]:
|
|
"""
|
|
Flatten nested DTCG tokens to dot notation paths.
|
|
|
|
Args:
|
|
tokens: Nested DTCG token structure
|
|
prefix: Current path prefix
|
|
|
|
Returns:
|
|
Flattened dictionary with dot notation keys
|
|
"""
|
|
flat = {}
|
|
|
|
for key, value in tokens.items():
|
|
current_path = f"{prefix}.{key}" if prefix else key
|
|
|
|
if isinstance(value, dict) and '$value' in value:
|
|
# This is a token definition
|
|
flat[current_path] = value
|
|
elif isinstance(value, dict):
|
|
# This is a nested group
|
|
flat.update(flatten_tokens(value, current_path))
|
|
|
|
return flat
|
|
|
|
|
|
def generate_summary(diff: Dict[str, List[Dict[str, Any]]]) -> Dict[str, Any]:
|
|
"""
|
|
Generate summary statistics from diff.
|
|
|
|
Args:
|
|
diff: Token diff
|
|
|
|
Returns:
|
|
Summary statistics
|
|
"""
|
|
total_new = len(diff['added']) + len(diff['unchanged'])
|
|
total_existing = len(diff['modified']) + len(diff['removed']) + len(diff['unchanged'])
|
|
|
|
return {
|
|
'total_new_tokens': total_new,
|
|
'total_existing_tokens': total_existing,
|
|
'added_count': len(diff['added']),
|
|
'modified_count': len(diff['modified']),
|
|
'removed_count': len(diff['removed']),
|
|
'unchanged_count': len(diff['unchanged']),
|
|
'sync_status': 'in_sync' if len(diff['added']) == 0 and len(diff['modified']) == 0 and len(diff['removed']) == 0 else 'drift_detected',
|
|
'drift_percentage': f"{((len(diff['modified']) + len(diff['removed'])) / max(total_existing, 1)) * 100:.1f}%"
|
|
}
|
|
|
|
|
|
def extract_tokens(figma_variables: Dict[str, Any],
|
|
existing_tokens: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
"""
|
|
Main extraction function: convert Figma variables to DTCG and generate diff.
|
|
|
|
Args:
|
|
figma_variables: Figma get_variable_defs response
|
|
existing_tokens: Current design-tokens.json (optional)
|
|
|
|
Returns:
|
|
Extraction results with DTCG tokens, diff, and summary
|
|
"""
|
|
# Convert to DTCG format
|
|
dtcg_tokens = convert_to_dtcg(figma_variables)
|
|
|
|
# Generate diff if existing tokens provided
|
|
if existing_tokens:
|
|
diff = generate_diff(dtcg_tokens, existing_tokens)
|
|
summary = generate_summary(diff)
|
|
else:
|
|
# No existing tokens - all are new
|
|
flat = flatten_tokens(dtcg_tokens)
|
|
diff = {
|
|
'added': [
|
|
{
|
|
'path': path,
|
|
'value': data.get('$value'),
|
|
'type': data.get('$type')
|
|
}
|
|
for path, data in flat.items()
|
|
],
|
|
'modified': [],
|
|
'removed': [],
|
|
'unchanged': []
|
|
}
|
|
summary = {
|
|
'total_new_tokens': len(flat),
|
|
'total_existing_tokens': 0,
|
|
'added_count': len(flat),
|
|
'modified_count': 0,
|
|
'removed_count': 0,
|
|
'unchanged_count': 0,
|
|
'sync_status': 'initial_extraction',
|
|
'drift_percentage': '0.0%'
|
|
}
|
|
|
|
return {
|
|
'dtcg_tokens': dtcg_tokens,
|
|
'diff': diff,
|
|
'summary': summary
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Extract design tokens from Figma and convert to DTCG format'
|
|
)
|
|
parser.add_argument(
|
|
'--figma-variables',
|
|
required=True,
|
|
help='Path to JSON file with Figma variables (get_variable_defs response)'
|
|
)
|
|
parser.add_argument(
|
|
'--existing-tokens',
|
|
help='Path to existing design-tokens.json (optional)'
|
|
)
|
|
parser.add_argument(
|
|
'--output',
|
|
help='Output file path (default: stdout)'
|
|
)
|
|
parser.add_argument(
|
|
'--format',
|
|
choices=['full', 'tokens-only', 'diff-only'],
|
|
default='full',
|
|
help='Output format (default: full)'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Load Figma variables
|
|
with open(args.figma_variables, 'r') as f:
|
|
figma_variables = json.load(f)
|
|
|
|
# Load existing tokens if provided
|
|
existing_tokens = None
|
|
if args.existing_tokens:
|
|
with open(args.existing_tokens, 'r') as f:
|
|
existing_tokens = json.load(f)
|
|
|
|
# Run extraction
|
|
results = extract_tokens(figma_variables, existing_tokens)
|
|
|
|
# Format output based on --format flag
|
|
if args.format == 'tokens-only':
|
|
output = results['dtcg_tokens']
|
|
elif args.format == 'diff-only':
|
|
output = {
|
|
'diff': results['diff'],
|
|
'summary': results['summary']
|
|
}
|
|
else:
|
|
output = results
|
|
|
|
output_json = json.dumps(output, indent=2)
|
|
|
|
# Write output
|
|
if args.output:
|
|
with open(args.output, 'w') as f:
|
|
f.write(output_json)
|
|
else:
|
|
print(output_json)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|