Source code for rational.styling.compiler

"""Compile ``Style`` objects into hashed, deduplicated CSS classes."""

from __future__ import annotations

import hashlib
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any

from .responsive import DEFAULT_BREAKPOINTS, DEFAULT_CONTAINERS, Breakpoint
from .style import UNSAFE_VALUE, Style, StyleError
from .theme import Theme, get_theme

_ALIGN = {"between": "space-between", "around": "space-around", "evenly": "space-evenly"}
_PSEUDO: dict[str, tuple[str, ...]] = {
    "hover": (":hover",),
    "focus": (":focus",),
    "focus_visible": (":focus-visible",),
    "active": (":active",),
    "disabled": (":disabled", "[aria-disabled=true]"),
    "placeholder": ("::placeholder",),
}
_HOVER_MEDIA = "@media (hover: hover)"
_DEFAULT_TRANSITION = (
    "color var(--rw-motion) ease,background-color var(--rw-motion) ease,"
    "border-color var(--rw-motion) ease,box-shadow var(--rw-motion) ease,"
    "transform var(--rw-motion) ease,opacity var(--rw-motion) ease"
)


@dataclass(frozen=True, slots=True)
class CompiledStyle:
    class_name: str
    rules: tuple[str, ...]


Rule = tuple[tuple[str, ...], tuple[str, ...], str]  # (at-rules, selector suffixes, declarations)


[docs] class StyleCompiler: """Resolves tokens against a theme; caches compiled styles by value.""" def __init__( self, theme: str | Theme = "modern", breakpoints: Mapping[str, Breakpoint] | None = None, containers: Mapping[str, Breakpoint] | None = None, ) -> None: self.theme = get_theme(theme) self.breakpoints = {**DEFAULT_BREAKPOINTS, **(breakpoints or {})} self.containers = {**DEFAULT_CONTAINERS, **(containers or {})} self._cache: dict[tuple[Any, ...], CompiledStyle] = {} self._props: dict[str, Callable[[Style, Any], list[tuple[str, str]]]] = self._property_table() def compile(self, style: Style) -> CompiledStyle: key = style.key() cached = self._cache.get(key) if cached is not None: return cached rules: list[Rule] = [] self._collect(style, (), ("",), rules) canonical = "|".join(f"{at}{sfx}{decls}" for at, sfx, decls in rules) name = "rw-" + hashlib.sha256(canonical.encode()).hexdigest()[:8] compiled = CompiledStyle(name, tuple(self._render(name, r) for r in rules)) self._cache[key] = compiled return compiled def declarations(self, style: Style) -> str: out: list[tuple[str, str]] = [] for name, value in style.items(): handler = self._props.get(name) if handler is not None: out.extend(handler(style, value)) if style.raw: out.extend((prop, _safe(str(value))) for prop, value in style.raw.items()) return "".join(f"{p}:{v};" for p, v in out) # -- internals ----------------------------------------------------------------- def _collect(self, style: Style, at: tuple[str, ...], suffixes: tuple[str, ...], rules: list[Rule]) -> None: decls = self.declarations(style) if decls: rules.append((at, suffixes, decls)) for pseudo, alternatives in _PSEUDO.items(): sub = getattr(style, pseudo) if sub is not None: nested = tuple(s + a for s in suffixes for a in alternatives) inner_at = (*at, _HOVER_MEDIA) if pseudo == "hover" and _HOVER_MEDIA not in at else at self._collect(sub, inner_at, nested, rules) if style.viewport: for name, sub in self._ordered(style.viewport, self.breakpoints, "breakpoint"): self._collect(sub, (*at, f"@media {name}"), suffixes, rules) if style.container: for name, sub in self._ordered(style.container, self.containers, "container size"): self._collect(sub, (*at, f"@container {name}"), suffixes, rules) @staticmethod def _ordered( variants: Mapping[str, Style], registry: Mapping[str, Breakpoint], what: str ) -> list[tuple[str, Style]]: out = [] for name, sub in variants.items(): if name not in registry: raise StyleError(f"unknown {what} {name!r}; known: {sorted(registry)}") out.append((registry[name], sub)) out.sort(key=lambda pair: pair[0].sort_key()) return [(bp.condition(), sub) for bp, sub in out] @staticmethod def _render(name: str, rule: Rule) -> str: at, suffixes, decls = rule text = ",".join(f".{name}{s}" for s in suffixes) + "{" + decls + "}" for wrapper in reversed(at): text = f"{wrapper}{{{text}}}" return text def _token(self, scale: Mapping[str, str], var: str, value: Any, *, px: bool = True) -> str: if isinstance(value, bool): raise StyleError(f"unexpected boolean {value!r}") if isinstance(value, (int, float)): if value == 0: return "0" return f"{value:g}px" if px else f"{value:g}" if value in scale: return f"var(--rw-{var}-{value})" return _safe(value) def _property_table(self) -> dict[str, Callable[[Style, Any], list[tuple[str, str]]]]: theme = self.theme Handler = Callable[[Style, Any], list[tuple[str, str]]] # noqa: N806 def plain(css: str, mapping: Mapping[str, str] | None = None) -> Handler: def fn(_s: Style, v: Any) -> list[tuple[str, str]]: text = str(v) return [(css, mapping.get(text, text) if mapping else _safe(text))] return fn def length(css: str, scale: Mapping[str, str] | None = None, var: str = "space") -> Handler: def fn(_s: Style, v: Any) -> list[tuple[str, str]]: return [(css, self._token(scale or {}, var, v))] return fn def space(*props: str) -> Handler: def fn(_s: Style, v: Any) -> list[tuple[str, str]]: value = self._token(theme.space, "space", v) return [(p, value) for p in props] return fn def color(css: str) -> Handler: def fn(_s: Style, v: Any) -> list[tuple[str, str]]: if v in theme.color_names: return [(css, f"var(--rw-color-{v.replace('_', '-')})")] return [(css, _safe(str(v)))] return fn def border(css: str) -> Handler: def fn(_s: Style, v: Any) -> list[tuple[str, str]]: if v is True: return [(css, "1px solid var(--rw-color-border)")] if v is False: return [(css, "0")] return [(css, _safe(str(v)))] return fn def layout(s: Style, v: Any) -> list[tuple[str, str]]: if v not in ("vertical", "horizontal"): raise StyleError(f"layout must be 'vertical' or 'horizontal', not {v!r}") out = [] if s.display is not None else [("display", "flex")] return [*out, ("flex-direction", "column" if v == "vertical" else "row")] def wrap(_s: Style, v: Any) -> list[tuple[str, str]]: return [("flex-wrap", "wrap" if v is True else "nowrap" if v is False else _safe(str(v)))] def tracks(css: str) -> Handler: def fn(_s: Style, v: Any) -> list[tuple[str, str]]: if isinstance(v, int): return [(css, f"repeat({v},minmax(0,1fr))")] return [(css, _safe(str(v)))] return fn def span(_s: Style, v: Any) -> list[tuple[str, str]]: return [("grid-column", f"span {v}/span {v}" if isinstance(v, int) else _safe(str(v)))] def transition(_s: Style, v: Any) -> list[tuple[str, str]]: if v is True: return [("transition", _DEFAULT_TRANSITION)] return [("transition", "none" if v is False else _safe(str(v)))] def font_family(_s: Style, v: Any) -> list[tuple[str, str]]: if v in theme.fonts: return [("font-family", f"var(--rw-font-{v})")] return [("font-family", _safe(str(v)))] def shadow(_s: Style, v: Any) -> list[tuple[str, str]]: if v in theme.shadows: return [("box-shadow", f"var(--rw-shadow-{v})")] return [("box-shadow", _safe(str(v)))] table: dict[str, Handler] = { "display": plain("display"), "layout": layout, "direction": plain("flex-direction"), "wrap": wrap, "justify": plain("justify-content", _ALIGN), "align": plain("align-items", _ALIGN), "align_self": plain("align-self"), "gap": space("gap"), "row_gap": space("row-gap"), "column_gap": space("column-gap"), "grow": plain("flex-grow"), "shrink": plain("flex-shrink"), "basis": length("flex-basis"), "columns": tracks("grid-template-columns"), "rows": tracks("grid-template-rows"), "column_span": span, "position": plain("position"), "z_index": plain("z-index"), "overflow": plain("overflow"), "overflow_x": plain("overflow-x"), "overflow_y": plain("overflow-y"), "padding_x": space("padding-inline"), "padding_y": space("padding-block"), "margin_x": space("margin-inline"), "margin_y": space("margin-block"), "aspect_ratio": plain("aspect-ratio"), "font_family": font_family, "font_size": length("font-size", theme.font_size, "font-size"), "font_weight": plain("font-weight"), "line_height": plain("line-height"), "letter_spacing": length("letter-spacing"), "text_align": plain("text-align"), "text_transform": plain("text-transform"), "text_decoration": plain("text-decoration"), "white_space": plain("white-space"), "color": color("color"), "background": color("background"), "border_color": color("border-color"), "border_width": length("border-width"), "border_style": plain("border-style"), "border_radius": length("border-radius", theme.radius, "radius"), "outline": plain("outline"), "shadow": shadow, "opacity": plain("opacity"), "cursor": plain("cursor"), "transition": transition, "transform": plain("transform"), "pointer_events": plain("pointer-events"), "user_select": plain("user-select"), "list_style": plain("list-style"), "object_fit": plain("object-fit"), "container_type": plain("container-type"), "container_name": plain("container-name"), } for side in ("", "_top", "_right", "_bottom", "_left"): table[f"padding{side}"] = space(f"padding{side.replace('_', '-')}") table[f"margin{side}"] = space(f"margin{side.replace('_', '-')}") for prop in ("top", "right", "bottom", "left", "inset"): table[prop] = space(prop) for prop in ("width", "height", "min_width", "max_width", "min_height", "max_height"): table[prop] = length(prop.replace("_", "-")) for prop in ("border", "border_top", "border_right", "border_bottom", "border_left"): table[prop] = border(prop.replace("_", "-")) return table
def _safe(value: str) -> str: if UNSAFE_VALUE.search(value): # defense in depth; Style() validates on construction raise StyleError(f"unsafe characters in CSS value {value!r}") return value class StyleSheet: """Collects the rules a page uses; remembers what the browser already has.""" def __init__(self, compiler: StyleCompiler) -> None: self.compiler = compiler self._seen: set[str] = set() self._rules: list[str] = [] self._pending: list[str] = [] def use(self, style: Style) -> str: compiled = self.compiler.compile(style) if compiled.class_name not in self._seen: self._seen.add(compiled.class_name) self._rules.extend(compiled.rules) self._pending.extend(compiled.rules) return compiled.class_name def css(self) -> str: return "".join(self._rules) def drain(self) -> list[str]: """Rules added since the last drain (to ship with an event response).""" pending, self._pending = self._pending, [] return pending