"""
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)
# → '
'
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
from .ref.sx_ref import eval_expr as _raw_eval, call_component as _raw_call_component, expand_macro as _expand_macro, trampoline as _trampoline
def _eval(expr, env):
"""Evaluate and unwrap thunks — all html.py _eval calls are non-tail."""
return _trampoline(_raw_eval(expr, env))
def _call_component(comp, raw_args, env):
"""Call component and unwrap thunks — non-tail in html.py."""
return _trampoline(_raw_call_component(comp, raw_args, env))
# 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("&", "&")
.replace("<", "<")
.replace(">", ">")
)
def escape_attr(s: str) -> str:
"""Escape an attribute value for safe embedding in double quotes."""
return (
s.replace("&", "&")
.replace('"', """)
.replace("<", "<")
.replace(">", ">")
)
# ---------------------------------------------------------------------------
# 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: body HTML
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'")
parts.append(body_html)
parts.append("")
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...)
→ children
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...)
→ children
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}>"