""" 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