""" Style resolver — ``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue. Resolves a tuple of atom strings into a ``StyleValue`` with: - A content-addressed class name (``sx-{hash[:6]}``) - Base CSS declarations - Pseudo-class rules (hover, focus, etc.) - Media-query rules (responsive breakpoints) - Referenced @keyframes definitions Resolution order per atom: 1. Dictionary lookup in ``STYLE_ATOMS`` 2. Arbitrary value pattern match (``w-[347px]`` → ``width:347px``) 3. Ignored (unknown atoms are silently skipped) Results are memoized by input tuple for zero-cost repeat calls. """ from __future__ import annotations import hashlib import re from functools import lru_cache from typing import Sequence from .style_dict import ( ARBITRARY_PATTERNS, CHILD_SELECTOR_ATOMS, KEYFRAMES, PSEUDO_VARIANTS, RESPONSIVE_BREAKPOINTS, STYLE_ATOMS, ) from .types import StyleValue # --------------------------------------------------------------------------- # Compiled arbitrary-value patterns # --------------------------------------------------------------------------- _COMPILED_PATTERNS: list[tuple[re.Pattern, str]] = [ (re.compile(f"^{pat}$"), tmpl) for pat, tmpl in ARBITRARY_PATTERNS ] # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def resolve_style(atoms: tuple[str, ...]) -> StyleValue: """Resolve a tuple of keyword atoms into a StyleValue. Each atom is a Tailwind-compatible keyword (``flex``, ``gap-4``, ``hover:bg-sky-200``, ``sm:flex-row``, etc.). Both keywords (without leading colon) and runtime strings are accepted. """ return _resolve_cached(atoms) def merge_styles(styles: Sequence[StyleValue]) -> StyleValue: """Merge multiple StyleValues into one. Later declarations win for the same CSS property. Class name is recomputed from the merged declarations. """ if len(styles) == 1: return styles[0] all_decls: list[str] = [] all_media: list[tuple[str, str]] = [] all_pseudo: list[tuple[str, str]] = [] all_kf: list[tuple[str, str]] = [] for sv in styles: if sv.declarations: all_decls.append(sv.declarations) all_media.extend(sv.media_rules) all_pseudo.extend(sv.pseudo_rules) all_kf.extend(sv.keyframes) merged_decls = ";".join(all_decls) return _build_style_value( merged_decls, tuple(all_media), tuple(all_pseudo), tuple(dict(all_kf).items()), # dedupe keyframes by name ) # --------------------------------------------------------------------------- # Internal resolution # --------------------------------------------------------------------------- @lru_cache(maxsize=4096) def _resolve_cached(atoms: tuple[str, ...]) -> StyleValue: """Memoized resolver.""" base_decls: list[str] = [] media_rules: list[tuple[str, str]] = [] # (query, decls) pseudo_rules: list[tuple[str, str]] = [] # (selector_suffix, decls) keyframes_needed: list[tuple[str, str]] = [] for atom in atoms: if not atom: continue # Strip leading colon if keyword form (":flex" → "flex") a = atom.lstrip(":") # Split variant prefix(es): "hover:bg-sky-200" → ["hover", "bg-sky-200"] # "sm:hover:bg-sky-200" → ["sm", "hover", "bg-sky-200"] variant, base = _split_variant(a) # Resolve the base atom to CSS declarations decls = _resolve_atom(base) if not decls: continue # Check if this atom references a keyframe _check_keyframes(base, keyframes_needed) # Route to the appropriate bucket if variant is None: base_decls.append(decls) elif variant in RESPONSIVE_BREAKPOINTS: query = RESPONSIVE_BREAKPOINTS[variant] media_rules.append((query, decls)) elif variant in PSEUDO_VARIANTS: pseudo_sel = PSEUDO_VARIANTS[variant] pseudo_rules.append((pseudo_sel, decls)) else: # Compound variant: "sm:hover:..." → media + pseudo parts = variant.split(":") media_part = None pseudo_part = None for p in parts: if p in RESPONSIVE_BREAKPOINTS: media_part = RESPONSIVE_BREAKPOINTS[p] elif p in PSEUDO_VARIANTS: pseudo_part = PSEUDO_VARIANTS[p] if media_part and pseudo_part: # Both media and pseudo — store as pseudo within media # For now, put in pseudo_rules with media annotation pseudo_rules.append((pseudo_part, decls)) media_rules.append((media_part, decls)) elif media_part: media_rules.append((media_part, decls)) elif pseudo_part: pseudo_rules.append((pseudo_part, decls)) else: # Unknown variant — treat as base base_decls.append(decls) return _build_style_value( ";".join(base_decls), tuple(media_rules), tuple(pseudo_rules), tuple(keyframes_needed), ) def _split_variant(atom: str) -> tuple[str | None, str]: """Split a potentially variant-prefixed atom. Returns (variant, base) where variant is None for non-prefixed atoms. Examples: "flex" → (None, "flex") "hover:bg-sky-200" → ("hover", "bg-sky-200") "sm:flex-row" → ("sm", "flex-row") "sm:hover:bg-sky-200" → ("sm:hover", "bg-sky-200") """ # Check for responsive prefix first (always outermost) for bp in RESPONSIVE_BREAKPOINTS: prefix = bp + ":" if atom.startswith(prefix): rest = atom[len(prefix):] # Check for nested pseudo variant for pv in PSEUDO_VARIANTS: inner_prefix = pv + ":" if rest.startswith(inner_prefix): return (bp + ":" + pv, rest[len(inner_prefix):]) return (bp, rest) # Check for pseudo variant for pv in PSEUDO_VARIANTS: prefix = pv + ":" if atom.startswith(prefix): return (pv, atom[len(prefix):]) return (None, atom) def _resolve_atom(atom: str) -> str | None: """Look up CSS declarations for a single base atom. Returns None if the atom is unknown. """ # 1. Dictionary lookup decls = STYLE_ATOMS.get(atom) if decls is not None: return decls # 2. Dynamic keyframes: animate-{name} → animation-name:{name} if atom.startswith("animate-"): name = atom[len("animate-"):] if name in KEYFRAMES: return f"animation-name:{name}" # 3. Arbitrary value pattern match for pattern, template in _COMPILED_PATTERNS: m = pattern.match(atom) if m: groups = m.groups() result = template for i, g in enumerate(groups): result = result.replace(f"{{{i}}}", g) return result # 4. Unknown atom — silently skip return None def _check_keyframes(atom: str, kf_list: list[tuple[str, str]]) -> None: """If the atom references a built-in animation, add its @keyframes.""" if atom.startswith("animate-"): name = atom[len("animate-"):] if name in KEYFRAMES: kf_list.append((name, KEYFRAMES[name])) def _build_style_value( declarations: str, media_rules: tuple, pseudo_rules: tuple, keyframes: tuple, ) -> StyleValue: """Build a StyleValue with a content-addressed class name.""" # Build hash from all rules for deterministic class name hash_input = declarations for query, decls in media_rules: hash_input += f"@{query}{{{decls}}}" for sel, decls in pseudo_rules: hash_input += f"{sel}{{{decls}}}" for name, rule in keyframes: hash_input += rule h = hashlib.sha256(hash_input.encode()).hexdigest()[:6] class_name = f"sx-{h}" return StyleValue( class_name=class_name, declarations=declarations, media_rules=media_rules, pseudo_rules=pseudo_rules, keyframes=keyframes, )