diff --git a/common/components/__init__.py b/common/components/__init__.py index 6347e9c..5f1409a 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -5,11 +5,19 @@ re-exports the public API so ``from common.components import X`` keeps working. """ from common.components.core import ( + BaseComponent, Component, + Element, + Fragment, HTMLAttribute, HTMLTag, + Media, + Node, + Safe, _render_element, + collect_media, randomid, + render, ) from common.components.date_range_picker import ( DateRangeCalendar, @@ -82,7 +90,15 @@ from common.utils import truncate __all__ = [ "truncate", + "BaseComponent", "Component", + "Element", + "Fragment", + "Media", + "Node", + "Safe", + "collect_media", + "render", "HTMLAttribute", "HTMLTag", "_render_element", diff --git a/common/components/core.py b/common/components/core.py index 3ec159b..864afd0 100644 --- a/common/components/core.py +++ b/common/components/core.py @@ -1,4 +1,22 @@ -"""Escaping core: the Component builder and its memoised renderer.""" +"""Node layer: the lazy component tree, its renderer, and media collection. + +A FastHTML-style model. Everything renderable is a :class:`Node`. The single +:class:`Element` class represents *any* HTML element (tag + attrs + children); +named builders like ``Div`` / ``Span`` are generated from a whitelist rather +than hand-written per tag (see ``primitives.py``). Higher-level, behaviour- or +media-bearing components subclass :class:`BaseComponent` and implement +``render()`` returning a node subtree. + +Nodes are *lazy*: they hold structure and render to HTML only when asked +(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets +``Page()`` walk a finished tree and collect every component's declared JS +(:class:`Media`) instead of each view threading ``scripts=`` by hand. + +Backwards compatibility: the legacy ``Component(tag_name=...)`` function still +returns a ``SafeText`` string, so existing string-based call sites keep working +during the migration. Its child handling is Node-aware, so a tree mixing old +(string-returning) and new (node-returning) components renders correctly. +""" import hashlib from functools import lru_cache @@ -13,21 +31,121 @@ HTMLAttribute = tuple[str, str | int | bool] HTMLTag = str +# ── Media: declarative JS dependencies ────────────────────────────────────── + + +def _dedup(*sequences: tuple[str, ...]) -> tuple[str, ...]: + """First-seen dedup that preserves declaration order across sequences.""" + seen: dict[str, None] = {} + for sequence in sequences: + for item in sequence: + seen.setdefault(item, None) + return tuple(seen) + + +class Media: + """A component's JS dependencies, modelled on ``django.forms.Media``. + + ``js`` are static ES-module filenames (rendered as ``ModuleScript``); + ``js_external`` are vendored UMD / classic bundles (rendered as + ``StaticScript``). Addition merges with first-seen, order-preserving dedup, + so a page that uses a component many times emits each script once. + """ + + __slots__ = ("js", "js_external") + + def __init__( + self, + js: tuple[str, ...] | list[str] = (), + js_external: tuple[str, ...] | list[str] = (), + ) -> None: + self.js = tuple(js) + self.js_external = tuple(js_external) + + def __add__(self, other: "Media | None") -> "Media": + if not other: + return self + return Media( + _dedup(self.js, other.js), + _dedup(self.js_external, other.js_external), + ) + + def __radd__(self, other: "Media | None") -> "Media": + # Supports ``sum(medias, Media())`` and ``0 + media``. + if not other or other == 0: + return self + return other.__add__(self) + + def __bool__(self) -> bool: + return bool(self.js or self.js_external) + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, Media) + and self.js == other.js + and self.js_external == other.js_external + ) + + def __hash__(self) -> int: + return hash((self.js, self.js_external)) + + def __repr__(self) -> str: + return f"Media(js={self.js!r}, js_external={self.js_external!r})" + + +# ── Node tree ──────────────────────────────────────────────────────────────── + + +class Node: + """Base class for everything renderable to HTML.""" + + # Declared dependencies. Class-level default is shared and empty; concrete + # components override with their own ``Media(...)``. + media: Media = Media() + + def _render(self) -> str: + raise NotImplementedError + + def collect_media(self) -> Media: + """Total media of this node and its subtree.""" + return self.media + + # `__html__` marks the value HTML-safe for Django (conditional_escape). + # `__str__` lets f-strings, ``str()`` and ``"".join(str(...))`` render the + # node — the bridge that keeps most existing composition working. + def __html__(self) -> str: + return self._render() + + def __str__(self) -> str: + return self._render() + + +def _child_key(child: object) -> tuple[str, bool]: + """Normalise a child to a ``(text, is_safe)`` pair. + + Nodes render to safe HTML; ``SafeText`` (and anything exposing ``__html__``) + is already safe; plain strings are escaped. ``is_safe`` is part of the + render cache key so a safe ``""`` and an unsafe ``""`` never collide. + """ + if isinstance(child, Node): + return (child._render(), True) + if isinstance(child, str): + return (child, isinstance(child, SafeText)) + if hasattr(child, "__html__"): + return (child.__html__(), True) + return (str(child), False) + + @lru_cache(maxsize=4096) def _render_element( tag_name: str, attrs_key: tuple[tuple[str, str], ...], children_key: tuple[tuple[str, bool], ...], ) -> str: - """Pure, memoized HTML builder behind `Component`. + """Pure, memoized HTML builder. Identical (tag, attrs, children) render once. - Inputs are fully hashable and fully determine the output, so identical - elements are rendered once. `attrs_key` is (name, stringified value) pairs - (attribute values are always escaped). `children_key` is (child, is_safe) - pairs: SafeText children pass through, plain strings are escaped. The - `is_safe` flag is part of the key on purpose — otherwise a safe ``""`` - and an unsafe ``""`` (equal as strings) would collide and one would - render with the wrong escaping. + ``attrs_key`` is (name, stringified value) pairs (values always escaped); + ``children_key`` is (text, is_safe) pairs (safe passes through, else escaped). """ children_blob = "\n".join( child if is_safe else escape(child) for child, is_safe in children_key @@ -41,24 +159,136 @@ def _render_element( return f"<{tag_name}{attributes_blob}>{children_blob}" +class Element(Node): + """Any HTML element: a tag name, attributes and children. + + Children may be other nodes, ``SafeText``, or plain strings (escaped). + Rendering goes through the memoized :func:`_render_element`. + """ + + def __init__( + self, + tag_name: str, + attributes: list[HTMLAttribute] | None = None, + children: "list | Node | str | None" = None, + ) -> None: + if not tag_name: + raise ValueError("tag_name is required.") + self.tag_name = tag_name + self.attributes = attributes or [] + if children is None: + children = [] + elif isinstance(children, (str, Node)): + children = [children] + self.children = children + + def collect_media(self) -> Media: + media = self.media + for child in self.children: + if isinstance(child, Node): + media = media + child.collect_media() + return media + + def _render(self) -> str: + attrs_key = tuple((name, str(value)) for name, value in self.attributes) + children_key = tuple(_child_key(child) for child in self.children) + return _render_element(self.tag_name, attrs_key, children_key) + + +class Safe(Node): + """A node wrapping pre-rendered, trusted HTML (the ``mark_safe`` analogue). + + Used as the migration bridge for components still built from f-strings: + they return ``Safe(html)`` and declare their ``media`` explicitly rather + than atomising their markup into a node tree up front. + """ + + def __init__(self, html: object, media: Media | None = None) -> None: + self._html = str(html) + if media is not None: + self.media = media + + def _render(self) -> str: + return self._html + + +class Fragment(Node): + """An ordered group of children with no wrapping tag. + + Replaces ``mark_safe(str(a) + str(b))`` / ``"\\n".join(...)`` composition, + so media still bubbles up from the grouped children. + """ + + def __init__(self, *children: object, separator: str = "") -> None: + self.children = [c for c in children if c is not None and c != ""] + self.separator = separator + + def collect_media(self) -> Media: + media = Media() + for child in self.children: + if isinstance(child, Node): + media = media + child.collect_media() + return media + + def _render(self) -> str: + parts = [] + for child in self.children: + text, is_safe = _child_key(child) + parts.append(text if is_safe else escape(text)) + return self.separator.join(parts) + + +class BaseComponent(Node): + """Base for higher-level components: implement ``render()`` returning a node + subtree and declare ``media`` (a :class:`Media`). + + ``render()`` is called once and memoized; ``collect_media()`` returns this + component's own media merged with the rendered subtree's. + """ + + def render(self) -> Node: + raise NotImplementedError + + def _tree(self) -> Node: + cached = getattr(self, "_tree_cache", None) + if cached is None: + cached = self.render() + self._tree_cache = cached + return cached + + def _render(self) -> str: + return self._tree()._render() + + def collect_media(self) -> Media: + return self.media + self._tree().collect_media() + + +def render(node: "Node | str") -> SafeText: + """Render a node (or pass a string through) to safe HTML.""" + if isinstance(node, Node): + return mark_safe(node._render()) + return mark_safe(str(node)) + + +def collect_media(node: "Node | str") -> Media: + """Collect the media of a node tree (empty for a bare string).""" + if isinstance(node, Node): + return node.collect_media() + return Media() + + def Component( attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, + children: "list[HTMLTag] | HTMLTag | None" = None, tag_name: str = "", ) -> SafeText: - """Render an HTML element. Attribute values are always escaped; children are - escaped unless they are `SafeText` (so nested components pass through), - preventing accidental HTML injection. Rendering is memoized via - `_render_element`.""" - attributes = attributes or [] - children = children or [] - if not tag_name: - raise ValueError("tag_name is required.") - if isinstance(children, str): - children = [children] - attrs_key = tuple((name, str(value)) for name, value in attributes) - children_key = tuple((child, isinstance(child, SafeText)) for child in children) - return mark_safe(_render_element(tag_name, attrs_key, children_key)) + """Legacy element builder: returns a ``SafeText`` string. + + Kept for backwards compatibility while call sites migrate to :class:`Element` + and the generated tag builders. Child handling is Node-aware, so a tree that + mixes string-returning and node-returning components still renders correctly. + """ + return render(Element(tag_name, attributes, children)) def randomid(seed: str = "", content: str = "", length: int = 10) -> str: diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py new file mode 100644 index 0000000..122b24a --- /dev/null +++ b/tests/test_node_tree.py @@ -0,0 +1,137 @@ +"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media). + +These cover the new machinery directly and assert byte-for-byte parity between +``Element`` and the legacy ``Component()`` shim, so the migration of call sites +in later phases can rely on identical output. +""" + +import unittest + +from django.utils.safestring import SafeText, mark_safe + +from common.components import ( + BaseComponent, + Component, + Element, + Fragment, + Media, + Node, + Safe, + collect_media, + render, +) + + +class ElementRenderTest(unittest.TestCase): + def test_matches_legacy_component(self): + element = Element("div", [("class", "test")], "hello") + legacy = Component( + tag_name="div", attributes=[("class", "test")], children="hello" + ) + self.assertEqual(render(element), legacy) + self.assertEqual(render(element), '
hello
') + + def test_plain_string_children_escaped(self): + self.assertEqual( + render(Element("span", children=[""])), "<b>" + ) + + def test_safe_children_pass_through(self): + self.assertEqual( + render(Element("span", children=[mark_safe("x")])), + "x", + ) + + def test_node_children_render_safely(self): + inner = Element("b", children=["x"]) + self.assertEqual( + render(Element("span", children=[inner])), "x" + ) + + def test_legacy_component_renders_node_children(self): + # The compatibility bridge: a string-returning legacy Component must + # render nested Node children as HTML, not escape them. + inner = Element("b", children=["x"]) + result = Component(tag_name="span", children=[inner]) + self.assertEqual(result, "x") + self.assertIsInstance(result, SafeText) + + +class SafeAndFragmentTest(unittest.TestCase): + def test_safe_passes_html_through(self): + self.assertEqual(render(Safe("raw")), "raw") + + def test_fragment_concatenates(self): + frag = Fragment( + Element("span", children=["a"]), Element("span", children=["b"]) + ) + self.assertEqual(render(frag), "ab") + + def test_fragment_skips_empty_children(self): + frag = Fragment("", None, Element("span", children=["a"])) + self.assertEqual(render(frag), "a") + + def test_fragment_escapes_plain_strings(self): + self.assertEqual(render(Fragment("", Safe(""))), "<x>") + + +class MediaTest(unittest.TestCase): + def test_merge_dedups_preserving_order(self): + merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"]) + self.assertEqual(merged.js, ("a.js", "b.js", "c.js")) + + def test_external_kept_separate(self): + merged = Media(js=["a.js"]) + Media(js_external=["umd.js"]) + self.assertEqual(merged.js, ("a.js",)) + self.assertEqual(merged.js_external, ("umd.js",)) + + def test_sum_with_radd(self): + merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media()) + self.assertEqual(merged.js, ("a.js", "b.js")) + + def test_falsy_when_empty(self): + self.assertFalse(Media()) + self.assertTrue(Media(js=["a.js"])) + + +class MediaCollectionTest(unittest.TestCase): + def test_bubbles_through_element_children(self): + class Widget(BaseComponent): + media = Media(js=["widget.js"]) + + def render(self) -> Node: + return Element("div", children=["x"]) + + tree = Element("section", children=[Element("div", children=[Widget()])]) + self.assertEqual(collect_media(tree).js, ("widget.js",)) + + def test_bubbles_through_fragment(self): + class Widget(BaseComponent): + media = Media(js=["w.js"]) + + def render(self) -> Node: + return Element("div") + + self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",)) + + def test_component_merges_own_and_subtree_media(self): + class Inner(BaseComponent): + media = Media(js=["inner.js"]) + + def render(self) -> Node: + return Element("span") + + class Outer(BaseComponent): + media = Media(js=["outer.js"]) + + def render(self) -> Node: + return Element("div", children=[Inner()]) + + self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js")) + + def test_bare_string_has_no_media(self): + self.assertFalse(collect_media("just a string")) + + +if __name__ == "__main__": + unittest.main()