Files
rose-ash/shared/sx/html.py
giles 2663dfb095
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m1s
Add SVG cover art to SX Manifesto as s-expression
Soviet constructivist poster with paper texture filters, grid lines,
aged stain spots, and "(<x>)" symbol in red.

Add missing SVG filter primitive tags to both server (html.py) and
client (sx.js): feTurbulence, feColorMatrix, feBlend,
feComponentTransfer, feFuncR/G/B/A, feDisplacementMap, feComposite,
feFlood, feImage, feMorphology, feSpecularLighting, feDiffuseLighting,
fePointLight, feSpotLight, feDistantLight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:34:58 +00:00

531 lines
18 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, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _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
)
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) -------------------------------
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_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:])
# --- Render-aware special forms --------------------------------------
# Check BEFORE HTML_TAGS because some names overlap (e.g. `map`).
if name in _RENDER_FORMS:
return _RENDER_FORMS[name](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 (~prefix) → render-aware component call ------------
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Component):
return _render_component(val, expr[1:], env)
# Fall through to evaluation
# --- 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
# Handle :style StyleValue — convert to class and register CSS rule
style_val = attrs.get("style")
if isinstance(style_val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(style_val)
# Merge into :class
existing_class = attrs.get("class")
if existing_class and existing_class is not NIL and existing_class is not False:
attrs["class"] = f"{existing_class} {style_val.class_name}"
else:
attrs["class"] = style_val.class_name
del attrs["style"]
# 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}")
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
# Render children
child_html = "".join(_render(child, env) for child in children)
return f"{opening}{child_html}</{tag}>"