Files
mono/shared/static/scripts/sx.js
giles 1447122a0c Add on-demand CSS: registry, pre-computed component classes, header compression
- Parse tw.css into per-class lookup registry at startup
- Pre-scan component CSS classes at registration time (avoid per-request regex)
- Compress SX-Css header: 8-char hash replaces full class list (LRU cache)
- Add ;@css comment annotation for dynamically constructed class names
- Safelist bg-sky-{100..400} in Tailwind config for menu-row-sx dynamic shades
- Client sends/receives hash, falls back gracefully on cache miss

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:39:57 +00:00

2368 lines
85 KiB
JavaScript

/**
* sx.js — S-expression parser, evaluator, and DOM renderer. [v2-debug]
*
* 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, "/").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);
// Pre-rendered DOM node → return as-is
if (expr && expr.nodeType) return expr;
// 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);
}
/** True when the array expr is a render-only form (HTML tag, <>, raw!, ~comp). */
function _isRenderExpr(v) {
if (!Array.isArray(v) || !v.length) return false;
var h = v[0];
if (!isSym(h)) return false;
var n = h.name;
return !!(HTML_TAGS[n] || SVG_TAGS[n] || n === "<>" || n === "raw!" || n.charAt(0) === "~");
}
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 in the caller's env so expressions
// like (get t "src") resolve while lambda params are still bound.
var v = args[i + 1];
if (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v)) {
kwargs[args[i].name] = v;
} else if (isSym(v)) {
kwargs[args[i].name] = sxEval(v, env);
} else if (Array.isArray(v) && v.length && isSym(v[0])) {
// Expression with Symbol head — evaluate in caller's env.
// Render-only forms go through renderDOM; data exprs through sxEval.
if (_isRenderExpr(v)) {
kwargs[args[i].name] = renderDOM(v, env);
} else {
kwargs[args[i].name] = sxEval(v, env);
}
} else {
// Data arrays, dicts, etc — pass through as-is
kwargs[args[i].name] = 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) {
// 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;
// Scripts in innerHTML don't execute — recreate them as live elements
var deadScripts = tpl.content.querySelectorAll("script");
for (var si = 0; si < deadScripts.length; si++) {
var dead = deadScripts[si];
var live = document.createElement("script");
for (var ai = 0; ai < dead.attributes.length; ai++)
live.setAttribute(dead.attributes[ai].name, dead.attributes[ai].value);
live.textContent = dead.textContent;
dead.parentNode.replaceChild(live, dead);
}
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 isRawText = (tag === "script" || tag === "style");
var inner = [];
for (var ci = 0; ci < children.length; ci++) {
var child = children[ci];
if (isRawText && typeof child === "string") inner.push(child);
else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env)));
else inner.push(renderStr(child, 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) {
// Evaluate kwarg values eagerly in the caller's env so expressions
// like (get t "src") resolve while lambda params are still bound.
var v = args[i + 1];
if (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v)) {
kwargs[args[i].name] = v;
} else if (isSym(v)) {
kwargs[args[i].name] = sxEval(v, env);
} else if (Array.isArray(v) && v.length && isSym(v[0])) {
// Expression with Symbol head — evaluate in caller's env.
// Render-only forms go through renderStr; data exprs through sxEval.
if (_isRenderExpr(v)) {
kwargs[args[i].name] = new RawHTML(renderStr(v, env));
} else {
kwargs[args[i].name] = sxEval(v, env);
}
} else {
// Data arrays, dicts, etc — pass through as-is
kwargs[args[i].name] = 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) {
try {
var exprs = parseAll(text);
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
} catch (err) {
console.error("sx.js loadComponents error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
throw err;
}
},
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";
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(",");
// Send known CSS classes so server only sends new rules
var cssHeader = _getSxCssHeader();
if (cssHeader) headers["SX-Css"] = cssHeader;
// 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 });
// Process the response
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
var target = resolveTarget(el, null);
var selectSel = el.getAttribute("sx-select");
// Check for text/sx content type — use direct DOM rendering path
var ct = resp.headers.get("Content-Type") || "";
if (ct.indexOf("text/sx") >= 0) {
try {
// Strip and load any <script type="text/sx" data-components> blocks
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { Sx.loadComponents(defs); return ""; });
// Process on-demand CSS: extract <style data-sx-css> and inject into head
text = _processCssResponse(text, resp);
var sxSource = text.trim();
// Parse and render to live DOM nodes (skip renderToString + DOMParser)
if (sxSource && sxSource.charAt(0) !== "(") {
console.error("sx.js: sxSource does not start with '(' — first 200 chars:", sxSource.substring(0, 200));
}
var sxDom = Sx.render(sxSource);
// Wrap in container for querySelectorAll (DocumentFragment doesn't support it)
var container = document.createElement("div");
container.appendChild(sxDom);
// OOB processing on live DOM nodes
var oobs = container.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");
oob.parentNode.removeChild(oob);
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
});
// hx-swap-oob compat
var hxOobs = container.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");
oob.parentNode.removeChild(oob);
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
});
// sx-select filtering
var selectedDOM;
if (selectSel) {
selectedDOM = document.createDocumentFragment();
selectSel.split(",").forEach(function (sel) {
container.querySelectorAll(sel.trim()).forEach(function (m) {
selectedDOM.appendChild(m);
});
});
} else {
// Use all remaining children
selectedDOM = document.createDocumentFragment();
while (container.firstChild) selectedDOM.appendChild(container.firstChild);
}
// Main swap using DOM morph
if (swapStyle !== "none" && target) {
_swapDOM(target, selectedDOM, swapStyle);
_hoistHeadElements(target);
}
} catch (err) {
console.error("sx.js render error [v2]:", err, "\nsxSource first 500:", sxSource ? sxSource.substring(0, 500) : "(empty)");
return;
}
} else {
// HTML string path — existing DOMParser pipeline
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
// Process any sx script blocks in the response
Sx.processScripts(doc);
// OOB processing
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);
});
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) {
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);
_hoistHeadElements(target);
}
}
// History
var pushUrl = el.getAttribute("sx-push-url");
if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) {
var pushTarget = pushUrl === "true" ? url : pushUrl;
try {
history.pushState({ sxUrl: pushTarget, scrollY: window.scrollY }, "", pushTarget);
} catch (e) {
// Cross-origin pushState not allowed — full navigation
location.assign(pushTarget);
return;
}
}
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);
});
}
// ---- DOM morphing ------------------------------------------------------
/**
* Lightweight DOM reconciler — patches oldNode to match newNode in-place,
* preserving event listeners, focus, scroll position, and form state on
* keyed (id) elements.
*/
function _morphDOM(oldNode, newNode) {
// Different node types or tag names → replace wholesale
if (oldNode.nodeType !== newNode.nodeType ||
oldNode.nodeName !== newNode.nodeName) {
oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
return;
}
// Text/comment nodes → update content
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
if (oldNode.nodeValue !== newNode.nodeValue)
oldNode.nodeValue = newNode.nodeValue;
return;
}
// Element nodes → sync attributes, then recurse children
if (oldNode.nodeType === 1) {
// Skip morphing focused input to preserve user's in-progress edits
if (oldNode === document.activeElement &&
(oldNode.tagName === "INPUT" || oldNode.tagName === "TEXTAREA" || oldNode.tagName === "SELECT")) {
_syncAttrs(oldNode, newNode); // sync non-value attrs (class, style, etc.)
return; // don't touch value or children
}
_syncAttrs(oldNode, newNode);
_morphChildren(oldNode, newNode);
}
}
function _syncAttrs(old, neu) {
// Add/update attributes from new
var newAttrs = neu.attributes;
for (var i = 0; i < newAttrs.length; i++) {
var a = newAttrs[i];
if (old.getAttribute(a.name) !== a.value)
old.setAttribute(a.name, a.value);
}
// Remove attributes not in new
var oldAttrs = old.attributes;
for (var j = oldAttrs.length - 1; j >= 0; j--) {
if (!neu.hasAttribute(oldAttrs[j].name))
old.removeAttribute(oldAttrs[j].name);
}
}
function _morphChildren(oldParent, newParent) {
var oldChildren = Array.prototype.slice.call(oldParent.childNodes);
var newChildren = Array.prototype.slice.call(newParent.childNodes);
// Build ID map of old children for keyed matching
var oldById = {};
for (var k = 0; k < oldChildren.length; k++) {
var kid = oldChildren[k];
if (kid.id) oldById[kid.id] = kid;
}
var oi = 0;
for (var ni = 0; ni < newChildren.length; ni++) {
var newChild = newChildren[ni];
var matchById = newChild.id ? oldById[newChild.id] : null;
if (matchById) {
// Keyed match — move into position if needed, then morph
if (matchById !== oldChildren[oi]) {
oldParent.insertBefore(matchById, oldChildren[oi] || null);
}
_morphDOM(matchById, newChild);
oi++;
} else if (oi < oldChildren.length) {
// Positional match — morph in place
var oldChild = oldChildren[oi];
if (oldChild.id && !newChild.id) {
// Old has ID, new doesn't — insert new before old (don't clobber keyed)
oldParent.insertBefore(newChild.cloneNode(true), oldChild);
} else {
_morphDOM(oldChild, newChild);
oi++;
}
} else {
// Extra new children — append
oldParent.appendChild(newChild.cloneNode(true));
}
}
// Remove leftover old children
while (oi < oldChildren.length) {
var leftover = oldChildren[oi];
if (leftover.parentNode === oldParent) oldParent.removeChild(leftover);
oi++;
}
}
// ---- DOM-native swap engine --------------------------------------------
/**
* Swap using live DOM nodes (from Sx.render) instead of HTML strings.
* Uses _morphDOM for innerHTML/outerHTML to preserve state.
*/
function _swapDOM(target, newNodes, strategy) {
// newNodes is a DocumentFragment, Element, or Text node
var wrapper;
switch (strategy) {
case "innerHTML":
// Morph children of target to match newNodes
if (newNodes.nodeType === 11) {
// DocumentFragment — morph its children into target
_morphChildren(target, newNodes);
} else {
wrapper = document.createElement("div");
wrapper.appendChild(newNodes);
_morphChildren(target, wrapper);
}
break;
case "outerHTML":
var parent = target.parentNode;
if (newNodes.nodeType === 11) {
// Fragment — morph first child, insert rest
var first = newNodes.firstChild;
if (first) {
_morphDOM(target, first);
var sib = first.nextSibling; // skip first (used as morph template, not consumed)
while (sib) {
var next = sib.nextSibling;
parent.insertBefore(sib, target.nextSibling);
sib = next;
}
} else {
parent.removeChild(target);
}
} else {
_morphDOM(target, newNodes);
}
_activateScripts(parent);
Sx.processScripts(parent);
Sx.hydrate(parent);
SxEngine.process(parent);
return; // early return like existing outerHTML
case "afterend":
target.parentNode.insertBefore(newNodes, target.nextSibling);
break;
case "beforeend":
target.appendChild(newNodes);
break;
case "afterbegin":
target.insertBefore(newNodes, target.firstChild);
break;
case "beforebegin":
target.parentNode.insertBefore(newNodes, target);
break;
case "delete":
target.parentNode.removeChild(target);
return;
default: // fallback = innerHTML
if (newNodes.nodeType === 11) {
_morphChildren(target, newNodes);
} else {
wrapper = document.createElement("div");
wrapper.appendChild(newNodes);
_morphChildren(target, wrapper);
}
}
_activateScripts(target);
Sx.processScripts(target);
Sx.hydrate(target);
SxEngine.process(target);
}
// ---- Swap engine (string-based, kept as fallback) ----------------------
/** Scripts inserted via innerHTML/insertAdjacentHTML don't execute.
* Recreate them as live elements so the browser fetches & runs them. */
function _activateScripts(root) {
var dead = root.querySelectorAll("script:not([type]), script[type='text/javascript']");
for (var i = 0; i < dead.length; i++) {
var d = dead[i];
var live = document.createElement("script");
for (var a = 0; a < d.attributes.length; a++)
live.setAttribute(d.attributes[a].name, d.attributes[a].value);
live.textContent = d.textContent;
d.parentNode.replaceChild(live, d);
}
}
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
_activateScripts(parent);
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;
}
_activateScripts(target);
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 --------------------------------------------------
if (typeof window !== "undefined") {
window.addEventListener("popstate", function (e) {
var url = location.href;
var main = document.getElementById("main-panel");
if (!main) { location.reload(); return; }
var histHeaders = { "SX-Request": "true", "SX-History-Restore": "true" };
var cssH = _getSxCssHeader();
if (cssH) histHeaders["SX-Css"] = cssH;
var loadedN = Object.keys(_componentEnv).filter(function (k) { return k.charAt(0) === "~"; });
if (loadedN.length) histHeaders["SX-Components"] = loadedN.join(",");
var histOpts = { headers: histHeaders };
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 (t) { return { text: t, resp: resp }; });
}).then(function (r) {
var text = r.text;
var resp = r.resp;
// Strip and load any <script type="text/sx" data-components> blocks
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { Sx.loadComponents(defs); return ""; });
// Process on-demand CSS
text = _processCssResponse(text, resp);
text = text.trim();
if (text.charAt(0) === "(") {
// sx response — render to live DOM, morph into main
try {
var popDom = Sx.render(text);
var popContainer = document.createElement("div");
popContainer.appendChild(popDom);
var newMain = popContainer.querySelector("#main-panel");
_morphChildren(main, newMain || popContainer);
_activateScripts(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0);
} catch (err) {
console.error("sx.js popstate render error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
location.reload();
}
} else {
// HTML response — parse and morph
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
var newMain = doc.getElementById("main-panel");
if (newMain) {
_morphChildren(main, newMain);
_activateScripts(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0);
} else {
location.reload();
}
}
}).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-01c-cssx";
// CSS class tracking for on-demand CSS delivery
var _sxCssKnown = {};
var _sxCssHash = ""; // 8-char hex hash from server
function _initCssTracking() {
var meta = document.querySelector('meta[name="sx-css-classes"]');
if (meta) {
var content = meta.getAttribute("content");
if (content) {
// If content is short (≤16 chars), it's a hash from the server
if (content.length <= 16) {
_sxCssHash = content;
} else {
content.split(",").forEach(function (c) {
if (c) _sxCssKnown[c] = true;
});
}
}
}
}
function _getSxCssHeader() {
// Prefer sending the hash (compact) over the full class list
if (_sxCssHash) return _sxCssHash;
var names = Object.keys(_sxCssKnown);
return names.length ? names.join(",") : "";
}
function _processCssResponse(text, resp) {
// Read SX-Css-Hash response header — replaces local hash
var hashHeader = resp.headers.get("SX-Css-Hash");
if (hashHeader) _sxCssHash = hashHeader;
// Merge SX-Css-Add header into known set (kept for debugging/fallback)
var addHeader = resp.headers.get("SX-Css-Add");
if (addHeader) {
addHeader.split(",").forEach(function (c) {
if (c) _sxCssKnown[c] = true;
});
}
// Extract <style data-sx-css>...</style> blocks and inject into <style id="sx-css">
var cssTarget = document.getElementById("sx-css");
if (cssTarget) {
text = text.replace(/<style[^>]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi,
function (_, css) {
cssTarget.textContent += css;
return "";
});
}
return text;
}
if (typeof document !== "undefined") {
var init = function () {
console.log("[sx.js] v" + Sx.VERSION + " init");
_initCssTracking();
Sx.processScripts();
Sx.hydrate();
SxEngine.process();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}
})(typeof window !== "undefined" ? window : this);