Files
mono/shared/static/scripts/sx.js
giles 2b41aaa6ce Fix renderComponentDOM evaluating kwarg expressions in wrong scope
renderComponentDOM was deferring evaluation of complex expressions
(arrays) passed as component kwargs, storing raw AST instead.  When the
component body later used these values as attributes, the caller's env
(with lambda params like t, a) was no longer available, producing
stringified arrays like "get,t,src" as attribute values — which browsers
interpreted as relative URLs.

Evaluate all non-literal kwarg values eagerly in the caller's env,
matching the behavior of callComponent and the Python-side renderer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:40:50 +00:00

2000 lines
70 KiB
JavaScript

/**
* sx.js — S-expression parser, evaluator, and DOM renderer.
*
* Client-side counterpart to shared/sx/ Python modules.
* Parses s-expression text, evaluates it, and renders to DOM nodes.
*
* Usage:
* Sx.loadComponents('(defcomp ~card (&key title) (div :class "c" title))');
* const node = Sx.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 sx but we match Python semantics where
// only nil/false/None are falsy for control flow. Revisit if needed.
function isSxTruthy(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);
}
var ctx = this.text.substring(Math.max(0, this.pos - 40), this.pos + 40);
throw parseErr("Unexpected character: " + ch + " | context: «" + ctx.replace(/\n/g, "\\n") + "»", 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 sx
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 !isSxTruthy(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 sxEval(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] = sxEval(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 sxEval(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 = sxEval(head, env);
var args = [];
for (var ai = 1; ai < expr.length; ai++) args.push(sxEval(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 sxEval(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] = sxEval(rawArgs[i + 1], env);
i += 2;
} else {
children.push(sxEval(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 sxEval(comp.body, local);
}
// --- Special forms -------------------------------------------------------
var SPECIAL_FORMS = {};
SPECIAL_FORMS["if"] = function (expr, env) {
var cond = sxEval(expr[1], env);
if (isSxTruthy(cond)) return sxEval(expr[2], env);
return expr.length > 3 ? sxEval(expr[3], env) : NIL;
};
SPECIAL_FORMS["when"] = function (expr, env) {
if (!isSxTruthy(sxEval(expr[1], env))) return NIL;
var result = NIL;
for (var i = 2; i < expr.length; i++) result = sxEval(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 sxEval(clauses[i][1], env);
if (isSxTruthy(sxEval(test, env))) return sxEval(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 sxEval(clauses[j + 1], env);
if (isSxTruthy(sxEval(t, env))) return sxEval(clauses[j + 1], env);
}
}
return NIL;
};
SPECIAL_FORMS["case"] = function (expr, env) {
var val = sxEval(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 sxEval(expr[i + 1], env);
if (val == sxEval(t, env)) return sxEval(expr[i + 1], env);
}
return NIL;
};
SPECIAL_FORMS["and"] = function (expr, env) {
var result = true;
for (var i = 1; i < expr.length; i++) {
result = sxEval(expr[i], env);
if (!isSxTruthy(result)) return result;
}
return result;
};
SPECIAL_FORMS["or"] = function (expr, env) {
var result = false;
for (var i = 1; i < expr.length; i++) {
result = sxEval(expr[i], env);
if (isSxTruthy(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] = sxEval(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] = sxEval(bindings[j + 1], local);
}
}
}
var result = NIL;
for (var k = 2; k < expr.length; k++) result = sxEval(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 = sxEval(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 = sxEval(expr[i], env);
return result;
};
SPECIAL_FORMS["quote"] = function (expr) { return expr[1]; };
SPECIAL_FORMS["set!"] = function (expr, env) {
var v = sxEval(expr[2], env);
env[expr[1].name] = v;
return v;
};
SPECIAL_FORMS["->"] = function (expr, env) {
var result = sxEval(expr[1], env);
for (var i = 2; i < expr.length; i++) {
var form = expr[i];
var fn, args;
if (Array.isArray(form)) {
fn = sxEval(form[0], env);
args = [result];
for (var j = 1; j < form.length; j++) args.push(sxEval(form[j], env));
} else {
fn = sxEval(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 = sxEval(expr[1], env), coll = sxEval(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 = sxEval(expr[1], env), coll = sxEval(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 = sxEval(expr[1], env), coll = sxEval(expr[2], env);
return coll.filter(function (item) {
var r = isLambda(fn) ? callLambda(fn, [item], env) : fn(item);
return isSxTruthy(r);
});
};
HO_FORMS["reduce"] = function (expr, env) {
var fn = sxEval(expr[1], env), acc = sxEval(expr[2], env), coll = sxEval(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 = sxEval(expr[1], env), coll = sxEval(expr[2], env);
for (var i = 0; i < coll.length; i++) {
var r = isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
if (isSxTruthy(r)) return r;
}
return NIL;
};
HO_FORMS["every?"] = function (expr, env) {
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
for (var i = 0; i < coll.length; i++) {
if (!isSxTruthy(isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]))) return false;
}
return true;
};
HO_FORMS["for-each"] = function (expr, env) {
var fn = sxEval(expr[1], env), coll = sxEval(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(sxEval(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 = sxEval(expr[1], env);
if (isSxTruthy(cond)) return renderDOM(expr[2], env);
return expr.length > 3 ? renderDOM(expr[3], env) : document.createDocumentFragment();
};
RENDER_FORMS["when"] = function (expr, env) {
if (!isSxTruthy(sxEval(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 (isSxTruthy(sxEval(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 (isSxTruthy(sxEval(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]] = sxEval(bindings[i][1], local);
}
} else {
for (var j = 0; j < bindings.length; j += 2) {
local[isSym(bindings[j]) ? bindings[j].name : bindings[j]] = sxEval(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) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["map"] = function (expr, env) {
var fn = sxEval(expr[1], env), coll = sxEval(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 = sxEval(expr[1], env), coll = sxEval(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 = sxEval(expr, env);
return renderDOM(result, env);
};
RENDER_FORMS["for-each"] = function (expr, env) {
var fn = sxEval(expr[1], env), coll = sxEval(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) {
// Evaluate kwarg values eagerly so expressions like (get t "src")
// resolve in the caller's env (where lambda params are bound).
var v = args[i + 1];
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v))
? v : sxEval(v, 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 = sxEval(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);
// Unknown component — render a visible warning, don't crash
console.warn("sx.js: unknown component " + name);
var warn = document.createElement("div");
warn.setAttribute("style",
"background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;" +
"padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace");
warn.textContent = "Unknown component: " + name;
return warn;
}
// Fallback: evaluate then render
return renderDOM(sxEval(expr, env), env);
}
// Lambda/list head → evaluate
if (isLambda(head) || Array.isArray(head)) return renderDOM(sxEval(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 = sxEval(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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function escapeAttr(s) { return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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(sxEval(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 = sxEval(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 isSxTruthy(sxEval(expr[1], env))
? renderStr(expr[2], env)
: (expr.length > 3 ? renderStr(expr[3], env) : "");
}
if (name === "when") {
if (!isSxTruthy(sxEval(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]] = sxEval(bindings[li][1], local);
}
} else {
for (var lj = 0; lj < bindings.length; lj += 2) {
local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(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") { sxEval(expr, env); return ""; }
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
if (name === "map") {
var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env);
if (!Array.isArray(mapColl)) return "";
var mapParts = [];
for (var mi = 0; mi < mapColl.length; mi++) {
if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env));
else mapParts.push(renderStr(mapFn(mapColl[mi]), env));
}
return mapParts.join("");
}
if (name === "map-indexed") {
var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env);
if (!Array.isArray(mixColl)) return "";
var mixParts = [];
for (var mxi = 0; mxi < mixColl.length; mxi++) {
if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env));
else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env));
}
return mixParts.join("");
}
if (name === "filter") {
var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env);
if (!Array.isArray(filtColl)) return "";
var filtParts = [];
for (var fli = 0; fli < filtColl.length; fli++) {
var keep = isLambda(filtFn) ? callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]);
if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env));
}
return filtParts.join("");
}
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);
// Unknown component — return visible warning
console.warn("sx.js: unknown component " + name);
return '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
'Unknown component: ' + escapeText(name) + '</div>';
}
return renderStr(sxEval(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 = sxEval(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 renderLambdaStr(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 renderStr(fn.body, local);
}
function renderStrComponent(comp, args, env) {
var kwargs = {}, children = [];
var i = 0;
while (i < args.length) {
if (isKw(args[i]) && i + 1 < args.length) {
var v = args[i + 1];
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v))
? v : (isSym(v) ? sxEval(v, env) : v);
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 sx conventions. */
function toKebab(s) { return s.replace(/_/g, "-"); }
// =========================================================================
// Public API
// =========================================================================
var _componentEnv = {};
// =========================================================================
// Head auto-hoist: move meta/title/link/script[ld+json] from body to <head>
// =========================================================================
var HEAD_HOIST_SELECTOR =
"meta, title, link[rel='canonical'], script[type='application/ld+json']";
function _hoistHeadElements(root) {
var els = root.querySelectorAll(HEAD_HOIST_SELECTOR);
if (!els.length) return;
var head = document.head;
for (var i = 0; i < els.length; i++) {
var el = els[i];
var tag = el.tagName.toLowerCase();
// For <title>, replace existing
if (tag === "title") {
document.title = el.textContent || "";
el.parentNode.removeChild(el);
continue;
}
// For <meta>, remove existing with same name/property to avoid duplicates
if (tag === "meta") {
var name = el.getAttribute("name");
var prop = el.getAttribute("property");
if (name) {
var old = head.querySelector('meta[name="' + name + '"]');
if (old) old.parentNode.removeChild(old);
}
if (prop) {
var old2 = head.querySelector('meta[property="' + prop + '"]');
if (old2) old2.parentNode.removeChild(old2);
}
}
// For <link rel=canonical>, remove existing
if (tag === "link" && el.getAttribute("rel") === "canonical") {
var oldLink = head.querySelector('link[rel="canonical"]');
if (oldLink) oldLink.parentNode.removeChild(oldLink);
}
// Move from body to head
el.parentNode.removeChild(el);
head.appendChild(el);
}
}
var Sx = {
// Types
NIL: NIL,
Symbol: Symbol,
Keyword: Keyword,
// Parser
parse: parse,
parseAll: parseAll,
// Evaluator
eval: function (expr, env) { return sxEval(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).
* Sx.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++) sxEval(exprs[i], _componentEnv);
},
getEnv: function () { return _componentEnv; },
// Utility
isTruthy: isSxTruthy,
isNil: isNil,
/**
* Mount a sx expression into a DOM element, replacing its contents.
* Sx.mount(el, '(~card :title "Hi")')
* Sx.mount("#target", '(~card :title "Hi")')
* Sx.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 = Sx.render(exprOrText, extraEnv);
el.textContent = "";
el.appendChild(node);
// Auto-hoist head elements (meta, title, link, script[ld+json]) to <head>
_hoistHeadElements(el);
// Process sx- attributes and hydrate the newly mounted content
if (typeof SxEngine !== "undefined") SxEngine.process(el);
Sx.hydrate(el);
},
/**
* Process all <script type="text/sx"> 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/sx"]');
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s._sxProcessed) continue;
s._sxProcessed = true;
var text = s.textContent;
if (!text || !text.trim()) continue;
// data-components: load as component definitions
if (s.hasAttribute("data-components")) {
Sx.loadComponents(text);
continue;
}
// data-mount="<selector>": render into target
var mountSel = s.getAttribute("data-mount");
if (mountSel) {
var target = document.querySelector(mountSel);
if (target) Sx.mount(target, text);
continue;
}
// Default: load as components
Sx.loadComponents(text);
}
},
/**
* Bind client-side sx rendering to elements with data-sx-* attrs.
*
* Pattern:
* <div data-sx="(~card :title title)" data-sx-env='{"title":"Hi"}'>
* <!-- server-rendered HTML (hydration target) -->
* </div>
*
* Call Sx.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-sx");
if (!source) return;
var baseEnv = {};
var envAttr = el.getAttribute("data-sx-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-sx-env", JSON.stringify(baseEnv));
}
},
/**
* Find all [data-sx] elements within root and render them.
* Useful after HTMX swaps bring in new sx-enabled elements.
*/
hydrate: function (root) {
var els = (root || document).querySelectorAll("[data-sx]");
for (var i = 0; i < els.length; i++) {
if (els[i]._sxHydrated) continue;
els[i]._sxHydrated = true;
Sx.update(els[i]);
}
},
// For testing
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
_eval: sxEval,
_renderStr: renderStr,
_renderDOM: renderDOM,
};
global.Sx = Sx;
// =========================================================================
// SxEngine — native fetch/swap/history engine (replaces HTMX)
// =========================================================================
var SxEngine = (function () {
if (typeof document === "undefined") return {};
// ---- helpers ----------------------------------------------------------
var PROCESSED = "_sxBound";
var VERBS = ["get", "post", "put", "delete", "patch"];
var DEFAULT_SWAP = "outerHTML";
var HISTORY_MAX = 20;
function dispatch(el, name, detail) {
var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} });
return el.dispatchEvent(evt);
}
function csrfToken() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.getAttribute("content") : null;
}
function sameOrigin(url) {
try { return new URL(url, location.href).origin === location.origin; } catch (e) { return true; }
}
function resolveTarget(el, attr) {
var sel = el.getAttribute("sx-target") || attr;
if (!sel || sel === "this") return el;
if (sel === "closest") return el.parentElement;
return document.querySelector(sel);
}
function getVerb(el) {
for (var i = 0; i < VERBS.length; i++) {
var v = VERBS[i];
if (el.hasAttribute("sx-" + v)) return { method: v.toUpperCase(), url: el.getAttribute("sx-" + v) };
}
return null;
}
// ---- Sync manager -----------------------------------------------------
var _controllers = new WeakMap();
function abortPrevious(el) {
var prev = _controllers.get(el);
if (prev) prev.abort();
}
function trackController(el, ctrl) {
_controllers.set(el, ctrl);
}
// ---- Request executor -------------------------------------------------
function executeRequest(el, verbInfo, extraParams) {
var method = verbInfo.method;
var url = verbInfo.url;
// sx-media: skip if media query doesn't match
var media = el.getAttribute("sx-media");
if (media && !window.matchMedia(media).matches) return Promise.resolve();
// sx-confirm: show dialog first
var confirmMsg = el.getAttribute("sx-confirm");
if (confirmMsg) {
if (typeof Swal !== "undefined") {
return Swal.fire({
title: confirmMsg,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes",
cancelButtonText: "Cancel"
}).then(function (result) {
if (!result.isConfirmed) return;
return _doFetch(el, method, url, extraParams);
});
}
if (!window.confirm(confirmMsg)) return Promise.resolve();
}
return _doFetch(el, method, url, extraParams);
}
function _doFetch(el, method, url, extraParams) {
// sx-sync: abort previous
var sync = el.getAttribute("sx-sync");
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
var ctrl = new AbortController();
trackController(el, ctrl);
// Build headers
var headers = {
"SX-Request": "true",
"SX-Current-URL": location.href
};
var targetSel = el.getAttribute("sx-target");
if (targetSel) headers["SX-Target"] = targetSel;
// Send loaded component names so cross-domain responses can prepend missing defs
var loadedNames = Object.keys(_componentEnv).filter(function (k) {
return k.charAt(0) === "~";
});
if (loadedNames.length) headers["SX-Components"] = loadedNames.join(",");
// Extra headers from sx-headers
var extraH = el.getAttribute("sx-headers");
if (extraH) {
try {
var parsed = JSON.parse(extraH);
for (var k in parsed) headers[k] = parsed[k];
} catch (e) { /* ignore */ }
}
// CSRF for same-origin mutating requests
if (method !== "GET" && sameOrigin(url)) {
var csrf = csrfToken();
if (csrf) headers["X-CSRFToken"] = csrf;
}
// Build body
var body = null;
var isJson = el.getAttribute("sx-encoding") === "json";
if (method !== "GET") {
var form = el.closest("form") || (el.tagName === "FORM" ? el : null);
if (form) {
if (isJson) {
var fd = new FormData(form);
var obj = {};
fd.forEach(function (v, k) {
if (obj[k] !== undefined) {
if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
obj[k].push(v);
} else {
obj[k] = v;
}
});
body = JSON.stringify(obj);
headers["Content-Type"] = "application/json";
} else {
body = new URLSearchParams(new FormData(form));
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
}
}
// Include extra inputs
var includeSel = el.getAttribute("sx-include");
if (includeSel && method !== "GET") {
var extras = document.querySelectorAll(includeSel);
if (!body) body = new URLSearchParams();
extras.forEach(function (inp) {
if (inp.name) body.append(inp.name, inp.value);
});
}
// sx-vals: merge extra key-value pairs
var valsAttr = el.getAttribute("sx-vals");
if (valsAttr) {
try {
var vals = JSON.parse(valsAttr);
if (method === "GET") {
for (var vk in vals) {
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);
}
} else if (body instanceof URLSearchParams) {
for (var vk2 in vals) body.append(vk2, vals[vk2]);
} else if (!body) {
body = new URLSearchParams();
for (var vk3 in vals) body.append(vk3, vals[vk3]);
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
} catch (e) { /* ignore */ }
}
// For GET with form data, append to URL
if (method === "GET") {
var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null);
if (form2) {
var qs = new URLSearchParams(new FormData(form2)).toString();
if (qs) url += (url.indexOf("?") >= 0 ? "&" : "?") + qs;
}
// Also handle search inputs with name attr
if (el.tagName === "INPUT" && el.name) {
var param = encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value);
url += (url.indexOf("?") >= 0 ? "&" : "?") + param;
}
}
// Lifecycle: beforeRequest
if (!dispatch(el, "sx:beforeRequest", { method: method, url: url })) return Promise.resolve();
// Loading state
el.classList.add("sx-request");
el.setAttribute("aria-busy", "true");
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
// Cross-origin credentials for known subdomains
try {
var urlHost = new URL(url, location.href).hostname;
if (urlHost !== location.hostname &&
(urlHost.endsWith(".rose-ash.com") || urlHost.endsWith(".localhost"))) {
fetchOpts.credentials = "include";
}
} catch (e) {}
if (body && method !== "GET") fetchOpts.body = body;
return fetch(url, fetchOpts).then(function (resp) {
el.classList.remove("sx-request");
el.removeAttribute("aria-busy");
if (!resp.ok) {
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
return _handleRetry(el, verbInfo, extraParams);
}
return resp.text().then(function (text) {
dispatch(el, "sx:afterRequest", { response: resp });
// Check for text/sx content type
var ct = resp.headers.get("Content-Type") || "";
if (ct.indexOf("text/sx") >= 0) {
try { text = Sx.renderToString(text); }
catch (err) {
console.error("sx.js render error:", err);
return;
}
}
// Process the response
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
var target = resolveTarget(el, null);
// sx-select: extract subset from response
var selectSel = el.getAttribute("sx-select");
// Parse response into DOM for OOB + select processing
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
// Process any sx script blocks in the response (e.g. cross-domain component defs)
Sx.processScripts(doc);
// OOB processing: extract elements with sx-swap-oob
var oobs = doc.querySelectorAll("[sx-swap-oob]");
oobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("sx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
// Also support hx-swap-oob during migration
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
hxOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("hx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
// Build final content
var content;
if (selectSel) {
// sx-select may be comma-separated
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
var frags = [];
parts.forEach(function (sel) {
var matches = doc.querySelectorAll(sel);
matches.forEach(function (m) { frags.push(m.outerHTML); });
});
content = frags.join("");
} else {
content = doc.body ? doc.body.innerHTML : text;
}
// Main swap
if (swapStyle !== "none" && target) {
_swapContent(target, content, swapStyle);
// Auto-hoist any head elements that ended up in body
_hoistHeadElements(target);
}
// History
var pushUrl = el.getAttribute("sx-push-url");
if (pushUrl === "true") {
history.pushState({ sxUrl: url }, "", url);
} else if (pushUrl && pushUrl !== "false") {
history.pushState({ sxUrl: pushUrl }, "", pushUrl);
}
dispatch(el, "sx:afterSwap", { target: target });
// Settle tick
requestAnimationFrame(function () {
dispatch(el, "sx:afterSettle", { target: target });
});
});
}).catch(function (err) {
el.classList.remove("sx-request");
el.removeAttribute("aria-busy");
if (err.name === "AbortError") return;
dispatch(el, "sx:sendError", { error: err });
return _handleRetry(el, verbInfo, extraParams);
});
}
// ---- Swap engine ------------------------------------------------------
function _swapContent(target, html, strategy) {
switch (strategy) {
case "innerHTML":
target.innerHTML = html;
break;
case "outerHTML":
var tgt = target;
var parent = tgt.parentNode;
tgt.insertAdjacentHTML("afterend", html);
parent.removeChild(tgt);
// Process parent to catch all newly inserted siblings
Sx.processScripts(parent);
Sx.hydrate(parent);
SxEngine.process(parent);
return; // early return — afterSwap handling done inline
case "afterend":
target.insertAdjacentHTML("afterend", html);
break;
case "beforeend":
target.insertAdjacentHTML("beforeend", html);
break;
case "afterbegin":
target.insertAdjacentHTML("afterbegin", html);
break;
case "beforebegin":
target.insertAdjacentHTML("beforebegin", html);
break;
case "delete":
target.parentNode.removeChild(target);
return;
default:
target.innerHTML = html;
}
Sx.processScripts(target);
Sx.hydrate(target);
SxEngine.process(target);
}
// ---- Retry system -----------------------------------------------------
function _handleRetry(el, verbInfo, extraParams) {
var retry = el.getAttribute("sx-retry");
if (!retry) return;
var parts = retry.split(":");
var strategy = parts[0]; // "exponential"
var startMs = parseInt(parts[1], 10) || 1000;
var capMs = parseInt(parts[2], 10) || 30000;
var currentMs = parseInt(el.getAttribute("data-sx-retry-ms"), 10) || startMs;
el.classList.add("sx-error");
el.classList.remove("sx-loading");
setTimeout(function () {
el.classList.remove("sx-error");
el.classList.add("sx-loading");
el.setAttribute("data-sx-retry-ms", Math.min(currentMs * 2, capMs));
executeRequest(el, verbInfo, extraParams);
}, currentMs);
}
// ---- Trigger system ---------------------------------------------------
function parseTrigger(spec) {
if (!spec) return null;
var triggers = [];
var parts = spec.split(",");
for (var i = 0; i < parts.length; i++) {
var p = parts[i].trim();
if (!p) continue;
var tokens = p.split(/\s+/);
var trigger = { event: tokens[0], modifiers: {} };
for (var j = 1; j < tokens.length; j++) {
var tok = tokens[j];
if (tok === "once") trigger.modifiers.once = true;
else if (tok === "changed") trigger.modifiers.changed = true;
else if (tok.indexOf("delay:") === 0) trigger.modifiers.delay = parseInt(tok.substring(6), 10);
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
}
triggers.push(trigger);
}
return triggers;
}
function bindTriggers(el, verbInfo) {
var triggerSpec = el.getAttribute("sx-trigger");
var triggers;
if (triggerSpec) {
triggers = parseTrigger(triggerSpec);
} else {
// Defaults
if (el.tagName === "FORM") {
triggers = [{ event: "submit", modifiers: {} }];
} else if (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") {
triggers = [{ event: "change", modifiers: {} }];
} else {
triggers = [{ event: "click", modifiers: {} }];
}
}
triggers.forEach(function (trig) {
if (trig.event === "intersect") {
_bindIntersect(el, verbInfo, trig.modifiers);
} else if (trig.event === "load") {
setTimeout(function () { executeRequest(el, verbInfo); }, 0);
} else if (trig.event === "revealed") {
_bindIntersect(el, verbInfo, { once: true });
} else {
_bindEvent(el, verbInfo, trig);
}
});
}
function _bindEvent(el, verbInfo, trig) {
var eventName = trig.event;
var mods = trig.modifiers;
var listenTarget = mods.from ? document.querySelector(mods.from) || el : el;
var timer = null;
var lastVal = undefined;
var handler = function (e) {
// For form submissions, prevent default
if (eventName === "submit") e.preventDefault();
// For links, prevent navigation
if (eventName === "click" && el.tagName === "A") e.preventDefault();
// changed modifier: only fire if value changed
if (mods.changed && el.value !== undefined) {
if (el.value === lastVal) return;
lastVal = el.value;
}
if (mods.delay) {
clearTimeout(timer);
timer = setTimeout(function () { executeRequest(el, verbInfo); }, mods.delay);
} else {
executeRequest(el, verbInfo);
}
};
listenTarget.addEventListener(eventName, handler, { once: !!mods.once });
}
function _bindIntersect(el, verbInfo, mods) {
if (!("IntersectionObserver" in window)) {
executeRequest(el, verbInfo);
return;
}
var fired = false;
var delay = mods.delay || 0;
var obs = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
if (mods.once && fired) return;
fired = true;
if (mods.once) obs.unobserve(el);
if (delay) {
setTimeout(function () { executeRequest(el, verbInfo); }, delay);
} else {
executeRequest(el, verbInfo);
}
});
});
obs.observe(el);
}
// ---- History manager --------------------------------------------------
var _historyCache = {};
var _historyCacheKeys = [];
function _cacheCurrentPage() {
var key = location.href;
var main = document.getElementById("main-panel");
if (!main) return;
_historyCache[key] = main.innerHTML;
// LRU eviction
var idx = _historyCacheKeys.indexOf(key);
if (idx >= 0) _historyCacheKeys.splice(idx, 1);
_historyCacheKeys.push(key);
while (_historyCacheKeys.length > HISTORY_MAX) {
delete _historyCache[_historyCacheKeys.shift()];
}
}
if (typeof window !== "undefined") {
window.addEventListener("popstate", function (e) {
var url = location.href;
// Try cache first
if (_historyCache[url]) {
var main = document.getElementById("main-panel");
if (main) {
main.innerHTML = _historyCache[url];
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
return;
}
}
// Fetch fresh
var histOpts = {
headers: { "SX-Request": "true", "SX-History-Restore": "true" }
};
try {
var hHost = new URL(url, location.href).hostname;
if (hHost !== location.hostname &&
(hHost.endsWith(".rose-ash.com") || hHost.endsWith(".localhost"))) {
histOpts.credentials = "include";
}
} catch (e) {}
fetch(url, histOpts).then(function (resp) {
return resp.text();
}).then(function (text) {
var ct = "";
// Response content-type is lost here, check for sx
if (text.charAt(0) === "(") {
try { text = Sx.renderToString(text); } catch (e) { /* not sx */ }
}
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
var newMain = doc.getElementById("main-panel");
var main = document.getElementById("main-panel");
if (main && newMain) {
main.innerHTML = newMain.innerHTML;
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
}
}).catch(function () {
location.reload();
});
});
}
// ---- sx-on:* inline event handlers ------------------------------------
function _bindInlineHandlers(el) {
var attrs = el.attributes;
for (var i = 0; i < attrs.length; i++) {
var name = attrs[i].name;
if (name.indexOf("sx-on:") === 0) {
var evtName = name.substring(6);
el.addEventListener(evtName, new Function("event", attrs[i].value));
}
}
}
// ---- Process function -------------------------------------------------
function process(root) {
root = root || document.body;
if (!root || !root.querySelectorAll) return;
var selector = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]";
var elements = root.querySelectorAll(selector);
// Also check root itself
if (root.matches && root.matches(selector)) {
_processOne(root);
}
for (var i = 0; i < elements.length; i++) {
_processOne(elements[i]);
}
// Bind sx-on:* handlers on all elements
var allOnEls = root.querySelectorAll("[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]");
allOnEls.forEach(function (el) {
if (el[PROCESSED + "on"]) return;
el[PROCESSED + "on"] = true;
_bindInlineHandlers(el);
});
}
function _processOne(el) {
if (el[PROCESSED]) return;
// sx-disable: skip processing
if (el.hasAttribute("sx-disable") || el.closest("[sx-disable]")) return;
el[PROCESSED] = true;
var verbInfo = getVerb(el);
if (!verbInfo) return;
bindTriggers(el, verbInfo);
}
// ---- Public API -------------------------------------------------------
var engine = {
process: process,
executeRequest: executeRequest,
version: "1.0.0"
};
return engine;
})();
global.SxEngine = SxEngine;
// =========================================================================
// Auto-init in browser
// =========================================================================
Sx.VERSION = "2026-03-01a";
if (typeof document !== "undefined") {
var init = function () {
console.log("[sx.js] v" + Sx.VERSION + " init");
Sx.processScripts();
Sx.hydrate();
SxEngine.process();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Cache current page before navigation
document.addEventListener("sx:beforeRequest", function () {
if (typeof SxEngine._cacheCurrentPage === "function") SxEngine._cacheCurrentPage();
});
}
})(typeof window !== "undefined" ? window : this);