From 4031657bb506e7280b9a007ca8f171c70ee380b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:16:59 +0000 Subject: [PATCH] Phase 2: convert primitives to nodes via a whitelist element factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic leaf builders (Div, Span, Td, Tr, Th, Ul, Li, Strong, Label, Template, P) are now generated from one _html_element factory over the single Element class — the tag name is data, not a per-tag body. Only elements that add classes/behaviour (Button, Pill, Checkbox, Radio, Input, A, SearchField, H1, Modal, AddForm, tables) stay hand-written. All primitives now return Node objects; string-built widgets (Icon, SimpleTable, YearPicker) return Safe, and YearPicker declares its datepicker media. Raw concatenation (_popover_html, Popover slot) uses Fragment. Node.__str__/__html__ now return a SafeString: a node's rendered output is safe HTML by construction, so str(node) stays safe when fed back into a child list or template (matching the old SafeText behaviour and preventing double-escaping). Consumers adapted: the form widgets (SearchSelectWidget, PrimitiveCheckboxWidget) return render(component) so Django gets a safe string; the session form's manual field markup joins via str(row). Component tests render nodes to HTML before asserting. https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK --- common/components/__init__.py | 6 + common/components/primitives.py | 279 +++++++++++++------------------- games/forms.py | 57 ++++--- games/views/session.py | 2 +- tests/test_components.py | 40 ++++- tests/test_search_select.py | 38 +++-- 6 files changed, 217 insertions(+), 205 deletions(-) diff --git a/common/components/__init__.py b/common/components/__init__.py index 5f1409a..da49499 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -73,6 +73,7 @@ from common.components.primitives import ( TableTd, Td, Template, + Th, Tr, Ul, YearPicker, @@ -131,6 +132,11 @@ __all__ = [ "Span", "StaticScript", "Label", + "Li", + "Td", + "Th", + "Tr", + "Ul", "TableHeader", "TableRow", "TableTd", diff --git a/common/components/primitives.py b/common/components/primitives.py index 0b41daf..407dda2 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -1,4 +1,11 @@ -"""Generic HTML primitives (no domain knowledge).""" +"""Generic HTML primitives (no domain knowledge). + +Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one +per tag: they are generated from a whitelist via :func:`_html_element`, each a +thin builder over the single :class:`Element` node class. Only elements that add +classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out. +Everything returns a :class:`Node`; string-built widgets return :class:`Safe`. +""" from django.middleware.csrf import get_token from django.templatetags.static import static @@ -6,7 +13,16 @@ from django.urls import reverse from django.utils.html import conditional_escape from django.utils.safestring import SafeText, mark_safe -from common.components.core import Component, HTMLAttribute, HTMLTag, randomid +from common.components.core import ( + Element, + Fragment, + HTMLAttribute, + HTMLTag, + Media, + Node, + Safe, + randomid, +) from common.icons import get_icon from common.utils import truncate @@ -27,18 +43,46 @@ _SIZE_CLASSES = { } +# ── Generic leaf elements ──────────────────────────────────────────────────── +# A whitelist of plain tags, each turned into a builder over `Element`. The +# tag name is data, not a separate class/function body. Add a tag = one line. + + +def _html_element(tag_name: str): + """Build a generic element builder for ``tag_name`` (the whitelist factory).""" + + def element( + attributes: list[HTMLAttribute] | None = None, + children: "list[HTMLTag] | HTMLTag | Node | None" = None, + ) -> Element: + return Element(tag_name, attributes, children) + + element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:] + element.__doc__ = f"Builder for the <{tag_name}> element." + return element + + +Div = _html_element("div") +P = _html_element("p") +Ul = _html_element("ul") +Li = _html_element("li") +Strong = _html_element("strong") +Span = _html_element("span") +Label = _html_element("label") +Template = _html_element("template") +Td = _html_element("td") +Tr = _html_element("tr") +Th = _html_element("th") + + def _popover_html( id: str, popover_content: str, wrapped_content: str = "", wrapped_classes: str = "", - slot: str = "", -) -> SafeText: - """Generate popover HTML using Component(tag_name=...). - - Single source of truth for popover HTML structure. - Used by Popover() and the python_popover template tag bridge. - """ + slot: "Node | str" = "", +) -> Node: + """Generate popover HTML. Single source of truth for popover structure.""" display_content = wrapped_content if wrapped_content else slot span = Span( @@ -79,7 +123,7 @@ def _popover_html( ], ) - return mark_safe(span + "\n" + div) + return Fragment(span, div, separator="\n") def Popover( @@ -89,14 +133,14 @@ def Popover( children: list[HTMLTag] | None = None, attributes: list[HTMLAttribute] | None = None, id: str = "", -) -> str: +) -> Node: children = children or [] if not wrapped_content and not children: raise ValueError("One of wrapped_content or children is required.") if not id: id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") - slot = mark_safe("\n".join(children)) + slot = Fragment(*children, separator="\n") if children else "" return _popover_html( id=id, popover_content=popover_content, @@ -113,7 +157,7 @@ def PopoverTruncated( length: int = 30, ellipsis: str = "…", endpart: str = "", -) -> str: +) -> "Node | str": """ Returns `input_string` truncated after `length` of characters and displays the untruncated text in a popover HTML element. @@ -143,7 +187,7 @@ def A( children: list[HTMLTag] | HTMLTag | None = None, url_name: str | None = None, href: str | None = None, -) -> SafeText: +) -> Element: """ Returns an anchor tag. @@ -161,8 +205,8 @@ def A( additional_attributes = [("href", reverse(url_name))] elif href is not None: additional_attributes = [("href", href)] - return Component( - tag_name="a", attributes=attributes + additional_attributes, children=children + return Element( + "a", attributes=attributes + additional_attributes, children=children ) @@ -179,7 +223,7 @@ def Button( title: str = "", onclick: str = "", name: str = "", -) -> SafeText: +) -> Element: attributes = attributes or [] children = children or [] @@ -224,8 +268,8 @@ def Button( button_attrs.append(("name", name)) button_attrs.extend(other_attrs) - return Component( - tag_name="button", + return Element( + "button", attributes=button_attrs, children=children, ) @@ -267,7 +311,7 @@ def _button_group_button( title: str = "", hx_get: str = "", hx_target: str = "", -) -> SafeText: +) -> Element: """Generate a single button-group button (inner