Initial commit
This commit is contained in:
394
skills/product-design/functions/token_extractor.py
Executable file
394
skills/product-design/functions/token_extractor.py
Executable file
@@ -0,0 +1,394 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user