Adds Sexp.update() for re-rendering data-sexp elements with new data, Sexp.hydrate() for finding and rendering all [data-sexp] elements, and auto-init on DOMContentLoaded + htmx:afterSwap integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1267 lines
44 KiB
JavaScript
1267 lines
44 KiB
JavaScript
/**
|
|
* 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, "<").replace(/>/g, ">"); }
|
|
function escapeAttr(s) { return s.replace(/&/g, "&").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,
|
|
|
|
/**
|
|
* Mount a sexp expression into a DOM element, replacing its contents.
|
|
* Sexp.mount(el, '(~card :title "Hi")')
|
|
* Sexp.mount("#target", '(~card :title "Hi")')
|
|
* Sexp.mount(el, '(~card :title name)', {name: "Jo"})
|
|
*/
|
|
mount: function (target, exprOrText, extraEnv) {
|
|
var el = typeof target === "string" ? document.querySelector(target) : target;
|
|
if (!el) return;
|
|
var node = Sexp.render(exprOrText, extraEnv);
|
|
el.textContent = "";
|
|
el.appendChild(node);
|
|
},
|
|
|
|
/**
|
|
* Process all <script type="text/sexp"> tags in the document.
|
|
* Tags with data-components load component definitions.
|
|
* Tags with data-mount="<selector>" render into that element.
|
|
*/
|
|
processScripts: function (root) {
|
|
var scripts = (root || document).querySelectorAll('script[type="text/sexp"]');
|
|
for (var i = 0; i < scripts.length; i++) {
|
|
var s = scripts[i];
|
|
if (s._sexpProcessed) continue;
|
|
s._sexpProcessed = true;
|
|
|
|
var text = s.textContent;
|
|
if (!text || !text.trim()) continue;
|
|
|
|
// data-components: load as component definitions
|
|
if (s.hasAttribute("data-components")) {
|
|
Sexp.loadComponents(text);
|
|
continue;
|
|
}
|
|
|
|
// data-mount="<selector>": render into target
|
|
var mountSel = s.getAttribute("data-mount");
|
|
if (mountSel) {
|
|
var target = document.querySelector(mountSel);
|
|
if (target) Sexp.mount(target, text);
|
|
continue;
|
|
}
|
|
|
|
// Default: load as components
|
|
Sexp.loadComponents(text);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Bind client-side sexp rendering to elements with data-sexp-* attrs.
|
|
*
|
|
* Pattern:
|
|
* <div data-sexp="(~card :title title)" data-sexp-env='{"title":"Hi"}'>
|
|
* <!-- server-rendered HTML (hydration target) -->
|
|
* </div>
|
|
*
|
|
* Call Sexp.update(el, {title: "New"}) to re-render with new data.
|
|
*/
|
|
update: function (target, newEnv) {
|
|
var el = typeof target === "string" ? document.querySelector(target) : target;
|
|
if (!el) return;
|
|
var source = el.getAttribute("data-sexp");
|
|
if (!source) return;
|
|
var baseEnv = {};
|
|
var envAttr = el.getAttribute("data-sexp-env");
|
|
if (envAttr) {
|
|
try { baseEnv = JSON.parse(envAttr); } catch (e) { /* ignore */ }
|
|
}
|
|
var env = merge({}, _componentEnv, baseEnv, newEnv || {});
|
|
var node = renderDOM(parse(source), env);
|
|
el.textContent = "";
|
|
el.appendChild(node);
|
|
if (newEnv) {
|
|
merge(baseEnv, newEnv);
|
|
el.setAttribute("data-sexp-env", JSON.stringify(baseEnv));
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Find all [data-sexp] elements within root and render them.
|
|
* Useful after HTMX swaps bring in new sexp-enabled elements.
|
|
*/
|
|
hydrate: function (root) {
|
|
var els = (root || document).querySelectorAll("[data-sexp]");
|
|
for (var i = 0; i < els.length; i++) {
|
|
if (els[i]._sexpHydrated) continue;
|
|
els[i]._sexpHydrated = true;
|
|
Sexp.update(els[i]);
|
|
}
|
|
},
|
|
|
|
// For testing
|
|
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
|
|
_eval: sexpEval,
|
|
_renderStr: renderStr,
|
|
_renderDOM: renderDOM,
|
|
};
|
|
|
|
global.Sexp = Sexp;
|
|
|
|
// =========================================================================
|
|
// Auto-init in browser
|
|
// =========================================================================
|
|
|
|
if (typeof document !== "undefined") {
|
|
var init = function () {
|
|
Sexp.processScripts();
|
|
Sexp.hydrate();
|
|
};
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
// Re-process after HTMX swaps
|
|
document.addEventListener("htmx:afterSwap", function (e) {
|
|
Sexp.processScripts(e.detail.target);
|
|
Sexp.hydrate(e.detail.target);
|
|
});
|
|
}
|
|
|
|
})(typeof window !== "undefined" ? window : this);
|