Files
mono/shared/sx/html.py
giles e8bc228c7f Rebrand sexp → sx across web platform (173 files)
Rename all sexp directories, files, identifiers, and references to sx.
artdag/ excluded (separate media processing DSL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:06:57 +00:00

482 lines
15 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
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval, _call_component
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",
"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,
}
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)
# --- 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
# 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}>"