Files
rose-ash/shared/sx/html.py
giles f9f810ffd7 Complete Python eval removal: epoch protocol, scope consolidation, JIT fixes
Route all rendering through OCaml bridge — render_to_html no longer uses
Python async_eval. Fix register_components to parse &key params and &rest
children from defcomp forms. Remove all dead sx_ref.py imports.

Epoch protocol (prevents pipe desync):
- Every command prefixed with (epoch N), all responses tagged with epoch
- Both sides discard stale-epoch messages — desync structurally impossible
- OCaml main loop discards stale io-responses between commands

Consolidate scope primitives into sx_scope.ml:
- Single source of truth for scope-push!/pop!/peek, collect!/collected,
  emit!/emitted, context, and 12 other scope operations
- Removes duplicate registrations from sx_server.ml (including bugs where
  scope-emit! and clear-collected! were registered twice with different impls)
- Bind scope prims into env so JIT VM finds them via OP_GLOBAL_GET

JIT VM fixes:
- Trampoline thunks before passing args to CALL_PRIM
- as_list resolves thunks via _sx_trampoline_fn
- len handles all value types (Bool, Number, RawHTML, SxExpr, Spread, etc.)

Other fixes:
- ~cssx/tw signature: (tokens) → (&key tokens) to match callers
- Minimal Python evaluator in html.py for sync sx() Jinja function
- Python scope primitive stubs (thread-local) for non-OCaml paths
- Reader macro resolution via OcamlSync instead of sx_ref.py

Tests: 1114 OCaml, 1078 JS, 35 Python regression, 6/6 Playwright SSR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:14:40 +00:00

825 lines
27 KiB
Python

"""
HSX-style HTML renderer.
Walks an s-expression tree and emits an HTML string. HTML elements are
recognised by tag name; everything else is evaluated via the s-expression
evaluator and then rendered recursively.
Usage::
from shared.sx import parse, make_env
from shared.sx.html import render
expr = parse('(div :class "card" (h1 "Hello") (p "World"))')
html = render(expr)
# → '<div class="card"><h1>Hello</h1><p>World</p></div>'
Components defined with ``defcomp`` are evaluated and their result is
rendered as HTML::
env = {}
evaluate(parse('(defcomp ~card (&key title &rest children) ...)'), env)
html = render(parse('(~card :title "Hi" (p "body"))'), env)
"""
from __future__ import annotations
import contextvars
from typing import Any
from .types import Component, Island, Keyword, Lambda, Macro, NIL, Symbol
def _eval(expr, env):
"""Minimal Python evaluator for sync html.py rendering.
Handles: literals, symbols, keywords, dicts, special forms (if, when,
cond, let, begin/do, and, or, str, not, list), lambda calls, and
primitive function calls. Enough for the sync sx() Jinja function.
"""
from .primitives import _PRIMITIVES
# Literals
if isinstance(expr, (int, float, str, bool)):
return expr
if expr is None or expr is NIL:
return NIL
# Symbol lookup
if isinstance(expr, Symbol):
name = expr.name
if name in env:
return env[name]
if name in _PRIMITIVES:
return _PRIMITIVES[name]
if name == "true":
return True
if name == "false":
return False
if name == "nil":
return NIL
from .types import EvalError
raise EvalError(f"Undefined symbol: {name}")
# Keyword
if isinstance(expr, Keyword):
return expr.name
# Dict
if isinstance(expr, dict):
return {k: _eval(v, env) for k, v in expr.items()}
# List — dispatch
if not isinstance(expr, list):
return expr
if not expr:
return []
head = expr[0]
if isinstance(head, Symbol):
name = head.name
# Special forms
if name == "if":
cond = _eval(expr[1], env)
if cond and cond is not NIL:
return _eval(expr[2], env)
return _eval(expr[3], env) if len(expr) > 3 else NIL
if name == "when":
cond = _eval(expr[1], env)
if cond and cond is not NIL:
result = NIL
for body in expr[2:]:
result = _eval(body, env)
return result
return NIL
if name == "let":
bindings = expr[1]
local = dict(env)
if isinstance(bindings, list):
if bindings and isinstance(bindings[0], list):
for b in bindings:
vname = b[0].name if isinstance(b[0], Symbol) else b[0]
local[vname] = _eval(b[1], local)
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
vname = bindings[i].name if isinstance(bindings[i], Symbol) else bindings[i]
local[vname] = _eval(bindings[i + 1], local)
result = NIL
for body in expr[2:]:
result = _eval(body, local)
return result
if name in ("begin", "do"):
result = NIL
for body in expr[1:]:
result = _eval(body, env)
return result
if name == "and":
result = True
for arg in expr[1:]:
result = _eval(arg, env)
if not result or result is NIL:
return result
return result
if name == "or":
for arg in expr[1:]:
result = _eval(arg, env)
if result and result is not NIL:
return result
return NIL
if name == "not":
val = _eval(expr[1], env)
return val is NIL or val is False or val is None
if name == "lambda" or name == "fn":
params_form = expr[1]
param_names = [p.name if isinstance(p, Symbol) else str(p) for p in params_form]
return Lambda(params=param_names, body=expr[2], closure=dict(env))
if name == "define":
var_name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
env[var_name] = _eval(expr[2], env)
return NIL
if name == "quote":
return expr[1]
if name == "str":
parts = []
for arg in expr[1:]:
val = _eval(arg, env)
if val is NIL or val is None:
parts.append("")
else:
parts.append(str(val))
return "".join(parts)
if name == "list":
return [_eval(arg, env) for arg in expr[1:]]
# Primitive or function call
fn = _eval(head, env)
else:
fn = _eval(head, env)
# Evaluate args
args = [_eval(a, env) for a in expr[1:]]
# Call
if callable(fn):
return fn(*args)
if isinstance(fn, Lambda):
local = dict(fn.closure)
local.update(env)
for p, v in zip(fn.params, args):
local[p] = v
return _eval(fn.body, local)
return NIL
def _expand_macro(*a, **kw):
raise RuntimeError("Macro expansion requires OCaml bridge")
# ContextVar for collecting CSS class names during render.
# Set to a set[str] to collect; None to skip.
css_class_collector: contextvars.ContextVar[set[str] | None] = contextvars.ContextVar(
"css_class_collector", default=None
)
# ContextVar for SVG/MathML namespace auto-detection.
# When True, unknown tag names inside (svg ...) or (math ...) are treated as elements.
_svg_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_svg_context", default=False
)
class _RawHTML:
"""Marker for pre-rendered HTML that should not be escaped."""
__slots__ = ("html",)
def __init__(self, html: str):
self.html = html
# ---------------------------------------------------------------------------
# HTML constants
# ---------------------------------------------------------------------------
# Tags that must not have a closing tag
VOID_ELEMENTS = frozenset({
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
})
# Standard HTML tags (subset — any symbol that isn't recognised here will be
# treated as a function call and evaluated instead of rendered as a tag).
HTML_TAGS = frozenset({
# Root / document
"html", "head", "body",
# Metadata
"title", "meta", "link", "style", "script", "base", "noscript",
# Sections
"header", "footer", "main", "nav", "aside", "section", "article",
"address", "hgroup",
# Headings
"h1", "h2", "h3", "h4", "h5", "h6",
# Grouping
"div", "p", "blockquote", "pre", "figure", "figcaption",
"ul", "ol", "li", "dl", "dt", "dd", "hr",
# Text
"a", "span", "em", "strong", "small", "s", "cite", "q",
"abbr", "code", "var", "samp", "kbd", "sub", "sup",
"i", "b", "u", "mark", "ruby", "rt", "rp",
"bdi", "bdo", "br", "wbr", "time", "data",
# Edits
"ins", "del",
# Embedded
"img", "picture", "source", "iframe", "embed", "object", "param",
"video", "audio", "track", "canvas", "map", "area",
"svg", "math",
# SVG child elements
"path", "circle", "ellipse", "line", "polygon", "polyline", "rect",
"g", "defs", "use", "text", "tspan", "clipPath", "mask",
"linearGradient", "radialGradient", "stop", "filter",
"feGaussianBlur", "feOffset", "feMerge", "feMergeNode",
"feTurbulence", "feColorMatrix", "feBlend",
"feComponentTransfer", "feFuncR", "feFuncG", "feFuncB", "feFuncA",
"feDisplacementMap", "feComposite", "feFlood", "feImage",
"feMorphology", "feSpecularLighting", "feDiffuseLighting",
"fePointLight", "feSpotLight", "feDistantLight",
"animate", "animateTransform",
# Table
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
"caption", "colgroup", "col",
# Forms
"form", "fieldset", "legend", "label", "input", "button",
"select", "option", "optgroup", "textarea", "output",
"datalist", "progress", "meter",
# Interactive
"details", "summary", "dialog",
# Template
"template", "slot",
})
# Attributes that are boolean (presence = true, absence = false)
BOOLEAN_ATTRS = frozenset({
"async", "autofocus", "autoplay", "checked", "controls",
"default", "defer", "disabled", "formnovalidate", "hidden",
"inert", "ismap", "loop", "multiple", "muted", "nomodule",
"novalidate", "open", "playsinline", "readonly", "required",
"reversed", "selected",
})
# ---------------------------------------------------------------------------
# Escaping
# ---------------------------------------------------------------------------
def escape_text(s: str) -> str:
"""Escape text content for safe HTML embedding."""
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def escape_attr(s: str) -> str:
"""Escape an attribute value for safe embedding in double quotes."""
return (
s.replace("&", "&amp;")
.replace('"', "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
# ---------------------------------------------------------------------------
# Renderer
# ---------------------------------------------------------------------------
def render(expr: Any, env: dict[str, Any] | None = None) -> str:
"""Render an s-expression as an HTML string.
*expr* can be:
- A parsed (unevaluated) s-expression from ``parse()``
- An already-evaluated value (string, list, etc.)
*env* provides variable bindings for evaluation.
"""
if env is None:
env = {}
return _render(expr, env)
def _render(expr: Any, env: dict[str, Any]) -> str:
# --- nil / None / False → empty string --------------------------------
if expr is None or expr is NIL or expr is False:
return ""
# --- True → empty (typically from a boolean expression, not content) ---
if expr is True:
return ""
# --- pre-rendered HTML → pass through ----------------------------------
if isinstance(expr, _RawHTML):
return expr.html
# --- string → escaped text --------------------------------------------
if isinstance(expr, str):
return escape_text(expr)
# --- number → string --------------------------------------------------
if isinstance(expr, (int, float)):
return escape_text(str(expr))
# --- symbol → evaluate then render ------------------------------------
if isinstance(expr, Symbol):
val = _eval(expr, env)
return _render(val, env)
# --- keyword → its name (unlikely in render context, but safe) --------
if isinstance(expr, Keyword):
return escape_text(expr.name)
# --- list → main dispatch ---------------------------------------------
if isinstance(expr, list):
if not expr:
return ""
return _render_list(expr, env)
# --- dict → skip (data, not renderable as HTML content) -----------------
if isinstance(expr, dict):
return ""
# --- fallback ---------------------------------------------------------
return escape_text(str(expr))
# ---------------------------------------------------------------------------
# Render-aware special forms
# ---------------------------------------------------------------------------
# These mirror the evaluator's special forms but call _render on the result
# branches, so that HTML tags inside (if ...), (when ...), (let ...) etc.
# are rendered correctly instead of being evaluated as function calls.
def _rsf_if(expr: list, env: dict[str, Any]) -> str:
cond = _eval(expr[1], env)
if cond and cond is not NIL:
return _render(expr[2], env)
if len(expr) > 3:
return _render(expr[3], env)
return ""
def _rsf_when(expr: list, env: dict[str, Any]) -> str:
cond = _eval(expr[1], env)
if cond and cond is not NIL:
parts = []
for body_expr in expr[2:]:
parts.append(_render(body_expr, env))
return "".join(parts)
return ""
def _rsf_cond(expr: list, env: dict[str, Any]) -> str:
from .types import Keyword as Kw
clauses = expr[1:]
if not clauses:
return ""
# Scheme-style: ((test body) ...)
if isinstance(clauses[0], list) and len(clauses[0]) == 2:
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return _render(clause[1], env)
if isinstance(test, Kw) and test.name == "else":
return _render(clause[1], env)
if _eval(test, env):
return _render(clause[1], env)
else:
# Clojure-style: test body test body ...
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Kw) and test.name == "else":
return _render(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _render(result, env)
if _eval(test, env):
return _render(result, env)
i += 2
return ""
def _rsf_let(expr: list, env: dict[str, Any]) -> str:
bindings = expr[1]
local = dict(env)
if isinstance(bindings, list):
if bindings and isinstance(bindings[0], list):
for binding in bindings:
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(binding[1], local)
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(bindings[i + 1], local)
parts = []
for body_expr in expr[2:]:
parts.append(_render(body_expr, local))
return "".join(parts)
def _rsf_begin(expr: list, env: dict[str, Any]) -> str:
parts = []
for sub in expr[1:]:
parts.append(_render(sub, env))
return "".join(parts)
def _rsf_define(expr: list, env: dict[str, Any]) -> str:
_eval(expr, env) # side effect: define in env
return ""
def _rsf_defcomp(expr: list, env: dict[str, Any]) -> str:
_eval(expr, env) # side effect: register component
return ""
def _render_lambda_call(fn: Lambda, args: tuple, env: dict[str, Any]) -> str:
"""Call a lambda and render the result — the body may contain HTML tags."""
local = dict(fn.closure)
local.update(env)
for p, v in zip(fn.params, args):
local[p] = v
return _render(fn.body, local)
def _rsf_map(expr: list, env: dict[str, Any]) -> str:
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(_render_lambda_call(fn, (item,), env))
elif callable(fn):
parts.append(_render(fn(item), env))
else:
parts.append(_render(item, env))
return "".join(parts)
def _rsf_map_indexed(expr: list, env: dict[str, Any]) -> str:
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
parts = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
parts.append(_render_lambda_call(fn, (i, item), env))
elif callable(fn):
parts.append(_render(fn(i, item), env))
else:
parts.append(_render(item, env))
return "".join(parts)
def _rsf_filter(expr: list, env: dict[str, Any]) -> str:
# filter returns a list — render each kept item
result = _eval(expr, env)
return _render(result, env)
def _rsf_for_each(expr: list, env: dict[str, Any]) -> str:
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(_render_lambda_call(fn, (item,), env))
elif callable(fn):
parts.append(_render(fn(item), env))
else:
parts.append(_render(item, env))
return "".join(parts)
_RENDER_FORMS: dict[str, Any] = {
"if": _rsf_if,
"when": _rsf_when,
"cond": _rsf_cond,
"let": _rsf_let,
"let*": _rsf_let,
"begin": _rsf_begin,
"do": _rsf_begin,
"define": _rsf_define,
"defcomp": _rsf_defcomp,
"map": _rsf_map,
"map-indexed": _rsf_map_indexed,
"filter": _rsf_filter,
"for-each": _rsf_for_each,
"defmacro": _rsf_define, # side-effect only, returns ""
"defhandler": _rsf_define, # side-effect only, returns ""
}
def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
"""Render-aware component call: sets up scope then renders the body."""
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = _eval(args[i + 1], env)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
if p in kwargs:
local[p] = kwargs[p]
else:
local[p] = NIL
if comp.has_children:
# Render children to HTML and wrap as _RawHTML to prevent re-escaping
local["children"] = _RawHTML("".join(_render(c, env) for c in children))
return _render(comp.body, local)
def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
"""Render an island as static HTML with hydration attributes.
Produces: <span data-sx-island="name" data-sx-state="{:k &quot;v&quot;}">body HTML</span>
The client hydrates this into a reactive island via sx-parse (not JSON).
"""
from .parser import serialize as _sx_serialize
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = _eval(args[i + 1], env)
i += 2
else:
children.append(arg)
i += 1
local = dict(island.closure)
local.update(env)
for p in island.params:
if p in kwargs:
local[p] = kwargs[p]
else:
local[p] = NIL
if island.has_children:
local["children"] = _RawHTML("".join(_render(c, env) for c in children))
body_html = _render(island.body, local)
# Serialize state for hydration — SX format (not JSON)
state_sx = _escape_attr(_sx_serialize(kwargs)) if kwargs else ""
island_name = _escape_attr(island.name)
parts = [f'<span data-sx-island="{island_name}"']
if state_sx:
parts.append(f' data-sx-state="{state_sx}"')
parts.append(">")
parts.append(body_html)
parts.append("</span>")
return "".join(parts)
def _render_lake(args: list, env: dict[str, Any]) -> str:
"""Render a server-morphable lake slot.
(lake :id "name" :tag "div" children...)
→ <div data-sx-lake="name">children</div>
Lakes are server territory inside reactive islands. During morph,
the server can update lake content while surrounding reactive DOM
is preserved.
"""
lake_id = ""
lake_tag = "div"
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kname = arg.name
kval = _eval(args[i + 1], env)
if kname == "id":
lake_id = str(kval) if kval is not None and kval is not NIL else ""
elif kname == "tag":
lake_tag = str(kval) if kval is not None and kval is not NIL else "div"
i += 2
else:
children.append(arg)
i += 1
body = "".join(_render(c, env) for c in children)
return f'<{lake_tag} data-sx-lake="{_escape_attr(lake_id)}">{body}</{lake_tag}>'
def _render_marsh(args: list, env: dict[str, Any]) -> str:
"""Render a reactive server-morphable marsh slot.
(marsh :id "name" :tag "div" :transform fn children...)
→ <div data-sx-marsh="name">children</div>
Marshes are zones where reactivity and hypermedia interpenetrate.
Like lakes but content is parsed as SX on the client and re-evaluated
in the island's signal scope. :transform is consumed but not used
server-side (it's a client-side concern).
"""
marsh_id = ""
marsh_tag = "div"
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kname = arg.name
kval = _eval(args[i + 1], env)
if kname == "id":
marsh_id = str(kval) if kval is not None and kval is not NIL else ""
elif kname == "tag":
marsh_tag = str(kval) if kval is not None and kval is not NIL else "div"
elif kname == "transform":
pass # Client-side only; skip
i += 2
else:
children.append(arg)
i += 1
body = "".join(_render(c, env) for c in children)
return f'<{marsh_tag} data-sx-marsh="{_escape_attr(marsh_id)}">{body}</{marsh_tag}>'
def _render_list(expr: list, env: dict[str, Any]) -> str:
"""Render a list expression — could be an HTML element, special form,
component call, or data list."""
head = expr[0]
if isinstance(head, Symbol):
name = head.name
# --- raw! → unescaped HTML ----------------------------------------
if name == "raw!":
parts = []
for arg in expr[1:]:
val = _eval(arg, env)
if isinstance(val, _RawHTML):
parts.append(val.html)
elif isinstance(val, str):
parts.append(val)
elif val is not None and val is not NIL:
parts.append(str(val))
return "".join(parts)
# --- <> → fragment (render children, no wrapper) ------------------
if name == "<>":
return "".join(_render(child, env) for child in expr[1:])
# --- lake → server-morphable slot within island -------------------
if name == "lake":
return _render_lake(expr[1:], env)
# --- marsh → reactive server-morphable slot within island --------
if name == "marsh":
return _render_marsh(expr[1:], env)
# --- html: prefix → force tag rendering --------------------------
if name.startswith("html:"):
return _render_element(name[5:], expr[1:], env)
# --- Render-aware special forms --------------------------------------
# Check BEFORE HTML_TAGS because some names overlap (e.g. `map`).
# But if the name is ALSO an HTML tag and (a) first arg is a Keyword
# or (b) we're inside SVG/MathML context, it's a tag call.
rsf = _RENDER_FORMS.get(name)
if rsf is not None:
if name in HTML_TAGS and (
(len(expr) > 1 and isinstance(expr[1], Keyword))
or _svg_context.get(False)
):
return _render_element(name, expr[1:], env)
return rsf(expr, env)
# --- Macro expansion → expand then render --------------------------
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return _render(expanded, env)
# --- HTML tag → render as element ---------------------------------
if name in HTML_TAGS:
return _render_element(name, expr[1:], env)
# --- Component/Island (~prefix) → render-aware call ----------------
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Island):
return _render_island(val, expr[1:], env)
if isinstance(val, Component):
return _render_component(val, expr[1:], env)
# Fall through to evaluation
# --- Custom element (hyphenated name with keyword attrs) → tag ----
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
return _render_element(name, expr[1:], env)
# --- SVG/MathML context → unknown names are child elements --------
if _svg_context.get(False):
return _render_element(name, expr[1:], env)
# --- Other special forms / function calls → evaluate then render ---
result = _eval(expr, env)
return _render(result, env)
# --- head is lambda or other callable → evaluate then render ----------
if isinstance(head, (Lambda, list)):
result = _eval(expr, env)
return _render(result, env)
# --- data list → render each item -------------------------------------
return "".join(_render(item, env) for item in expr)
def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
"""Render an HTML element: extract attrs (keywords), render children."""
attrs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
# Keyword followed by value → attribute
if isinstance(arg, Keyword) and i + 1 < len(args):
attr_name = arg.name
attr_val = _eval(args[i + 1], env)
attrs[attr_name] = attr_val
i += 2
else:
children.append(arg)
i += 1
# Collect CSS classes if collector is active
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
if collector is not None:
collector.update(str(class_val).split())
# Build opening tag
parts = [f"<{tag}"]
for attr_name, attr_val in attrs.items():
if attr_val is None or attr_val is NIL or attr_val is False:
continue
if attr_name in BOOLEAN_ATTRS:
if attr_val:
parts.append(f" {attr_name}")
elif attr_val is True:
parts.append(f" {attr_name}")
elif isinstance(attr_val, dict):
from .parser import serialize as _sx_serialize
parts.append(f' {attr_name}="{escape_attr(_sx_serialize(attr_val))}"')
else:
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
parts.append(">")
opening = "".join(parts)
# Void elements: no closing tag, no children
if tag in VOID_ELEMENTS:
return opening
# SVG/MathML namespace auto-detection: set context for children
token = None
if tag in ("svg", "math"):
token = _svg_context.set(True)
try:
child_html = "".join(_render(child, env) for child in children)
finally:
if token is not None:
_svg_context.reset(token)
return f"{opening}{child_html}</{tag}>"