Phase 2: HSX-style HTML renderer with render-aware evaluation
S-expression AST → HTML string renderer with ~100 HTML tags, void elements, boolean attributes, XSS escaping, raw!, fragments, and components. Render-aware special forms (if, when, cond, let, map, etc.) handle HTML tags in control flow branches correctly by calling _render instead of _eval. 63 new tests (172 total across parser, evaluator, renderer). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
473
shared/sexp/html.py
Normal file
473
shared/sexp/html.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
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.sexp import parse, make_env
|
||||
from shared.sexp.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",
|
||||
# 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) -------------------------------
|
||||
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, 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}>"
|
||||
365
shared/sexp/tests/test_html.py
Normal file
365
shared/sexp/tests/test_html.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Tests for the HSX-style HTML renderer."""
|
||||
|
||||
import pytest
|
||||
from shared.sexp import parse, evaluate
|
||||
from shared.sexp.html import render, escape_text, escape_attr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def r(text, env=None):
|
||||
"""Parse and render a single expression."""
|
||||
return render(parse(text), env)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEscaping:
|
||||
def test_escape_text_ampersand(self):
|
||||
assert escape_text("A & B") == "A & B"
|
||||
|
||||
def test_escape_text_lt_gt(self):
|
||||
assert escape_text("<script>") == "<script>"
|
||||
|
||||
def test_escape_attr_quotes(self):
|
||||
assert escape_attr('he said "hi"') == "he said "hi""
|
||||
|
||||
def test_escape_attr_all(self):
|
||||
assert escape_attr('&<>"') == "&<>""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primitives / atoms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAtoms:
|
||||
def test_string(self):
|
||||
assert r('"Hello"') == "Hello"
|
||||
|
||||
def test_string_with_entities(self):
|
||||
assert r('"<b>bold</b>"') == "<b>bold</b>"
|
||||
|
||||
def test_integer(self):
|
||||
assert r("42") == "42"
|
||||
|
||||
def test_float(self):
|
||||
assert r("3.14") == "3.14"
|
||||
|
||||
def test_nil(self):
|
||||
assert r("nil") == ""
|
||||
|
||||
def test_true(self):
|
||||
assert r("true") == ""
|
||||
|
||||
def test_false(self):
|
||||
assert r("false") == ""
|
||||
|
||||
def test_keyword(self):
|
||||
assert r(":hello") == "hello"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple HTML elements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestElements:
|
||||
def test_div(self):
|
||||
assert r('(div "Hello")') == "<div>Hello</div>"
|
||||
|
||||
def test_empty_div(self):
|
||||
assert r("(div)") == "<div></div>"
|
||||
|
||||
def test_nested(self):
|
||||
assert r('(div (p "Hello"))') == "<div><p>Hello</p></div>"
|
||||
|
||||
def test_multiple_children(self):
|
||||
assert r('(ul (li "One") (li "Two"))') == \
|
||||
"<ul><li>One</li><li>Two</li></ul>"
|
||||
|
||||
def test_mixed_text_and_elements(self):
|
||||
assert r('(p "Hello " (strong "world"))') == \
|
||||
"<p>Hello <strong>world</strong></p>"
|
||||
|
||||
def test_deep_nesting(self):
|
||||
assert r('(div (div (div (span "deep"))))') == \
|
||||
"<div><div><div><span>deep</span></div></div></div>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAttributes:
|
||||
def test_single_attr(self):
|
||||
assert r('(div :class "card")') == '<div class="card"></div>'
|
||||
|
||||
def test_multiple_attrs(self):
|
||||
html = r('(div :class "card" :id "main")')
|
||||
assert 'class="card"' in html
|
||||
assert 'id="main"' in html
|
||||
|
||||
def test_attr_with_children(self):
|
||||
assert r('(div :class "card" (p "Body"))') == \
|
||||
'<div class="card"><p>Body</p></div>'
|
||||
|
||||
def test_attr_escaping(self):
|
||||
assert r('(div :title "A & B")') == '<div title="A & B"></div>'
|
||||
|
||||
def test_attr_with_quotes(self):
|
||||
assert r("""(div :title "say \\"hi\\"")""") == \
|
||||
'<div title="say "hi""></div>'
|
||||
|
||||
def test_false_attr_omitted(self):
|
||||
env = {"flag": False}
|
||||
html = render(parse("(div :hidden flag)"), env)
|
||||
assert html == "<div></div>"
|
||||
|
||||
def test_nil_attr_omitted(self):
|
||||
env = {"flag": None}
|
||||
html = render(parse("(div :data-x flag)"), env)
|
||||
assert html == "<div></div>"
|
||||
|
||||
def test_numeric_attr(self):
|
||||
assert r('(input :tabindex 1)') == '<input tabindex="1">'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boolean attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBooleanAttrs:
|
||||
def test_disabled_true(self):
|
||||
env = {"yes": True}
|
||||
html = render(parse("(button :disabled yes)"), env)
|
||||
assert html == "<button disabled></button>"
|
||||
|
||||
def test_disabled_false(self):
|
||||
env = {"no": False}
|
||||
html = render(parse("(button :disabled no)"), env)
|
||||
assert html == "<button></button>"
|
||||
|
||||
def test_checked(self):
|
||||
env = {"c": True}
|
||||
html = render(parse('(input :type "checkbox" :checked c)'), env)
|
||||
assert 'checked' in html
|
||||
assert 'type="checkbox"' in html
|
||||
|
||||
def test_required(self):
|
||||
env = {"r": True}
|
||||
html = render(parse("(input :required r)"), env)
|
||||
assert "required" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Void elements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVoidElements:
|
||||
def test_br(self):
|
||||
assert r("(br)") == "<br>"
|
||||
|
||||
def test_img(self):
|
||||
html = r('(img :src "/photo.jpg" :alt "Photo")')
|
||||
assert html == '<img src="/photo.jpg" alt="Photo">'
|
||||
|
||||
def test_input(self):
|
||||
html = r('(input :type "text" :name "q")')
|
||||
assert html == '<input type="text" name="q">'
|
||||
|
||||
def test_hr(self):
|
||||
assert r("(hr)") == "<hr>"
|
||||
|
||||
def test_meta(self):
|
||||
html = r('(meta :charset "utf-8")')
|
||||
assert html == '<meta charset="utf-8">'
|
||||
|
||||
def test_link(self):
|
||||
html = r('(link :rel "stylesheet" :href "/s.css")')
|
||||
assert html == '<link rel="stylesheet" href="/s.css">'
|
||||
|
||||
def test_void_no_closing_tag(self):
|
||||
# Void elements must not have a closing tag
|
||||
html = r("(br)")
|
||||
assert "</br>" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fragment (<>)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFragments:
|
||||
def test_basic_fragment(self):
|
||||
assert r('(<> (li "A") (li "B"))') == "<li>A</li><li>B</li>"
|
||||
|
||||
def test_empty_fragment(self):
|
||||
assert r("(<>)") == ""
|
||||
|
||||
def test_nested_fragment(self):
|
||||
html = r('(ul (<> (li "1") (li "2")))')
|
||||
assert html == "<ul><li>1</li><li>2</li></ul>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# raw!
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRawHtml:
|
||||
def test_raw_string(self):
|
||||
assert r('(raw! "<b>bold</b>")') == "<b>bold</b>"
|
||||
|
||||
def test_raw_no_escaping(self):
|
||||
html = r('(raw! "<script>alert(1)</script>")')
|
||||
assert "<script>" in html
|
||||
|
||||
def test_raw_with_variable(self):
|
||||
env = {"content": "<em>hi</em>"}
|
||||
html = render(parse("(raw! content)"), env)
|
||||
assert html == "<em>hi</em>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Components (defcomp / ~prefix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComponents:
|
||||
def test_basic_component(self):
|
||||
env = {}
|
||||
evaluate(parse('(defcomp ~badge (&key label) (span :class "badge" label))'), env)
|
||||
html = render(parse('(~badge :label "New")'), env)
|
||||
assert html == '<span class="badge">New</span>'
|
||||
|
||||
def test_component_with_children(self):
|
||||
env = {}
|
||||
evaluate(parse(
|
||||
'(defcomp ~card (&key title &rest children)'
|
||||
' (div :class "card" (h2 title) children))'
|
||||
), env)
|
||||
html = render(parse('(~card :title "Hi" (p "Body"))'), env)
|
||||
assert html == '<div class="card"><h2>Hi</h2><p>Body</p></div>'
|
||||
|
||||
def test_nested_components(self):
|
||||
env = {}
|
||||
evaluate(parse('(defcomp ~inner (&key text) (em text))'), env)
|
||||
evaluate(parse(
|
||||
'(defcomp ~outer (&key label)'
|
||||
' (div (~inner :text label)))'
|
||||
), env)
|
||||
html = render(parse('(~outer :label "Hello")'), env)
|
||||
assert html == "<div><em>Hello</em></div>"
|
||||
|
||||
def test_component_with_conditional(self):
|
||||
env = {}
|
||||
evaluate(parse(
|
||||
'(defcomp ~maybe (&key show text)'
|
||||
' (when show (span text)))'
|
||||
), env)
|
||||
assert render(parse('(~maybe :show true :text "Yes")'), env) == "<span>Yes</span>"
|
||||
assert render(parse('(~maybe :show false :text "No")'), env) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expressions in render context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExpressions:
|
||||
def test_let_in_render(self):
|
||||
html = r('(let ((x "Hello")) (p x))')
|
||||
assert html == "<p>Hello</p>"
|
||||
|
||||
def test_if_true_branch(self):
|
||||
html = r('(if true (p "Yes") (p "No"))')
|
||||
assert html == "<p>Yes</p>"
|
||||
|
||||
def test_if_false_branch(self):
|
||||
html = r('(if false (p "Yes") (p "No"))')
|
||||
assert html == "<p>No</p>"
|
||||
|
||||
def test_when_true(self):
|
||||
html = r('(when true (p "Shown"))')
|
||||
assert html == "<p>Shown</p>"
|
||||
|
||||
def test_when_false(self):
|
||||
html = r('(when false (p "Hidden"))')
|
||||
assert html == ""
|
||||
|
||||
def test_map_rendering(self):
|
||||
html = r('(map (lambda (x) (li x)) (list "A" "B" "C"))')
|
||||
assert html == "<li>A</li><li>B</li><li>C</li>"
|
||||
|
||||
def test_variable_in_element(self):
|
||||
env = {"name": "World"}
|
||||
html = render(parse('(p (str "Hello " name))'), env)
|
||||
assert html == "<p>Hello World</p>"
|
||||
|
||||
def test_str_in_attr(self):
|
||||
env = {"slug": "apple"}
|
||||
html = render(parse('(a :href (str "/p/" slug) "Link")'), env)
|
||||
assert html == '<a href="/p/apple">Link</a>'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full page structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFullPage:
|
||||
def test_simple_page(self):
|
||||
html = r('''
|
||||
(html :lang "en"
|
||||
(head (title "Test"))
|
||||
(body (h1 "Hello")))
|
||||
''')
|
||||
assert '<html lang="en">' in html
|
||||
assert "<title>Test</title>" in html
|
||||
assert "<h1>Hello</h1>" in html
|
||||
assert "</body>" in html
|
||||
assert "</html>" in html
|
||||
|
||||
def test_form(self):
|
||||
html = r('''
|
||||
(form :method "post" :action "/login"
|
||||
(label :for "user" "Username")
|
||||
(input :type "text" :name "user" :required true)
|
||||
(button :type "submit" "Login"))
|
||||
''')
|
||||
assert '<form method="post" action="/login">' in html
|
||||
assert '<label for="user">Username</label>' in html
|
||||
assert 'required' in html
|
||||
assert "<button" in html
|
||||
|
||||
def test_table(self):
|
||||
html = r('''
|
||||
(table
|
||||
(thead (tr (th "Name") (th "Price")))
|
||||
(tbody
|
||||
(tr (td "Apple") (td "1.50"))
|
||||
(tr (td "Banana") (td "0.75"))))
|
||||
''')
|
||||
assert "<thead>" in html
|
||||
assert "<th>Name</th>" in html
|
||||
assert "<td>Apple</td>" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_empty_list(self):
|
||||
assert render([], {}) == ""
|
||||
|
||||
def test_none(self):
|
||||
assert render(None, {}) == ""
|
||||
|
||||
def test_dict_not_rendered(self):
|
||||
assert render({"a": 1}, {}) == ""
|
||||
|
||||
def test_number_list(self):
|
||||
# A list of plain numbers (not a call) renders each
|
||||
assert render([1, 2, 3], {}) == "123"
|
||||
|
||||
def test_string_list(self):
|
||||
assert render(["a", "b"], {}) == "ab"
|
||||
Reference in New Issue
Block a user