- Create new dss/ Python package at project root - Move MCP core from tools/dss_mcp/ to dss/mcp/ - Move storage layer from tools/storage/ to dss/storage/ - Move domain logic from dss-mvp1/dss/ to dss/ - Move services from tools/api/services/ to dss/services/ - Move API server to apps/api/ - Move CLI to apps/cli/ - Move Storybook assets to storybook/ - Create unified dss/__init__.py with comprehensive exports - Merge configuration into dss/settings.py (Pydantic-based) - Create pyproject.toml for proper package management - Update startup scripts for new paths - Remove old tools/ and dss-mvp1/ directories Architecture changes: - DSS is now MCP-first with 40+ tools for Claude Code - Clean imports: from dss import Projects, Components, FigmaToolSuite - No more sys.path.insert() hacking - apps/ contains thin application wrappers (API, CLI) - Single unified Python package for all DSS logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""
|
|
Tailwind Token Source
|
|
|
|
Extracts design tokens from Tailwind CSS configuration files.
|
|
Supports tailwind.config.js/ts and CSS-based Tailwind v4 configurations.
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional
|
|
from .base import DesignToken, TokenCollection, TokenSource, TokenCategory
|
|
|
|
|
|
class TailwindTokenSource(TokenSource):
|
|
"""
|
|
Extract tokens from Tailwind CSS configuration.
|
|
|
|
Parses:
|
|
- tailwind.config.js/ts (theme and extend sections)
|
|
- Tailwind v4 CSS-based configuration
|
|
- CSS custom properties from Tailwind output
|
|
"""
|
|
|
|
# Tailwind category mappings
|
|
TAILWIND_CATEGORIES = {
|
|
'colors': TokenCategory.COLORS,
|
|
'backgroundColor': TokenCategory.COLORS,
|
|
'textColor': TokenCategory.COLORS,
|
|
'borderColor': TokenCategory.COLORS,
|
|
'spacing': TokenCategory.SPACING,
|
|
'padding': TokenCategory.SPACING,
|
|
'margin': TokenCategory.SPACING,
|
|
'gap': TokenCategory.SPACING,
|
|
'fontSize': TokenCategory.TYPOGRAPHY,
|
|
'fontFamily': TokenCategory.TYPOGRAPHY,
|
|
'fontWeight': TokenCategory.TYPOGRAPHY,
|
|
'lineHeight': TokenCategory.TYPOGRAPHY,
|
|
'letterSpacing': TokenCategory.TYPOGRAPHY,
|
|
'width': TokenCategory.SIZING,
|
|
'height': TokenCategory.SIZING,
|
|
'maxWidth': TokenCategory.SIZING,
|
|
'maxHeight': TokenCategory.SIZING,
|
|
'minWidth': TokenCategory.SIZING,
|
|
'minHeight': TokenCategory.SIZING,
|
|
'borderRadius': TokenCategory.BORDERS,
|
|
'borderWidth': TokenCategory.BORDERS,
|
|
'boxShadow': TokenCategory.SHADOWS,
|
|
'dropShadow': TokenCategory.SHADOWS,
|
|
'opacity': TokenCategory.OPACITY,
|
|
'zIndex': TokenCategory.Z_INDEX,
|
|
'transitionDuration': TokenCategory.MOTION,
|
|
'transitionTimingFunction': TokenCategory.MOTION,
|
|
'animation': TokenCategory.MOTION,
|
|
'screens': TokenCategory.BREAKPOINTS,
|
|
}
|
|
|
|
@property
|
|
def source_type(self) -> str:
|
|
return "tailwind"
|
|
|
|
async def extract(self, source: str) -> TokenCollection:
|
|
"""
|
|
Extract tokens from Tailwind config.
|
|
|
|
Args:
|
|
source: Path to tailwind.config.js/ts or directory containing it
|
|
|
|
Returns:
|
|
TokenCollection with extracted tokens
|
|
"""
|
|
config_path = self._find_config(source)
|
|
if not config_path:
|
|
raise FileNotFoundError(f"Tailwind config not found in: {source}")
|
|
|
|
content = config_path.read_text(encoding="utf-8")
|
|
source_file = str(config_path.absolute())
|
|
|
|
# Parse based on file type
|
|
if config_path.suffix in ('.js', '.cjs', '.mjs', '.ts'):
|
|
tokens = self._parse_js_config(content, source_file)
|
|
elif config_path.suffix == '.css':
|
|
tokens = self._parse_css_config(content, source_file)
|
|
else:
|
|
tokens = []
|
|
|
|
return TokenCollection(
|
|
tokens=tokens,
|
|
name=f"Tailwind Tokens from {config_path.name}",
|
|
sources=[self._create_source_id(source_file)],
|
|
)
|
|
|
|
def _find_config(self, source: str) -> Optional[Path]:
|
|
"""Find Tailwind config file."""
|
|
path = Path(source)
|
|
|
|
# If it's a file, use it directly
|
|
if path.is_file():
|
|
return path
|
|
|
|
# If it's a directory, look for config files
|
|
if path.is_dir():
|
|
config_names = [
|
|
'tailwind.config.js',
|
|
'tailwind.config.cjs',
|
|
'tailwind.config.mjs',
|
|
'tailwind.config.ts',
|
|
]
|
|
for name in config_names:
|
|
config_path = path / name
|
|
if config_path.exists():
|
|
return config_path
|
|
|
|
return None
|
|
|
|
def _parse_js_config(self, content: str, source_file: str) -> List[DesignToken]:
|
|
"""Parse JavaScript/TypeScript Tailwind config."""
|
|
tokens = []
|
|
|
|
# Extract theme object using regex (simplified parsing)
|
|
# This handles common patterns but may not cover all edge cases
|
|
|
|
# Look for theme: { ... } or theme.extend: { ... }
|
|
theme_match = re.search(
|
|
r'theme\s*:\s*\{([\s\S]*?)\n\s*\}(?=\s*[,}])',
|
|
content
|
|
)
|
|
|
|
extend_match = re.search(
|
|
r'extend\s*:\s*\{([\s\S]*?)\n\s{4}\}',
|
|
content
|
|
)
|
|
|
|
if extend_match:
|
|
theme_content = extend_match.group(1)
|
|
tokens.extend(self._parse_theme_object(theme_content, source_file, "extend"))
|
|
|
|
if theme_match and not extend_match:
|
|
theme_content = theme_match.group(1)
|
|
tokens.extend(self._parse_theme_object(theme_content, source_file, "theme"))
|
|
|
|
return tokens
|
|
|
|
def _parse_theme_object(self, content: str, source_file: str, prefix: str) -> List[DesignToken]:
|
|
"""Parse theme object content."""
|
|
tokens = []
|
|
|
|
# Find property blocks like: colors: { primary: '#3B82F6', ... }
|
|
prop_pattern = re.compile(
|
|
r"(\w+)\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}",
|
|
re.MULTILINE
|
|
)
|
|
|
|
for match in prop_pattern.finditer(content):
|
|
category_name = match.group(1)
|
|
category_content = match.group(2)
|
|
|
|
category = self.TAILWIND_CATEGORIES.get(
|
|
category_name, TokenCategory.OTHER
|
|
)
|
|
|
|
# Parse values in this category
|
|
tokens.extend(
|
|
self._parse_category_values(
|
|
category_name,
|
|
category_content,
|
|
source_file,
|
|
category
|
|
)
|
|
)
|
|
|
|
return tokens
|
|
|
|
def _parse_category_values(
|
|
self,
|
|
category_name: str,
|
|
content: str,
|
|
source_file: str,
|
|
category: TokenCategory
|
|
) -> List[DesignToken]:
|
|
"""Parse values within a category."""
|
|
tokens = []
|
|
|
|
# Match key: value pairs
|
|
# Handles: key: 'value', key: "value", key: value, 'key': value
|
|
value_pattern = re.compile(
|
|
r"['\"]?(\w[\w-]*)['\"]?\s*:\s*['\"]?([^,'\"}\n]+)['\"]?",
|
|
)
|
|
|
|
for match in value_pattern.finditer(content):
|
|
key = match.group(1)
|
|
value = match.group(2).strip()
|
|
|
|
# Skip function calls and complex values for now
|
|
if '(' in value or '{' in value:
|
|
continue
|
|
|
|
# Skip references to other values
|
|
if value.startswith('colors.') or value.startswith('theme('):
|
|
continue
|
|
|
|
token = DesignToken(
|
|
name=f"{category_name}.{key}",
|
|
value=value,
|
|
source=self._create_source_id(source_file),
|
|
source_file=source_file,
|
|
original_name=f"{category_name}.{key}",
|
|
original_value=value,
|
|
category=category,
|
|
)
|
|
token.tags.append("tailwind")
|
|
tokens.append(token)
|
|
|
|
return tokens
|
|
|
|
def _parse_css_config(self, content: str, source_file: str) -> List[DesignToken]:
|
|
"""Parse Tailwind v4 CSS-based configuration."""
|
|
tokens = []
|
|
|
|
# Tailwind v4 uses @theme directive
|
|
theme_match = re.search(
|
|
r'@theme\s*\{([\s\S]*?)\}',
|
|
content
|
|
)
|
|
|
|
if theme_match:
|
|
theme_content = theme_match.group(1)
|
|
|
|
# Parse CSS custom properties
|
|
var_pattern = re.compile(
|
|
r'(--[\w-]+)\s*:\s*([^;]+);'
|
|
)
|
|
|
|
for match in var_pattern.finditer(theme_content):
|
|
var_name = match.group(1)
|
|
var_value = match.group(2).strip()
|
|
|
|
# Determine category from variable name
|
|
category = self._category_from_var_name(var_name)
|
|
|
|
token = DesignToken(
|
|
name=self._normalize_var_name(var_name),
|
|
value=var_value,
|
|
source=self._create_source_id(source_file),
|
|
source_file=source_file,
|
|
original_name=var_name,
|
|
original_value=var_value,
|
|
category=category,
|
|
)
|
|
token.tags.append("tailwind-v4")
|
|
tokens.append(token)
|
|
|
|
return tokens
|
|
|
|
def _normalize_var_name(self, var_name: str) -> str:
|
|
"""Convert CSS variable name to token name."""
|
|
name = var_name.lstrip('-')
|
|
name = name.replace('-', '.')
|
|
return name.lower()
|
|
|
|
def _category_from_var_name(self, var_name: str) -> TokenCategory:
|
|
"""Determine category from variable name."""
|
|
name_lower = var_name.lower()
|
|
|
|
if 'color' in name_lower or 'bg' in name_lower:
|
|
return TokenCategory.COLORS
|
|
if 'spacing' in name_lower or 'gap' in name_lower:
|
|
return TokenCategory.SPACING
|
|
if 'font' in name_lower or 'text' in name_lower:
|
|
return TokenCategory.TYPOGRAPHY
|
|
if 'radius' in name_lower or 'border' in name_lower:
|
|
return TokenCategory.BORDERS
|
|
if 'shadow' in name_lower:
|
|
return TokenCategory.SHADOWS
|
|
|
|
return TokenCategory.OTHER
|
|
|
|
|
|
class TailwindClassExtractor:
|
|
"""
|
|
Extract Tailwind class usage from source files.
|
|
|
|
Identifies Tailwind utility classes for analysis and migration.
|
|
"""
|
|
|
|
# Common Tailwind class prefixes
|
|
TAILWIND_PREFIXES = [
|
|
'bg-', 'text-', 'border-', 'ring-',
|
|
'p-', 'px-', 'py-', 'pt-', 'pr-', 'pb-', 'pl-',
|
|
'm-', 'mx-', 'my-', 'mt-', 'mr-', 'mb-', 'ml-',
|
|
'w-', 'h-', 'min-w-', 'min-h-', 'max-w-', 'max-h-',
|
|
'flex-', 'grid-', 'gap-',
|
|
'font-', 'text-', 'leading-', 'tracking-',
|
|
'rounded-', 'shadow-', 'opacity-',
|
|
'z-', 'transition-', 'duration-', 'ease-',
|
|
]
|
|
|
|
async def extract_usage(self, source: str) -> Dict[str, List[str]]:
|
|
"""
|
|
Extract Tailwind class usage from file.
|
|
|
|
Returns dict mapping class categories to list of used classes.
|
|
"""
|
|
if Path(source).exists():
|
|
content = Path(source).read_text(encoding="utf-8")
|
|
else:
|
|
content = source
|
|
|
|
usage: Dict[str, List[str]] = {}
|
|
|
|
# Find className or class attributes
|
|
class_pattern = re.compile(
|
|
r'(?:className|class)\s*=\s*["\']([^"\']+)["\']'
|
|
)
|
|
|
|
for match in class_pattern.finditer(content):
|
|
classes = match.group(1).split()
|
|
|
|
for cls in classes:
|
|
# Check if it's a Tailwind class
|
|
for prefix in self.TAILWIND_PREFIXES:
|
|
if cls.startswith(prefix):
|
|
category = prefix.rstrip('-')
|
|
if category not in usage:
|
|
usage[category] = []
|
|
if cls not in usage[category]:
|
|
usage[category].append(cls)
|
|
break
|
|
|
|
return usage
|