Source code for rational.rendering.nodes

"""Immutable intermediate representation of rendered markup.

A tree built by components is *unresolved*: elements may still carry a
``Style`` and event bindings, and may contain ``Component`` instances as
children. The renderer turns it into a *resolved* tree in which styles are
class names, events are ``data-rw-*`` attributes and components are expanded.
Only resolved trees are serialized.
"""

from __future__ import annotations

import functools
import re
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeAlias

if TYPE_CHECKING:
    from ..events import ClientOp
    from ..styling.style import Style

AttrValue: TypeAlias = str | int | float | bool | None

EVENT_NAMES = frozenset({"click", "input", "change", "submit", "keydown", "enter", "blur"})

_TAG_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9-]*$")
_ATTR_RE = re.compile(r"^[a-zA-Z_:][-a-zA-Z0-9_:.]*$")
_FORBIDDEN_TAGS = frozenset({"script", "style"})
_URL_ATTRS = frozenset({"href", "src", "action", "formaction", "poster", "cite", "xlink:href"})
_UNSAFE_SCHEMES = ("javascript:", "vbscript:", "data:")
_CONTROL_RE = re.compile(r"[\x00-\x20\x7f]+")


class UnsafeMarkupError(ValueError):
    """Raised when markup would allow script injection."""


@dataclass(frozen=True, slots=True)
class TextNode:
    text: str


@dataclass(frozen=True, slots=True)
class RawHTML:
    """Pre-built markup that bypasses escaping. Create it only via ``unsafe_html``."""

    html: str


@dataclass(frozen=True, slots=True)
class Node:
    tag: str
    attrs: tuple[tuple[str, AttrValue], ...] = ()
    children: tuple[Any, ...] = ()
    key: str | None = None
    style: Style | None = None
    events: tuple[tuple[str, Callable[..., Any]], ...] = ()
    client: tuple[tuple[str, tuple[ClientOp, ...]], ...] = field(default=())

    def __post_init__(self) -> None:
        validate_tag(self.tag)
        for name, value in self.attrs:
            validate_attr(name, value)

    def attr(self, name: str) -> AttrValue:
        for n, v in self.attrs:
            if n == name:
                return v
        return None


[docs] def unsafe_html(html: str) -> RawHTML: """Insert ``html`` verbatim. The caller is responsible for sanitizing it.""" return RawHTML(html)
@functools.lru_cache(maxsize=512) def validate_tag(tag: str) -> None: # Cached: an app uses a small set of tag names, and only valid ones get cached (errors raise). if not _TAG_RE.match(tag): raise UnsafeMarkupError(f"invalid tag name {tag!r}") if tag.lower() in _FORBIDDEN_TAGS: raise UnsafeMarkupError(f"<{tag}> elements are not allowed; use unsafe_html() deliberately") def validate_attr(name: str, value: AttrValue) -> None: if _is_url_attr(name) and isinstance(value, str): check_url(value) @functools.lru_cache(maxsize=1024) def _is_url_attr(name: str) -> bool: """Validate an attribute *name* (cached) and report whether its value is a URL.""" if not _ATTR_RE.match(name): raise UnsafeMarkupError(f"invalid attribute name {name!r}") if name.lower().startswith("on"): raise UnsafeMarkupError(f"inline handler attribute {name!r} is not allowed; pass on_<event>=handler instead") return name.lower() in _URL_ATTRS def check_url(value: str) -> None: normalized = _CONTROL_RE.sub("", value).lower() if normalized.startswith("data:image/") and not normalized.startswith("data:image/svg"): return if normalized.startswith(_UNSAFE_SCHEMES): raise UnsafeMarkupError(f"unsafe URL scheme in {value[:40]!r}") def flatten(items: Iterable[Any]) -> list[Any]: """Flatten nested lists/tuples and drop ``None``/``False``; numbers become strings.""" out: list[Any] = [] for item in items: if item is None or item is False or item is True: continue if isinstance(item, (list, tuple, Iterator)): out.extend(flatten(item)) elif isinstance(item, (int, float)): out.append(str(item)) else: out.append(item) return out