Files
dss/demo/tools/ingest/css.py
Digital Production Factory 276ed71f31 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
2025-12-09 18:45:48 -03:00

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