diff --git a/shared/sexp/html.py b/shared/sexp/html.py
index 9aaf61e..818d26a 100644
--- a/shared/sexp/html.py
+++ b/shared/sexp/html.py
@@ -396,7 +396,9 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
parts = []
for arg in expr[1:]:
val = _eval(arg, env)
- if isinstance(val, str):
+ 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))
diff --git a/shared/sexp/tests/test_sexp_js.py b/shared/sexp/tests/test_sexp_js.py
new file mode 100644
index 0000000..a783116
--- /dev/null
+++ b/shared/sexp/tests/test_sexp_js.py
@@ -0,0 +1,175 @@
+"""Test sexp.js string renderer matches Python renderer output.
+
+Runs sexp.js through Node.js and compares output with Python.
+"""
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from shared.sexp.parser import parse, parse_all
+from shared.sexp.html import render as py_render
+from shared.sexp.evaluator import evaluate
+
+SEXP_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sexp.js"
+
+
+def _js_render(sexp_text: str, components_text: str = "") -> str:
+ """Run sexp.js in Node and return the renderToString result."""
+ # Build a small Node script
+ script = f"""
+ global.document = undefined; // no DOM needed for string render
+ {SEXP_JS.read_text()}
+ if ({json.dumps(components_text)}) Sexp.loadComponents({json.dumps(components_text)});
+ var result = Sexp.renderToString({json.dumps(sexp_text)});
+ process.stdout.write(result);
+ """
+ result = subprocess.run(
+ ["node", "-e", script],
+ capture_output=True, text=True, timeout=5,
+ )
+ if result.returncode != 0:
+ pytest.fail(f"Node.js error:\n{result.stderr}")
+ return result.stdout
+
+
+class TestParserParity:
+ """Parser produces equivalent structures."""
+
+ def test_simple_element(self):
+ assert _js_render('(div "hello")') == '
hello
'
+
+ def test_nested_elements(self):
+ html = _js_render('(div :class "card" (p "text"))')
+ assert html == ''
+
+ def test_void_element(self):
+ assert _js_render('(img :src "a.jpg")') == '
'
+ assert _js_render('(br)') == '
'
+
+ def test_boolean_attr(self):
+ assert _js_render('(input :disabled true :type "text")') == ''
+
+ def test_nil_attr_omitted(self):
+ assert _js_render('(div :class nil "hi")') == 'hi
'
+
+ def test_false_attr_omitted(self):
+ assert _js_render('(div :class false "hi")') == 'hi
'
+
+ def test_numbers(self):
+ assert _js_render('(span 42)') == '42'
+
+ def test_escaping(self):
+ html = _js_render('(div "")')
+ assert "<script>" in html
+
+
+class TestSpecialForms:
+ """Special forms render correctly."""
+
+ def test_if_true(self):
+ assert _js_render('(if true (span "yes") (span "no"))') == 'yes'
+
+ def test_if_false(self):
+ assert _js_render('(if false (span "yes") (span "no"))') == 'no'
+
+ def test_if_nil(self):
+ assert _js_render('(if nil (span "yes") (span "no"))') == 'no'
+
+ def test_when_true(self):
+ assert _js_render('(when true (span "yes"))') == 'yes'
+
+ def test_when_false(self):
+ assert _js_render('(when false (span "yes"))') == ''
+
+ def test_str(self):
+ assert _js_render('(div (str "a" "b" "c"))') == 'abc
'
+
+ def test_fragment(self):
+ assert _js_render('(<> (span "a") (span "b"))') == 'ab'
+
+ def test_let(self):
+ assert _js_render('(let ((x "hello")) (div x))') == 'hello
'
+
+ def test_let_clojure_style(self):
+ assert _js_render('(let (x "hello" y "world") (div (str x " " y)))') == 'hello world
'
+
+ def test_and(self):
+ assert _js_render('(when (and true true) (span "ok"))') == 'ok'
+ assert _js_render('(when (and true false) (span "ok"))') == ''
+
+ def test_or(self):
+ assert _js_render('(div (or nil "fallback"))') == 'fallback
'
+
+
+class TestComponents:
+ """Component definition and rendering."""
+
+ CARD = '(defcomp ~card (&key title) (div :class "card" (h2 title)))'
+
+ def test_simple_component(self):
+ html = _js_render('(~card :title "Hello")', self.CARD)
+ assert html == 'Hello
'
+
+ def test_component_with_children(self):
+ comp = '(defcomp ~box (&key &rest children) (div :class "box" (raw! children)))'
+ html = _js_render('(~box (p "inside"))', comp)
+ assert html == ''
+
+ def test_component_with_conditional(self):
+ comp = '(defcomp ~badge (&key show label) (when show (span label)))'
+ assert _js_render('(~badge :show true :label "ok")', comp) == 'ok'
+ assert _js_render('(~badge :show false :label "ok")', comp) == ''
+
+ def test_nested_components(self):
+ comps = """
+ (defcomp ~inner (&key text) (span text))
+ (defcomp ~outer (&key label) (div (~inner :text label)))
+ """
+ html = _js_render('(~outer :label "hi")', comps)
+ assert html == 'hi
'
+
+
+class TestPythonParity:
+ """JS string renderer matches Python renderer output."""
+
+ CASES = [
+ '(div :class "main" (p "hello"))',
+ '(div (if true "yes" "no"))',
+ '(div (when false "hidden"))',
+ '(span (str "a" "-" "b"))',
+ '(<> (div "one") (div "two"))',
+ '(ul (li "a") (li "b") (li "c"))',
+ '(input :type "text" :disabled true :value "x")',
+ '(div :class nil :id "ok" "text")',
+ '(img :src "photo.jpg" :alt "A photo")',
+ '(table (tr (td "cell")))',
+ ]
+
+ @pytest.mark.parametrize("sexp_text", CASES)
+ def test_matches_python(self, sexp_text):
+ py_html = py_render(parse(sexp_text))
+ js_html = _js_render(sexp_text)
+ assert js_html == py_html, f"Mismatch for {sexp_text!r}:\n PY: {py_html!r}\n JS: {js_html!r}"
+
+ COMP_CASES = [
+ (
+ '(defcomp ~tag (&key label colour) (span :class (str "tag-" colour) label))',
+ '(~tag :label "new" :colour "red")',
+ ),
+ (
+ '(defcomp ~wrap (&key &rest children) (div :class "w" (raw! children)))',
+ '(~wrap (p "a") (p "b"))',
+ ),
+ ]
+
+ @pytest.mark.parametrize("comp_text,call_text", COMP_CASES)
+ def test_component_matches_python(self, comp_text, call_text):
+ env = {}
+ evaluate(parse(comp_text), env)
+ py_html = py_render(parse(call_text), env)
+ js_html = _js_render(call_text, comp_text)
+ assert js_html == py_html
diff --git a/shared/static/scripts/sexp.js b/shared/static/scripts/sexp.js
new file mode 100644
index 0000000..8a8a991
--- /dev/null
+++ b/shared/static/scripts/sexp.js
@@ -0,0 +1,1153 @@
+/**
+ * sexp.js — S-expression parser, evaluator, and DOM renderer.
+ *
+ * Client-side counterpart to shared/sexp/ Python modules.
+ * Parses s-expression text, evaluates it, and renders to DOM nodes.
+ *
+ * Usage:
+ * Sexp.loadComponents('(defcomp ~card (&key title) (div :class "c" title))');
+ * const node = Sexp.render('(~card :title "Hello")');
+ * document.body.appendChild(node);
+ */
+;(function (global) {
+ "use strict";
+
+ // =========================================================================
+ // Types
+ // =========================================================================
+
+ /** Singleton nil — falsy placeholder. */
+ var NIL = Object.freeze({ _nil: true, toString: function () { return "nil"; } });
+
+ function isNil(x) { return x === NIL || x === null || x === undefined; }
+ function isTruthy(x) { return x !== false && !isNil(x) && x !== 0 && x !== ""; }
+ // Note: 0 and "" are falsy in sexp but we match Python semantics where
+ // only nil/false/None are falsy for control flow. Revisit if needed.
+ function isSexpTruthy(x) { return x !== false && !isNil(x); }
+
+ function Symbol(name) { this.name = name; }
+ Symbol.prototype.toString = function () { return this.name; };
+ Symbol.prototype._sym = true;
+
+ function Keyword(name) { this.name = name; }
+ Keyword.prototype.toString = function () { return ":" + this.name; };
+ Keyword.prototype._kw = true;
+
+ function Lambda(params, body, closure, name) {
+ this.params = params;
+ this.body = body;
+ this.closure = closure || {};
+ this.name = name || null;
+ }
+ Lambda.prototype._lambda = true;
+
+ function Component(name, params, hasChildren, body, closure) {
+ this.name = name;
+ this.params = params;
+ this.hasChildren = hasChildren;
+ this.body = body;
+ this.closure = closure || {};
+ }
+ Component.prototype._component = true;
+
+ /** Marker for pre-rendered HTML that bypasses escaping. */
+ function RawHTML(html) { this.html = html; }
+ RawHTML.prototype._raw = true;
+
+ function isSym(x) { return x && x._sym === true; }
+ function isKw(x) { return x && x._kw === true; }
+ function isLambda(x) { return x && x._lambda === true; }
+ function isComponent(x) { return x && x._component === true; }
+ function isRaw(x) { return x && x._raw === true; }
+
+ // =========================================================================
+ // Parser
+ // =========================================================================
+
+ var RE_WS = /\s+/y;
+ var RE_COMMENT = /;[^\n]*/y;
+ var RE_STRING = /"(?:[^"\\]|\\.)*"/y;
+ var RE_NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y;
+ var RE_KEYWORD = /:[a-zA-Z_][a-zA-Z0-9_>:\-]*/y;
+ var RE_SYMBOL = /[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*/y;
+
+ function Tokenizer(text) {
+ this.text = text;
+ this.pos = 0;
+ this.line = 1;
+ this.col = 1;
+ }
+
+ Tokenizer.prototype._advance = function (count) {
+ for (var i = 0; i < count; i++) {
+ if (this.pos < this.text.length) {
+ if (this.text[this.pos] === "\n") { this.line++; this.col = 1; }
+ else { this.col++; }
+ this.pos++;
+ }
+ }
+ };
+
+ Tokenizer.prototype._skip = function () {
+ while (this.pos < this.text.length) {
+ RE_WS.lastIndex = this.pos;
+ var m = RE_WS.exec(this.text);
+ if (m && m.index === this.pos) { this._advance(m[0].length); continue; }
+ RE_COMMENT.lastIndex = this.pos;
+ m = RE_COMMENT.exec(this.text);
+ if (m && m.index === this.pos) { this._advance(m[0].length); continue; }
+ break;
+ }
+ };
+
+ Tokenizer.prototype.peek = function () {
+ this._skip();
+ return this.pos < this.text.length ? this.text[this.pos] : null;
+ };
+
+ Tokenizer.prototype.next = function () {
+ this._skip();
+ if (this.pos >= this.text.length) return null;
+
+ var ch = this.text[this.pos];
+
+ // Delimiters
+ if ("()[]{}".indexOf(ch) !== -1) { this._advance(1); return ch; }
+
+ // String
+ if (ch === '"') {
+ RE_STRING.lastIndex = this.pos;
+ var m = RE_STRING.exec(this.text);
+ if (!m || m.index !== this.pos) throw parseErr("Unterminated string", this);
+ this._advance(m[0].length);
+ var raw = m[0].slice(1, -1);
+ return raw.replace(/\\n/g, "\n").replace(/\\t/g, "\t")
+ .replace(/\\"/g, '"').replace(/\\\\/g, "\\");
+ }
+
+ // Keyword
+ if (ch === ":") {
+ RE_KEYWORD.lastIndex = this.pos;
+ m = RE_KEYWORD.exec(this.text);
+ if (!m || m.index !== this.pos) throw parseErr("Invalid keyword", this);
+ this._advance(m[0].length);
+ return new Keyword(m[0].slice(1));
+ }
+
+ // Number (before symbol due to leading -)
+ if (isDigit(ch) || (ch === "-" && this.pos + 1 < this.text.length &&
+ (isDigit(this.text[this.pos + 1]) || this.text[this.pos + 1] === "."))) {
+ RE_NUMBER.lastIndex = this.pos;
+ m = RE_NUMBER.exec(this.text);
+ if (m && m.index === this.pos) {
+ this._advance(m[0].length);
+ var s = m[0];
+ return (s.indexOf(".") !== -1 || s.indexOf("e") !== -1 || s.indexOf("E") !== -1)
+ ? parseFloat(s) : parseInt(s, 10);
+ }
+ }
+
+ // Symbol
+ RE_SYMBOL.lastIndex = this.pos;
+ m = RE_SYMBOL.exec(this.text);
+ if (m && m.index === this.pos) {
+ this._advance(m[0].length);
+ var name = m[0];
+ if (name === "true") return true;
+ if (name === "false") return false;
+ if (name === "nil") return NIL;
+ return new Symbol(name);
+ }
+
+ throw parseErr("Unexpected character: " + ch, this);
+ };
+
+ function isDigit(c) { return c >= "0" && c <= "9"; }
+
+ function parseErr(msg, tok) {
+ return new Error(msg + " at line " + tok.line + ", col " + tok.col);
+ }
+
+ function parseExpr(tok) {
+ var token = tok.next();
+ if (token === null) throw parseErr("Unexpected end of input", tok);
+ if (token === "(") return parseList(tok, ")");
+ if (token === "[") return parseList(tok, "]");
+ if (token === "{") return parseMap(tok);
+ if (token === ")" || token === "]" || token === "}") {
+ throw parseErr("Unexpected " + token, tok);
+ }
+ return token;
+ }
+
+ function parseList(tok, closer) {
+ var items = [];
+ while (true) {
+ var c = tok.peek();
+ if (c === null) throw parseErr("Unterminated list, expected " + closer, tok);
+ if (c === closer) { tok.next(); return items; }
+ items.push(parseExpr(tok));
+ }
+ }
+
+ function parseMap(tok) {
+ var result = {};
+ while (true) {
+ var c = tok.peek();
+ if (c === null) throw parseErr("Unterminated map", tok);
+ if (c === "}") { tok.next(); return result; }
+ var key = parseExpr(tok);
+ var keyStr = isKw(key) ? key.name : String(key);
+ result[keyStr] = parseExpr(tok);
+ }
+ }
+
+ /** Parse a single s-expression. */
+ function parse(text) {
+ var tok = new Tokenizer(text);
+ var result = parseExpr(tok);
+ if (tok.peek() !== null) throw parseErr("Unexpected content after expression", tok);
+ return result;
+ }
+
+ /** Parse zero or more s-expressions. */
+ function parseAll(text) {
+ var tok = new Tokenizer(text);
+ var results = [];
+ while (tok.peek() !== null) results.push(parseExpr(tok));
+ return results;
+ }
+
+ // =========================================================================
+ // Primitives
+ // =========================================================================
+
+ var PRIMITIVES = {};
+
+ // Arithmetic
+ PRIMITIVES["+"] = function () { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; };
+ PRIMITIVES["-"] = function (a, b) { return arguments.length === 1 ? -a : a - b; };
+ PRIMITIVES["*"] = function () { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; };
+ PRIMITIVES["/"] = function (a, b) { return a / b; };
+ PRIMITIVES["mod"] = function (a, b) { return a % b; };
+ PRIMITIVES["inc"] = function (n) { return n + 1; };
+ PRIMITIVES["dec"] = function (n) { return n - 1; };
+ PRIMITIVES["abs"] = Math.abs;
+ PRIMITIVES["floor"] = Math.floor;
+ PRIMITIVES["ceil"] = Math.ceil;
+ PRIMITIVES["round"] = Math.round;
+ PRIMITIVES["min"] = Math.min;
+ PRIMITIVES["max"] = Math.max;
+ PRIMITIVES["sqrt"] = Math.sqrt;
+ PRIMITIVES["pow"] = Math.pow;
+
+ // Comparison
+ PRIMITIVES["="] = function (a, b) { return a == b; }; // loose, matches Python sexp
+ PRIMITIVES["!="] = function (a, b) { return a != b; };
+ PRIMITIVES["<"] = function (a, b) { return a < b; };
+ PRIMITIVES[">"] = function (a, b) { return a > b; };
+ PRIMITIVES["<="] = function (a, b) { return a <= b; };
+ PRIMITIVES[">="] = function (a, b) { return a >= b; };
+
+ // Logic
+ PRIMITIVES["not"] = function (x) { return !isSexpTruthy(x); };
+
+ // String
+ PRIMITIVES["str"] = function () {
+ var parts = [];
+ for (var i = 0; i < arguments.length; i++) {
+ var v = arguments[i];
+ if (isNil(v)) continue;
+ parts.push(String(v));
+ }
+ return parts.join("");
+ };
+ PRIMITIVES["upper"] = function (s) { return String(s).toUpperCase(); };
+ PRIMITIVES["lower"] = function (s) { return String(s).toLowerCase(); };
+ PRIMITIVES["trim"] = function (s) { return String(s).trim(); };
+ PRIMITIVES["split"] = function (s, sep) { return String(s).split(sep); };
+ PRIMITIVES["join"] = function (sep, coll) { return coll.join(sep); };
+ PRIMITIVES["starts-with?"] = function (s, p) { return String(s).indexOf(p) === 0; };
+ PRIMITIVES["ends-with?"] = function (s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
+ PRIMITIVES["concat"] = function () {
+ var out = [];
+ for (var i = 0; i < arguments.length; i++) out = out.concat(arguments[i]);
+ return out;
+ };
+
+ // Predicates
+ PRIMITIVES["nil?"] = function (x) { return isNil(x); };
+ PRIMITIVES["number?"] = function (x) { return typeof x === "number"; };
+ PRIMITIVES["string?"] = function (x) { return typeof x === "string"; };
+ PRIMITIVES["list?"] = function (x) { return Array.isArray(x); };
+ PRIMITIVES["dict?"] = function (x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; };
+ PRIMITIVES["empty?"] = function (c) { return !c || (Array.isArray(c) ? c.length === 0 : Object.keys(c).length === 0); };
+ PRIMITIVES["contains?"] = function (c, k) { return Array.isArray(c) ? c.indexOf(k) !== -1 : k in c; };
+ PRIMITIVES["odd?"] = function (n) { return n % 2 !== 0; };
+ PRIMITIVES["even?"] = function (n) { return n % 2 === 0; };
+ PRIMITIVES["zero?"] = function (n) { return n === 0; };
+
+ // Collections
+ PRIMITIVES["list"] = function () { return Array.prototype.slice.call(arguments); };
+ PRIMITIVES["dict"] = function () {
+ var d = {};
+ for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1];
+ return d;
+ };
+ PRIMITIVES["get"] = function (c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); };
+ PRIMITIVES["len"] = function (c) { return Array.isArray(c) ? c.length : Object.keys(c).length; };
+ PRIMITIVES["first"] = function (c) { return c && c.length > 0 ? c[0] : NIL; };
+ PRIMITIVES["last"] = function (c) { return c && c.length > 0 ? c[c.length - 1] : NIL; };
+ PRIMITIVES["rest"] = function (c) { return c ? c.slice(1) : []; };
+ PRIMITIVES["nth"] = function (c, n) { return c && n < c.length ? c[n] : NIL; };
+ PRIMITIVES["cons"] = function (x, c) { return [x].concat(c || []); };
+ PRIMITIVES["append"] = function (c, x) { return (c || []).concat([x]); };
+ PRIMITIVES["keys"] = function (d) { return Object.keys(d || {}); };
+ PRIMITIVES["vals"] = function (d) { var r = []; for (var k in d) r.push(d[k]); return r; };
+ PRIMITIVES["merge"] = function () {
+ var out = {};
+ for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; for (var k in d) out[k] = d[k]; }
+ return out;
+ };
+ PRIMITIVES["assoc"] = function (d) {
+ var out = {}; for (var k in d) out[k] = d[k];
+ for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1];
+ return out;
+ };
+ PRIMITIVES["range"] = function (a, b, step) {
+ var r = []; step = step || 1;
+ if (b === undefined) { b = a; a = 0; }
+ for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i);
+ return r;
+ };
+
+ // =========================================================================
+ // Evaluator
+ // =========================================================================
+
+ function sexpEval(expr, env) {
+ // Literals
+ if (typeof expr === "number" || typeof expr === "string" || typeof expr === "boolean") return expr;
+ if (isNil(expr)) return NIL;
+
+ // Symbol lookup
+ if (isSym(expr)) {
+ var name = expr.name;
+ if (name in env) return env[name];
+ if (name in PRIMITIVES) return PRIMITIVES[name];
+ if (name === "true") return true;
+ if (name === "false") return false;
+ if (name === "nil") return NIL;
+ throw new Error("Undefined symbol: " + name);
+ }
+
+ // Keyword → its name
+ if (isKw(expr)) return expr.name;
+
+ // Dict literal
+ if (expr && typeof expr === "object" && !Array.isArray(expr) && !expr._sym && !expr._kw && !expr._raw) {
+ var d = {};
+ for (var dk in expr) d[dk] = sexpEval(expr[dk], env);
+ return d;
+ }
+
+ // List
+ if (!Array.isArray(expr)) return expr;
+ if (expr.length === 0) return [];
+
+ var head = expr[0];
+
+ // Non-callable head → data list
+ if (!isSym(head) && !isLambda(head) && !Array.isArray(head)) {
+ return expr.map(function (x) { return sexpEval(x, env); });
+ }
+
+ // Special forms
+ if (isSym(head)) {
+ var sf = SPECIAL_FORMS[head.name];
+ if (sf) return sf(expr, env);
+ var ho = HO_FORMS[head.name];
+ if (ho) return ho(expr, env);
+ }
+
+ // Function call
+ var fn = sexpEval(head, env);
+ var args = [];
+ for (var ai = 1; ai < expr.length; ai++) args.push(sexpEval(expr[ai], env));
+
+ if (typeof fn === "function") return fn.apply(null, args);
+ if (isLambda(fn)) return callLambda(fn, args, env);
+ if (isComponent(fn)) return callComponent(fn, expr.slice(1), env);
+ throw new Error("Not callable: " + fn);
+ }
+
+ function callLambda(fn, args, callerEnv) {
+ if (args.length !== fn.params.length) {
+ throw new Error((fn.name || "lambda") + " expects " + fn.params.length + " args, got " + args.length);
+ }
+ var local = merge({}, fn.closure, callerEnv);
+ for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
+ return sexpEval(fn.body, local);
+ }
+
+ function callComponent(comp, rawArgs, env) {
+ var kwargs = {}, children = [];
+ var i = 0;
+ while (i < rawArgs.length) {
+ if (isKw(rawArgs[i]) && i + 1 < rawArgs.length) {
+ kwargs[rawArgs[i].name] = sexpEval(rawArgs[i + 1], env);
+ i += 2;
+ } else {
+ children.push(sexpEval(rawArgs[i], env));
+ i++;
+ }
+ }
+ var local = merge({}, comp.closure, env);
+ for (var pi = 0; pi < comp.params.length; pi++) {
+ var p = comp.params[pi];
+ local[p] = (p in kwargs) ? kwargs[p] : NIL;
+ }
+ if (comp.hasChildren) local["children"] = children;
+ return sexpEval(comp.body, local);
+ }
+
+ // --- Special forms -------------------------------------------------------
+
+ var SPECIAL_FORMS = {};
+
+ SPECIAL_FORMS["if"] = function (expr, env) {
+ var cond = sexpEval(expr[1], env);
+ if (isSexpTruthy(cond)) return sexpEval(expr[2], env);
+ return expr.length > 3 ? sexpEval(expr[3], env) : NIL;
+ };
+
+ SPECIAL_FORMS["when"] = function (expr, env) {
+ if (!isSexpTruthy(sexpEval(expr[1], env))) return NIL;
+ var result = NIL;
+ for (var i = 2; i < expr.length; i++) result = sexpEval(expr[i], env);
+ return result;
+ };
+
+ SPECIAL_FORMS["cond"] = function (expr, env) {
+ var clauses = expr.slice(1);
+ if (!clauses.length) return NIL;
+ // Scheme-style
+ if (Array.isArray(clauses[0]) && clauses[0].length === 2) {
+ for (var i = 0; i < clauses.length; i++) {
+ var test = clauses[i][0];
+ if ((isSym(test) && (test.name === "else" || test.name === ":else")) ||
+ (isKw(test) && test.name === "else")) return sexpEval(clauses[i][1], env);
+ if (isSexpTruthy(sexpEval(test, env))) return sexpEval(clauses[i][1], env);
+ }
+ } else {
+ // Clojure-style
+ for (var j = 0; j < clauses.length - 1; j += 2) {
+ var t = clauses[j];
+ if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
+ return sexpEval(clauses[j + 1], env);
+ if (isSexpTruthy(sexpEval(t, env))) return sexpEval(clauses[j + 1], env);
+ }
+ }
+ return NIL;
+ };
+
+ SPECIAL_FORMS["case"] = function (expr, env) {
+ var val = sexpEval(expr[1], env);
+ for (var i = 2; i < expr.length - 1; i += 2) {
+ var t = expr[i];
+ if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
+ return sexpEval(expr[i + 1], env);
+ if (val == sexpEval(t, env)) return sexpEval(expr[i + 1], env);
+ }
+ return NIL;
+ };
+
+ SPECIAL_FORMS["and"] = function (expr, env) {
+ var result = true;
+ for (var i = 1; i < expr.length; i++) {
+ result = sexpEval(expr[i], env);
+ if (!isSexpTruthy(result)) return result;
+ }
+ return result;
+ };
+
+ SPECIAL_FORMS["or"] = function (expr, env) {
+ var result = false;
+ for (var i = 1; i < expr.length; i++) {
+ result = sexpEval(expr[i], env);
+ if (isSexpTruthy(result)) return result;
+ }
+ return result;
+ };
+
+ SPECIAL_FORMS["let"] = SPECIAL_FORMS["let*"] = function (expr, env) {
+ var bindings = expr[1], local = merge({}, env);
+ if (Array.isArray(bindings)) {
+ if (bindings.length && Array.isArray(bindings[0])) {
+ // Scheme-style
+ for (var i = 0; i < bindings.length; i++) {
+ var vname = isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0];
+ local[vname] = sexpEval(bindings[i][1], local);
+ }
+ } else {
+ // Clojure-style
+ for (var j = 0; j < bindings.length; j += 2) {
+ var vn = isSym(bindings[j]) ? bindings[j].name : bindings[j];
+ local[vn] = sexpEval(bindings[j + 1], local);
+ }
+ }
+ }
+ var result = NIL;
+ for (var k = 2; k < expr.length; k++) result = sexpEval(expr[k], local);
+ return result;
+ };
+
+ SPECIAL_FORMS["lambda"] = SPECIAL_FORMS["fn"] = function (expr, env) {
+ var paramsExpr = expr[1], paramNames = [];
+ for (var i = 0; i < paramsExpr.length; i++) {
+ var p = paramsExpr[i];
+ paramNames.push(isSym(p) ? p.name : String(p));
+ }
+ return new Lambda(paramNames, expr[2], merge({}, env));
+ };
+
+ SPECIAL_FORMS["define"] = function (expr, env) {
+ var name = expr[1].name;
+ var value = sexpEval(expr[2], env);
+ if (isLambda(value) && !value.name) value.name = name;
+ env[name] = value;
+ return value;
+ };
+
+ SPECIAL_FORMS["defcomp"] = function (expr, env) {
+ var nameSym = expr[1];
+ var compName = nameSym.name.replace(/^~/, "");
+ var paramsExpr = expr[2];
+ var params = [], hasChildren = false, inKey = false;
+ for (var i = 0; i < paramsExpr.length; i++) {
+ var p = paramsExpr[i];
+ if (isSym(p)) {
+ if (p.name === "&key") { inKey = true; continue; }
+ if (p.name === "&rest") { hasChildren = true; continue; }
+ if (inKey || hasChildren) { if (!hasChildren) params.push(p.name); }
+ else params.push(p.name);
+ }
+ }
+ var comp = new Component(compName, params, hasChildren, expr[3], merge({}, env));
+ env[nameSym.name] = comp;
+ return comp;
+ };
+
+ SPECIAL_FORMS["begin"] = SPECIAL_FORMS["do"] = function (expr, env) {
+ var result = NIL;
+ for (var i = 1; i < expr.length; i++) result = sexpEval(expr[i], env);
+ return result;
+ };
+
+ SPECIAL_FORMS["quote"] = function (expr) { return expr[1]; };
+
+ SPECIAL_FORMS["set!"] = function (expr, env) {
+ var v = sexpEval(expr[2], env);
+ env[expr[1].name] = v;
+ return v;
+ };
+
+ SPECIAL_FORMS["->"] = function (expr, env) {
+ var result = sexpEval(expr[1], env);
+ for (var i = 2; i < expr.length; i++) {
+ var form = expr[i];
+ var fn, args;
+ if (Array.isArray(form)) {
+ fn = sexpEval(form[0], env);
+ args = [result];
+ for (var j = 1; j < form.length; j++) args.push(sexpEval(form[j], env));
+ } else {
+ fn = sexpEval(form, env);
+ args = [result];
+ }
+ if (typeof fn === "function") result = fn.apply(null, args);
+ else if (isLambda(fn)) result = callLambda(fn, args, env);
+ else throw new Error("-> form not callable: " + fn);
+ }
+ return result;
+ };
+
+ // --- Higher-order forms --------------------------------------------------
+
+ var HO_FORMS = {};
+
+ HO_FORMS["map"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ return coll.map(function (item) { return isLambda(fn) ? callLambda(fn, [item], env) : fn(item); });
+ };
+
+ HO_FORMS["map-indexed"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ return coll.map(function (item, i) { return isLambda(fn) ? callLambda(fn, [i, item], env) : fn(i, item); });
+ };
+
+ HO_FORMS["filter"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ return coll.filter(function (item) {
+ var r = isLambda(fn) ? callLambda(fn, [item], env) : fn(item);
+ return isSexpTruthy(r);
+ });
+ };
+
+ HO_FORMS["reduce"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), acc = sexpEval(expr[2], env), coll = sexpEval(expr[3], env);
+ for (var i = 0; i < coll.length; i++) acc = isLambda(fn) ? callLambda(fn, [acc, coll[i]], env) : fn(acc, coll[i]);
+ return acc;
+ };
+
+ HO_FORMS["some"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ for (var i = 0; i < coll.length; i++) {
+ var r = isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
+ if (isSexpTruthy(r)) return r;
+ }
+ return NIL;
+ };
+
+ HO_FORMS["every?"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ for (var i = 0; i < coll.length; i++) {
+ if (!isSexpTruthy(isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]))) return false;
+ }
+ return true;
+ };
+
+ HO_FORMS["for-each"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ for (var i = 0; i < coll.length; i++) isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
+ return NIL;
+ };
+
+ // =========================================================================
+ // DOM Renderer
+ // =========================================================================
+
+ var HTML_TAGS = makeSet(
+ "html head body title meta link style script base noscript " +
+ "header footer main nav aside section article address hgroup " +
+ "h1 h2 h3 h4 h5 h6 " +
+ "div p blockquote pre figure figcaption ul ol li dl dt dd hr " +
+ "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 " +
+ "ins del " +
+ "img picture source iframe embed object param video audio track canvas map area " +
+ "svg math 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 thead tbody tfoot tr th td caption colgroup col " +
+ "form fieldset legend label input button select option optgroup textarea output " +
+ "datalist progress meter details summary dialog template slot"
+ );
+
+ var VOID_ELEMENTS = makeSet(
+ "area base br col embed hr img input link meta param source track wbr"
+ );
+
+ var BOOLEAN_ATTRS = makeSet(
+ "async autofocus autoplay checked controls default defer disabled " +
+ "formnovalidate hidden inert ismap loop multiple muted nomodule " +
+ "novalidate open playsinline readonly required reversed selected"
+ );
+
+ // SVG elements that need createElementNS
+ var SVG_TAGS = makeSet(
+ "svg path circle ellipse line polygon polyline rect g defs use text tspan " +
+ "clipPath mask linearGradient radialGradient stop filter " +
+ "feGaussianBlur feOffset feMerge feMergeNode animate animateTransform"
+ );
+
+ var SVG_NS = "http://www.w3.org/2000/svg";
+
+ /**
+ * Render an s-expression to DOM node(s).
+ * Returns a DocumentFragment, Element, or Text node.
+ */
+ function renderDOM(expr, env) {
+ // nil / false → empty
+ if (isNil(expr) || expr === false || expr === true) return document.createDocumentFragment();
+
+ // Pre-rendered HTML
+ if (isRaw(expr)) {
+ var tpl = document.createElement("template");
+ tpl.innerHTML = expr.html;
+ return tpl.content;
+ }
+
+ // String → text node
+ if (typeof expr === "string") return document.createTextNode(expr);
+
+ // Number → text node
+ if (typeof expr === "number") return document.createTextNode(String(expr));
+
+ // Symbol → evaluate then render
+ if (isSym(expr)) return renderDOM(sexpEval(expr, env), env);
+
+ // Keyword → text
+ if (isKw(expr)) return document.createTextNode(expr.name);
+
+ // Dict → empty
+ if (expr && typeof expr === "object" && !Array.isArray(expr)) return document.createDocumentFragment();
+
+ // List → dispatch
+ if (Array.isArray(expr)) {
+ if (!expr.length) return document.createDocumentFragment();
+ return renderList(expr, env);
+ }
+
+ return document.createTextNode(String(expr));
+ }
+
+ /** Render-aware special forms for DOM output. */
+ var RENDER_FORMS = {};
+
+ RENDER_FORMS["if"] = function (expr, env) {
+ var cond = sexpEval(expr[1], env);
+ if (isSexpTruthy(cond)) return renderDOM(expr[2], env);
+ return expr.length > 3 ? renderDOM(expr[3], env) : document.createDocumentFragment();
+ };
+
+ RENDER_FORMS["when"] = function (expr, env) {
+ if (!isSexpTruthy(sexpEval(expr[1], env))) return document.createDocumentFragment();
+ var frag = document.createDocumentFragment();
+ for (var i = 2; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
+ return frag;
+ };
+
+ RENDER_FORMS["cond"] = function (expr, env) {
+ var clauses = expr.slice(1);
+ if (!clauses.length) return document.createDocumentFragment();
+ if (Array.isArray(clauses[0]) && clauses[0].length === 2) {
+ for (var i = 0; i < clauses.length; i++) {
+ var test = clauses[i][0];
+ if ((isSym(test) && (test.name === "else" || test.name === ":else")) ||
+ (isKw(test) && test.name === "else")) return renderDOM(clauses[i][1], env);
+ if (isSexpTruthy(sexpEval(test, env))) return renderDOM(clauses[i][1], env);
+ }
+ } else {
+ for (var j = 0; j < clauses.length - 1; j += 2) {
+ var t = clauses[j];
+ if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
+ return renderDOM(clauses[j + 1], env);
+ if (isSexpTruthy(sexpEval(t, env))) return renderDOM(clauses[j + 1], env);
+ }
+ }
+ return document.createDocumentFragment();
+ };
+
+ RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) {
+ var bindings = expr[1], local = merge({}, env);
+ if (Array.isArray(bindings)) {
+ if (bindings.length && Array.isArray(bindings[0])) {
+ for (var i = 0; i < bindings.length; i++) {
+ local[isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]] = sexpEval(bindings[i][1], local);
+ }
+ } else {
+ for (var j = 0; j < bindings.length; j += 2) {
+ local[isSym(bindings[j]) ? bindings[j].name : bindings[j]] = sexpEval(bindings[j + 1], local);
+ }
+ }
+ }
+ var frag = document.createDocumentFragment();
+ for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local));
+ return frag;
+ };
+
+ RENDER_FORMS["begin"] = RENDER_FORMS["do"] = function (expr, env) {
+ var frag = document.createDocumentFragment();
+ for (var i = 1; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
+ return frag;
+ };
+
+ RENDER_FORMS["define"] = function (expr, env) { sexpEval(expr, env); return document.createDocumentFragment(); };
+ RENDER_FORMS["defcomp"] = function (expr, env) { sexpEval(expr, env); return document.createDocumentFragment(); };
+
+ RENDER_FORMS["map"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ var frag = document.createDocumentFragment();
+ for (var i = 0; i < coll.length; i++) {
+ var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
+ frag.appendChild(val);
+ }
+ return frag;
+ };
+
+ RENDER_FORMS["map-indexed"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ var frag = document.createDocumentFragment();
+ for (var i = 0; i < coll.length; i++) {
+ var val = isLambda(fn) ? renderLambdaDOM(fn, [i, coll[i]], env) : renderDOM(fn(i, coll[i]), env);
+ frag.appendChild(val);
+ }
+ return frag;
+ };
+
+ RENDER_FORMS["filter"] = function (expr, env) {
+ var result = sexpEval(expr, env);
+ return renderDOM(result, env);
+ };
+
+ RENDER_FORMS["for-each"] = function (expr, env) {
+ var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
+ var frag = document.createDocumentFragment();
+ for (var i = 0; i < coll.length; i++) {
+ var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
+ frag.appendChild(val);
+ }
+ return frag;
+ };
+
+ function renderLambdaDOM(fn, args, env) {
+ var local = merge({}, fn.closure, env);
+ for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
+ return renderDOM(fn.body, local);
+ }
+
+ function renderComponentDOM(comp, args, env) {
+ var kwargs = {}, children = [];
+ var i = 0;
+ while (i < args.length) {
+ if (isKw(args[i]) && i + 1 < args.length) {
+ kwargs[args[i].name] = sexpEval(args[i + 1], env);
+ i += 2;
+ } else {
+ children.push(args[i]);
+ i++;
+ }
+ }
+ var local = merge({}, comp.closure, env);
+ for (var pi = 0; pi < comp.params.length; pi++) {
+ var p = comp.params[pi];
+ local[p] = (p in kwargs) ? kwargs[p] : NIL;
+ }
+ if (comp.hasChildren) {
+ // Pre-render children to a fragment, wrap as RawHTML for raw! compatibility
+ var childFrag = document.createDocumentFragment();
+ for (var ci = 0; ci < children.length; ci++) childFrag.appendChild(renderDOM(children[ci], env));
+ local["children"] = childFrag;
+ }
+ return renderDOM(comp.body, local);
+ }
+
+ function renderList(expr, env) {
+ var head = expr[0];
+
+ if (isSym(head)) {
+ var name = head.name;
+
+ // raw! → insert unescaped
+ if (name === "raw!") {
+ var frag = document.createDocumentFragment();
+ for (var ri = 1; ri < expr.length; ri++) {
+ var val = sexpEval(expr[ri], env);
+ if (typeof val === "string") {
+ var tpl = document.createElement("template");
+ tpl.innerHTML = val;
+ frag.appendChild(tpl.content);
+ } else if (val && val.nodeType) {
+ // Already a DOM node (e.g. from children fragment)
+ frag.appendChild(val.cloneNode ? val.cloneNode(true) : val);
+ } else if (!isNil(val)) {
+ frag.appendChild(document.createTextNode(String(val)));
+ }
+ }
+ return frag;
+ }
+
+ // <> → fragment
+ if (name === "<>") {
+ var f = document.createDocumentFragment();
+ for (var fi = 1; fi < expr.length; fi++) f.appendChild(renderDOM(expr[fi], env));
+ return f;
+ }
+
+ // Render-aware special forms
+ if (RENDER_FORMS[name]) return RENDER_FORMS[name](expr, env);
+
+ // HTML tag
+ if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env);
+
+ // Component
+ if (name.charAt(0) === "~") {
+ var comp = env[name];
+ if (isComponent(comp)) return renderComponentDOM(comp, expr.slice(1), env);
+ }
+
+ // Fallback: evaluate then render
+ return renderDOM(sexpEval(expr, env), env);
+ }
+
+ // Lambda/list head → evaluate
+ if (isLambda(head) || Array.isArray(head)) return renderDOM(sexpEval(expr, env), env);
+
+ // Data list
+ var dl = document.createDocumentFragment();
+ for (var di = 0; di < expr.length; di++) dl.appendChild(renderDOM(expr[di], env));
+ return dl;
+ }
+
+ function renderElement(tag, args, env) {
+ var el = SVG_TAGS[tag]
+ ? document.createElementNS(SVG_NS, tag)
+ : document.createElement(tag);
+
+ var i = 0;
+ while (i < args.length) {
+ var arg = args[i];
+ if (isKw(arg) && i + 1 < args.length) {
+ var attrName = arg.name;
+ var attrVal = sexpEval(args[i + 1], env);
+ i += 2;
+ if (isNil(attrVal) || attrVal === false) continue;
+ if (BOOLEAN_ATTRS[attrName]) {
+ if (attrVal) el.setAttribute(attrName, "");
+ } else if (attrVal === true) {
+ el.setAttribute(attrName, "");
+ } else {
+ el.setAttribute(attrName, String(attrVal));
+ }
+ } else {
+ // Child
+ if (!(tag in VOID_ELEMENTS)) {
+ el.appendChild(renderDOM(arg, env));
+ }
+ i++;
+ }
+ }
+
+ return el;
+ }
+
+ // =========================================================================
+ // String Renderer (for SSR parity / testing)
+ // =========================================================================
+
+ function escapeText(s) { return s.replace(/&/g, "&").replace(//g, ">"); }
+ function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); }
+
+ function renderStr(expr, env) {
+ if (isNil(expr) || expr === false || expr === true) return "";
+ if (isRaw(expr)) return expr.html;
+ if (typeof expr === "string") return escapeText(expr);
+ if (typeof expr === "number") return escapeText(String(expr));
+ if (isSym(expr)) return renderStr(sexpEval(expr, env), env);
+ if (isKw(expr)) return escapeText(expr.name);
+ if (Array.isArray(expr)) { if (!expr.length) return ""; return renderStrList(expr, env); }
+ if (expr && typeof expr === "object") return "";
+ return escapeText(String(expr));
+ }
+
+ function renderStrList(expr, env) {
+ var head = expr[0];
+ if (!isSym(head)) {
+ var parts = [];
+ for (var i = 0; i < expr.length; i++) parts.push(renderStr(expr[i], env));
+ return parts.join("");
+ }
+ var name = head.name;
+
+ if (name === "raw!") {
+ var ps = [];
+ for (var ri = 1; ri < expr.length; ri++) {
+ var v = sexpEval(expr[ri], env);
+ if (isRaw(v)) ps.push(v.html);
+ else if (typeof v === "string") ps.push(v);
+ else if (!isNil(v)) ps.push(String(v));
+ }
+ return ps.join("");
+ }
+ if (name === "<>") {
+ var fs = [];
+ for (var fi = 1; fi < expr.length; fi++) fs.push(renderStr(expr[fi], env));
+ return fs.join("");
+ }
+ if (name === "if") {
+ return isSexpTruthy(sexpEval(expr[1], env))
+ ? renderStr(expr[2], env)
+ : (expr.length > 3 ? renderStr(expr[3], env) : "");
+ }
+ if (name === "when") {
+ if (!isSexpTruthy(sexpEval(expr[1], env))) return "";
+ var ws = [];
+ for (var wi = 2; wi < expr.length; wi++) ws.push(renderStr(expr[wi], env));
+ return ws.join("");
+ }
+ if (name === "let" || name === "let*") {
+ var bindings = expr[1], local = merge({}, env);
+ if (Array.isArray(bindings)) {
+ if (bindings.length && Array.isArray(bindings[0])) {
+ for (var li = 0; li < bindings.length; li++) {
+ local[isSym(bindings[li][0]) ? bindings[li][0].name : bindings[li][0]] = sexpEval(bindings[li][1], local);
+ }
+ } else {
+ for (var lj = 0; lj < bindings.length; lj += 2) {
+ local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sexpEval(bindings[lj + 1], local);
+ }
+ }
+ }
+ var ls = [];
+ for (var lk = 2; lk < expr.length; lk++) ls.push(renderStr(expr[lk], local));
+ return ls.join("");
+ }
+ if (name === "begin" || name === "do") {
+ var bs = [];
+ for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env));
+ return bs.join("");
+ }
+ if (name === "define" || name === "defcomp") { sexpEval(expr, env); return ""; }
+
+ if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env);
+
+ if (name.charAt(0) === "~") {
+ var comp = env[name];
+ if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env);
+ }
+
+ return renderStr(sexpEval(expr, env), env);
+ }
+
+ function renderStrElement(tag, args, env) {
+ var attrs = [], children = [];
+ var i = 0;
+ while (i < args.length) {
+ if (isKw(args[i]) && i + 1 < args.length) {
+ var aname = args[i].name, aval = sexpEval(args[i + 1], env);
+ i += 2;
+ if (isNil(aval) || aval === false) continue;
+ if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); }
+ else if (aval === true) attrs.push(" " + aname);
+ else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"');
+ } else {
+ children.push(args[i]);
+ i++;
+ }
+ }
+ var open = "<" + tag + attrs.join("") + ">";
+ if (VOID_ELEMENTS[tag]) return open;
+ var inner = [];
+ for (var ci = 0; ci < children.length; ci++) inner.push(renderStr(children[ci], env));
+ return open + inner.join("") + "" + tag + ">";
+ }
+
+ function renderStrComponent(comp, args, env) {
+ var kwargs = {}, children = [];
+ var i = 0;
+ while (i < args.length) {
+ if (isKw(args[i]) && i + 1 < args.length) {
+ kwargs[args[i].name] = sexpEval(args[i + 1], env);
+ i += 2;
+ } else { children.push(args[i]); i++; }
+ }
+ var local = merge({}, comp.closure, env);
+ for (var pi = 0; pi < comp.params.length; pi++) {
+ var p = comp.params[pi];
+ local[p] = (p in kwargs) ? kwargs[p] : NIL;
+ }
+ if (comp.hasChildren) {
+ var cs = [];
+ for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env));
+ local["children"] = new RawHTML(cs.join(""));
+ }
+ return renderStr(comp.body, local);
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ function merge(target) {
+ for (var i = 1; i < arguments.length; i++) {
+ var src = arguments[i];
+ if (src) for (var k in src) target[k] = src[k];
+ }
+ return target;
+ }
+
+ function makeSet(str) {
+ var s = {}, parts = str.split(/\s+/);
+ for (var i = 0; i < parts.length; i++) if (parts[i]) s[parts[i]] = true;
+ return s;
+ }
+
+ /** Convert snake_case kwargs to kebab-case for sexp conventions. */
+ function toKebab(s) { return s.replace(/_/g, "-"); }
+
+ // =========================================================================
+ // Public API
+ // =========================================================================
+
+ var _componentEnv = {};
+
+ var Sexp = {
+ // Types
+ NIL: NIL,
+ Symbol: Symbol,
+ Keyword: Keyword,
+
+ // Parser
+ parse: parse,
+ parseAll: parseAll,
+
+ // Evaluator
+ eval: function (expr, env) { return sexpEval(expr, env || _componentEnv); },
+
+ // DOM Renderer
+ render: function (exprOrText, extraEnv) {
+ var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText;
+ var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
+ return renderDOM(expr, env);
+ },
+
+ // String Renderer (matches Python html.render output)
+ renderToString: function (exprOrText, extraEnv) {
+ var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText;
+ var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
+ return renderStr(expr, env);
+ },
+
+ /**
+ * Render a named component with keyword args (Python-style API).
+ * Sexp.renderComponent("card", {title: "Hi"})
+ */
+ renderComponent: function (name, kwargs, extraEnv) {
+ var fullName = name.charAt(0) === "~" ? name : "~" + name;
+ var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
+ var comp = env[fullName];
+ if (!isComponent(comp)) throw new Error("Unknown component: " + fullName);
+ // Build a synthetic call expression
+ var callExpr = [new Symbol(fullName)];
+ if (kwargs) {
+ for (var k in kwargs) {
+ callExpr.push(new Keyword(toKebab(k)));
+ callExpr.push(kwargs[k]);
+ }
+ }
+ return renderDOM(callExpr, env);
+ },
+
+ // Component management
+ loadComponents: function (text) {
+ var exprs = parseAll(text);
+ for (var i = 0; i < exprs.length; i++) sexpEval(exprs[i], _componentEnv);
+ },
+
+ getEnv: function () { return _componentEnv; },
+
+ // Utility
+ isTruthy: isSexpTruthy,
+ isNil: isNil,
+
+ // For testing
+ _types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
+ _eval: sexpEval,
+ _renderStr: renderStr,
+ _renderDOM: renderDOM,
+ };
+
+ global.Sexp = Sexp;
+
+})(typeof window !== "undefined" ? window : this);