Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
290 lines
9.0 KiB
Python
290 lines
9.0 KiB
Python
"""
|
|
SCSS Token Source
|
|
|
|
Extracts design tokens from SCSS/Sass variables.
|
|
Supports $variable declarations and @use module variables.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional
|
|
from .base import DesignToken, TokenCollection, TokenSource
|
|
|
|
|
|
class SCSSTokenSource(TokenSource):
|
|
"""
|
|
Extract tokens from SCSS/Sass files.
|
|
|
|
Parses:
|
|
- $variable: value;
|
|
- $variable: value !default;
|
|
- // Comment descriptions
|
|
- @use module variables
|
|
- Maps: $colors: (primary: #3B82F6, secondary: #10B981);
|
|
"""
|
|
|
|
@property
|
|
def source_type(self) -> str:
|
|
return "scss"
|
|
|
|
async def extract(self, source: str) -> TokenCollection:
|
|
"""
|
|
Extract tokens from SCSS file or content.
|
|
|
|
Args:
|
|
source: File path or SCSS content string
|
|
|
|
Returns:
|
|
TokenCollection with extracted tokens
|
|
"""
|
|
if self._is_file_path(source):
|
|
file_path = Path(source)
|
|
if not file_path.exists():
|
|
raise FileNotFoundError(f"SCSS file not found: {source}")
|
|
content = file_path.read_text(encoding="utf-8")
|
|
source_file = str(file_path.absolute())
|
|
else:
|
|
content = source
|
|
source_file = "<inline>"
|
|
|
|
tokens = []
|
|
|
|
# Extract simple variables
|
|
tokens.extend(self._parse_variables(content, source_file))
|
|
|
|
# Extract map variables
|
|
tokens.extend(self._parse_maps(content, source_file))
|
|
|
|
return TokenCollection(
|
|
tokens=tokens,
|
|
name=f"SCSS Tokens from {Path(source_file).name if source_file != '<inline>' else 'inline'}",
|
|
sources=[self._create_source_id(source_file)],
|
|
)
|
|
|
|
def _is_file_path(self, source: str) -> bool:
|
|
"""Check if source looks like a file path."""
|
|
if '$' in source and ':' in source:
|
|
return False
|
|
if source.endswith('.scss') or source.endswith('.sass'):
|
|
return True
|
|
return Path(source).exists()
|
|
|
|
def _parse_variables(self, content: str, source_file: str) -> List[DesignToken]:
|
|
"""Parse simple $variable declarations."""
|
|
tokens = []
|
|
lines = content.split('\n')
|
|
|
|
# Pattern for variable declarations
|
|
var_pattern = re.compile(
|
|
r'^\s*'
|
|
r'(\$[\w-]+)\s*:\s*' # Variable name
|
|
r'([^;!]+)' # Value
|
|
r'(\s*!default)?' # Optional !default
|
|
r'\s*;',
|
|
re.MULTILINE
|
|
)
|
|
|
|
# Track comments for descriptions
|
|
prev_comment = ""
|
|
|
|
for i, line in enumerate(lines, 1):
|
|
# Check for comment
|
|
comment_match = re.match(r'^\s*//\s*(.+)$', line)
|
|
if comment_match:
|
|
prev_comment = comment_match.group(1).strip()
|
|
continue
|
|
|
|
# Check for variable
|
|
var_match = var_pattern.match(line)
|
|
if var_match:
|
|
var_name = var_match.group(1)
|
|
var_value = var_match.group(2).strip()
|
|
is_default = bool(var_match.group(3))
|
|
|
|
# Skip if value is a map (handled separately)
|
|
if var_value.startswith('(') and var_value.endswith(')'):
|
|
prev_comment = ""
|
|
continue
|
|
|
|
# Skip if value references another variable that we can't resolve
|
|
if var_value.startswith('$') and '(' not in var_value:
|
|
# It's a simple variable reference, try to extract
|
|
pass
|
|
|
|
token = DesignToken(
|
|
name=self._normalize_var_name(var_name),
|
|
value=self._process_value(var_value),
|
|
description=prev_comment,
|
|
source=self._create_source_id(source_file, i),
|
|
source_file=source_file,
|
|
source_line=i,
|
|
original_name=var_name,
|
|
original_value=var_value,
|
|
)
|
|
|
|
if is_default:
|
|
token.tags.append("default")
|
|
|
|
tokens.append(token)
|
|
prev_comment = ""
|
|
else:
|
|
# Reset comment if line doesn't match
|
|
if line.strip() and not line.strip().startswith('//'):
|
|
prev_comment = ""
|
|
|
|
return tokens
|
|
|
|
def _parse_maps(self, content: str, source_file: str) -> List[DesignToken]:
|
|
"""Parse SCSS map declarations."""
|
|
tokens = []
|
|
|
|
# Pattern for map declarations (handles multi-line)
|
|
map_pattern = re.compile(
|
|
r'\$(\w[\w-]*)\s*:\s*\(([\s\S]*?)\)\s*;',
|
|
re.MULTILINE
|
|
)
|
|
|
|
for match in map_pattern.finditer(content):
|
|
map_name = match.group(1)
|
|
map_content = match.group(2)
|
|
|
|
# Get line number
|
|
line_num = content[:match.start()].count('\n') + 1
|
|
|
|
# Parse map entries
|
|
entries = self._parse_map_entries(map_content)
|
|
|
|
for key, value in entries.items():
|
|
token = DesignToken(
|
|
name=f"{self._normalize_var_name('$' + map_name)}.{key}",
|
|
value=self._process_value(value),
|
|
source=self._create_source_id(source_file, line_num),
|
|
source_file=source_file,
|
|
source_line=line_num,
|
|
original_name=f"${map_name}.{key}",
|
|
original_value=value,
|
|
)
|
|
token.tags.append("from-map")
|
|
tokens.append(token)
|
|
|
|
return tokens
|
|
|
|
def _parse_map_entries(self, map_content: str) -> Dict[str, str]:
|
|
"""Parse entries from a SCSS map."""
|
|
entries = {}
|
|
|
|
# Handle nested maps and simple key-value pairs
|
|
# This is a simplified parser for common cases
|
|
|
|
# Remove comments
|
|
map_content = re.sub(r'//[^\n]*', '', map_content)
|
|
|
|
# Split by comma (not inside parentheses)
|
|
depth = 0
|
|
current = ""
|
|
parts = []
|
|
|
|
for char in map_content:
|
|
if char == '(':
|
|
depth += 1
|
|
current += char
|
|
elif char == ')':
|
|
depth -= 1
|
|
current += char
|
|
elif char == ',' and depth == 0:
|
|
parts.append(current.strip())
|
|
current = ""
|
|
else:
|
|
current += char
|
|
|
|
if current.strip():
|
|
parts.append(current.strip())
|
|
|
|
# Parse each part
|
|
for part in parts:
|
|
if ':' in part:
|
|
key, value = part.split(':', 1)
|
|
key = key.strip().strip('"\'')
|
|
value = value.strip()
|
|
entries[key] = value
|
|
|
|
return entries
|
|
|
|
def _normalize_var_name(self, var_name: str) -> str:
|
|
"""Convert SCSS variable name to token name."""
|
|
# Remove $ prefix
|
|
name = var_name.lstrip('$')
|
|
# Convert kebab-case and underscores to dots
|
|
name = re.sub(r'[-_]', '.', name)
|
|
return name.lower()
|
|
|
|
def _process_value(self, value: str) -> str:
|
|
"""Process SCSS value for token storage."""
|
|
value = value.strip()
|
|
|
|
# Handle function calls (keep as-is for now)
|
|
if '(' in value and ')' in value:
|
|
return value
|
|
|
|
# Handle quotes
|
|
if (value.startswith('"') and value.endswith('"')) or \
|
|
(value.startswith("'") and value.endswith("'")):
|
|
return value[1:-1]
|
|
|
|
return value
|
|
|
|
|
|
class SCSSVariableResolver:
|
|
"""
|
|
Resolve SCSS variable references.
|
|
|
|
Builds a dependency graph and resolves $var references to actual values.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.variables: Dict[str, str] = {}
|
|
self.resolved: Dict[str, str] = {}
|
|
|
|
def add_variable(self, name: str, value: str) -> None:
|
|
"""Add a variable to the resolver."""
|
|
self.variables[name] = value
|
|
|
|
def resolve(self, name: str) -> Optional[str]:
|
|
"""Resolve a variable to its final value."""
|
|
if name in self.resolved:
|
|
return self.resolved[name]
|
|
|
|
value = self.variables.get(name)
|
|
if not value:
|
|
return None
|
|
|
|
# Check if value references other variables
|
|
if '$' in value:
|
|
resolved_value = self._resolve_references(value)
|
|
self.resolved[name] = resolved_value
|
|
return resolved_value
|
|
|
|
self.resolved[name] = value
|
|
return value
|
|
|
|
def _resolve_references(self, value: str, depth: int = 0) -> str:
|
|
"""Recursively resolve variable references in a value."""
|
|
if depth > 10: # Prevent infinite loops
|
|
return value
|
|
|
|
# Find variable references
|
|
var_pattern = re.compile(r'\$[\w-]+')
|
|
|
|
def replace_var(match):
|
|
var_name = match.group(0)
|
|
resolved = self.resolve(var_name.lstrip('$'))
|
|
return resolved if resolved else var_name
|
|
|
|
return var_pattern.sub(replace_var, value)
|
|
|
|
def resolve_all(self) -> Dict[str, str]:
|
|
"""Resolve all variables."""
|
|
for name in self.variables:
|
|
self.resolve(name)
|
|
return self.resolved
|