Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Replace Tailwind class strings with native SX expressions: (css :flex :gap-4 :hover:bg-sky-200) instead of :class "flex gap-4 ..." - Add style_dict.py: 516 atoms, variants, breakpoints, keyframes, patterns - Add style_resolver.py: memoized resolver with variant splitting - Add StyleValue type to types.py (frozen dataclass with class_name, declarations, etc.) - Add css and merge-styles primitives to primitives.py - Add defstyle and defkeyframes special forms to evaluator.py and async_eval.py - Integrate StyleValue into html.py and async_eval.py render paths - Add register_generated_rule() to css_registry.py, fix media query selector - Add style dict JSON delivery with localStorage caching to helpers.py - Add client-side css primitive, resolver, and style injection to sx.js Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
8.1 KiB
Python
255 lines
8.1 KiB
Python
"""
|
|
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,
|
|
)
|