Files
gh-alekspetrov-navigator/skills/product-design/functions/token_extractor.py
2025-11-29 17:51:59 +08:00

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()