""" 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) # → '

Hello

World

' 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, 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 ) # 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_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:]) # --- 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 (~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 # --- 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}"