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
283 lines
9.2 KiB
Python
283 lines
9.2 KiB
Python
"""
|
|
CSS Token Source
|
|
|
|
Extracts design tokens from CSS custom properties (CSS variables).
|
|
Parses :root declarations and other CSS variable definitions.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import List, Optional, Tuple
|
|
from .base import DesignToken, TokenCollection, TokenSource, TokenType, TokenCategory
|
|
|
|
|
|
class CSSTokenSource(TokenSource):
|
|
"""
|
|
Extract tokens from CSS files.
|
|
|
|
Parses CSS custom properties defined in :root or other selectors.
|
|
Supports:
|
|
- :root { --color-primary: #3B82F6; }
|
|
- [data-theme="dark"] { --color-primary: #60A5FA; }
|
|
- Comments as descriptions
|
|
"""
|
|
|
|
@property
|
|
def source_type(self) -> str:
|
|
return "css"
|
|
|
|
async def extract(self, source: str) -> TokenCollection:
|
|
"""
|
|
Extract tokens from CSS file or content.
|
|
|
|
Args:
|
|
source: File path or CSS content string
|
|
|
|
Returns:
|
|
TokenCollection with extracted tokens
|
|
"""
|
|
# Determine if source is file path or content
|
|
if self._is_file_path(source):
|
|
file_path = Path(source)
|
|
if not file_path.exists():
|
|
raise FileNotFoundError(f"CSS 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 = self._parse_css(content, source_file)
|
|
|
|
return TokenCollection(
|
|
tokens=tokens,
|
|
name=f"CSS 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 it contains CSS syntax, it's content
|
|
if '{' in source or ':' in source and ';' in source:
|
|
return False
|
|
# If it ends with .css, it's a file
|
|
if source.endswith('.css'):
|
|
return True
|
|
# If path exists, it's a file
|
|
return Path(source).exists()
|
|
|
|
def _parse_css(self, content: str, source_file: str) -> List[DesignToken]:
|
|
"""Parse CSS content and extract custom properties."""
|
|
tokens = []
|
|
|
|
# Track line numbers
|
|
lines = content.split('\n')
|
|
line_map = self._build_line_map(content)
|
|
|
|
# Find all CSS variable declarations
|
|
# Pattern matches: --var-name: value;
|
|
var_pattern = re.compile(
|
|
r'(\/\*[^*]*\*\/\s*)?' # Optional preceding comment
|
|
r'(--[\w-]+)\s*:\s*' # Variable name
|
|
r'([^;]+);', # Value
|
|
re.MULTILINE
|
|
)
|
|
|
|
# Find variables in all rule blocks
|
|
for match in var_pattern.finditer(content):
|
|
comment = match.group(1)
|
|
var_name = match.group(2)
|
|
var_value = match.group(3).strip()
|
|
|
|
# Get line number
|
|
pos = match.start()
|
|
line_num = self._get_line_number(pos, line_map)
|
|
|
|
# Extract description from comment
|
|
description = ""
|
|
if comment:
|
|
description = self._clean_comment(comment)
|
|
|
|
# Get context (selector)
|
|
context = self._get_selector_context(content, pos)
|
|
|
|
# Create token
|
|
token = DesignToken(
|
|
name=self._normalize_var_name(var_name),
|
|
value=var_value,
|
|
description=description,
|
|
source=self._create_source_id(source_file, line_num),
|
|
source_file=source_file,
|
|
source_line=line_num,
|
|
original_name=var_name,
|
|
original_value=var_value,
|
|
)
|
|
|
|
# Add context as tag if not :root
|
|
if context and context != ":root":
|
|
token.tags.append(f"context:{context}")
|
|
|
|
tokens.append(token)
|
|
|
|
return tokens
|
|
|
|
def _build_line_map(self, content: str) -> List[int]:
|
|
"""Build map of character positions to line numbers."""
|
|
line_map = []
|
|
pos = 0
|
|
for i, line in enumerate(content.split('\n'), 1):
|
|
line_map.append(pos)
|
|
pos += len(line) + 1 # +1 for newline
|
|
return line_map
|
|
|
|
def _get_line_number(self, pos: int, line_map: List[int]) -> int:
|
|
"""Get line number for character position."""
|
|
for i, line_start in enumerate(line_map):
|
|
if i + 1 < len(line_map):
|
|
if line_start <= pos < line_map[i + 1]:
|
|
return i + 1
|
|
else:
|
|
return i + 1
|
|
return 1
|
|
|
|
def _normalize_var_name(self, var_name: str) -> str:
|
|
"""Convert CSS variable name to token name."""
|
|
# Remove -- prefix
|
|
name = var_name.lstrip('-')
|
|
# Convert kebab-case to dot notation
|
|
name = name.replace('-', '.')
|
|
return name
|
|
|
|
def _clean_comment(self, comment: str) -> str:
|
|
"""Extract text from CSS comment."""
|
|
if not comment:
|
|
return ""
|
|
# Remove /* and */
|
|
text = re.sub(r'/\*|\*/', '', comment)
|
|
# Clean whitespace
|
|
text = ' '.join(text.split())
|
|
return text.strip()
|
|
|
|
def _get_selector_context(self, content: str, pos: int) -> str:
|
|
"""Get the CSS selector context for a variable."""
|
|
# Find the opening brace before this position
|
|
before = content[:pos]
|
|
last_open = before.rfind('{')
|
|
if last_open == -1:
|
|
return ""
|
|
|
|
# Find the selector before the brace
|
|
selector_part = before[:last_open]
|
|
# Get last selector (after } or start)
|
|
last_close = selector_part.rfind('}')
|
|
if last_close != -1:
|
|
selector_part = selector_part[last_close + 1:]
|
|
|
|
# Clean up
|
|
selector = selector_part.strip()
|
|
# Handle multi-line selectors
|
|
selector = ' '.join(selector.split())
|
|
return selector
|
|
|
|
|
|
class CSSInlineExtractor:
|
|
"""
|
|
Extract inline styles from HTML/JSX for token candidate identification.
|
|
|
|
Finds style="" attributes and extracts values that could become tokens.
|
|
"""
|
|
|
|
# Patterns for extracting inline styles
|
|
STYLE_ATTR_PATTERN = re.compile(
|
|
r'style\s*=\s*["\']([^"\']+)["\']',
|
|
re.IGNORECASE
|
|
)
|
|
|
|
# JSX style object pattern
|
|
JSX_STYLE_PATTERN = re.compile(
|
|
r'style\s*=\s*\{\{([^}]+)\}\}',
|
|
re.MULTILINE
|
|
)
|
|
|
|
async def extract_candidates(self, source: str) -> List[Tuple[str, str, int]]:
|
|
"""
|
|
Extract inline style values as token candidates.
|
|
|
|
Returns list of (property, value, line_number) tuples.
|
|
"""
|
|
candidates = []
|
|
|
|
# Determine if file or content
|
|
if Path(source).exists():
|
|
content = Path(source).read_text(encoding="utf-8")
|
|
else:
|
|
content = source
|
|
|
|
lines = content.split('\n')
|
|
|
|
for i, line in enumerate(lines, 1):
|
|
# Check HTML style attribute
|
|
for match in self.STYLE_ATTR_PATTERN.finditer(line):
|
|
style_content = match.group(1)
|
|
for prop, value in self._parse_style_string(style_content):
|
|
if self._is_token_candidate(value):
|
|
candidates.append((prop, value, i))
|
|
|
|
# Check JSX style object
|
|
for match in self.JSX_STYLE_PATTERN.finditer(line):
|
|
style_content = match.group(1)
|
|
for prop, value in self._parse_jsx_style(style_content):
|
|
if self._is_token_candidate(value):
|
|
candidates.append((prop, value, i))
|
|
|
|
return candidates
|
|
|
|
def _parse_style_string(self, style: str) -> List[Tuple[str, str]]:
|
|
"""Parse CSS style string into property-value pairs."""
|
|
pairs = []
|
|
for declaration in style.split(';'):
|
|
if ':' in declaration:
|
|
prop, value = declaration.split(':', 1)
|
|
pairs.append((prop.strip(), value.strip()))
|
|
return pairs
|
|
|
|
def _parse_jsx_style(self, style: str) -> List[Tuple[str, str]]:
|
|
"""Parse JSX style object into property-value pairs."""
|
|
pairs = []
|
|
# Simple parsing for common cases
|
|
for part in style.split(','):
|
|
if ':' in part:
|
|
prop, value = part.split(':', 1)
|
|
prop = prop.strip().strip('"\'')
|
|
value = value.strip().strip('"\'')
|
|
# Convert camelCase to kebab-case
|
|
prop = re.sub(r'([a-z])([A-Z])', r'\1-\2', prop).lower()
|
|
pairs.append((prop, value))
|
|
return pairs
|
|
|
|
def _is_token_candidate(self, value: str) -> bool:
|
|
"""Check if value should be extracted as a token."""
|
|
value = value.strip().lower()
|
|
|
|
# Colors are always candidates
|
|
if re.match(r'^#[0-9a-f]{3,8}$', value):
|
|
return True
|
|
if re.match(r'^rgb[a]?\s*\(', value):
|
|
return True
|
|
if re.match(r'^hsl[a]?\s*\(', value):
|
|
return True
|
|
|
|
# Dimensions with common units
|
|
if re.match(r'^\d+(\.\d+)?(px|rem|em|%)$', value):
|
|
return True
|
|
|
|
# Skip variable references
|
|
if value.startswith('var('):
|
|
return False
|
|
|
|
# Skip inherit/initial/etc
|
|
if value in ('inherit', 'initial', 'unset', 'auto', 'none'):
|
|
return False
|
|
|
|
return False
|