From e8a991834ba074498394ecc9c7a9a566f78fe33a Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 28 Feb 2026 23:28:21 +0000 Subject: [PATCH] Add sexp.js: client-side s-expression parser, evaluator, and DOM renderer Vanilla JS (no build tools) counterpart to shared/sexp/ Python modules. Parses s-expression text, evaluates special forms, and renders to DOM nodes or HTML strings. Full component system with defcomp/~name. Includes: - Parser: tokenizer + parse/parseAll matching Python parser exactly - Evaluator: all special forms (if, when, cond, let, and, or, lambda, defcomp, define, ->, set!), higher-order forms (map, filter, reduce) - DOM renderer: createElement for HTML tags, SVG namespace support, component invocation, raw! for pre-rendered HTML, <> fragments - String renderer: matches Python html.render output for SSR parity - ~50 built-in primitives (arithmetic, string, collection, predicates) - 35 parity tests verifying JS output matches Python output via Node.js Also fixes Python raw! handler to properly unwrap _RawHTML objects. Co-Authored-By: Claude Opus 4.6 --- shared/sexp/html.py | 4 +- shared/sexp/tests/test_sexp_js.py | 175 +++++ shared/static/scripts/sexp.js | 1153 +++++++++++++++++++++++++++++ 3 files changed, 1331 insertions(+), 1 deletion(-) create mode 100644 shared/sexp/tests/test_sexp_js.py create mode 100644 shared/static/scripts/sexp.js 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 == '

text

' + + 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 == '

inside

' + + 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("") + ""; + } + + 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);