Initial commit: Clean DSS implementation
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
This commit is contained in:
289
demo/tools/ingest/scss.py
Normal file
289
demo/tools/ingest/scss.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user