Files
mono/shared/static/scripts/sx.js
giles d551806976 Add SVG namespace auto-detection, custom elements, html: prefix, and fix filter/map tag collision
- Fix filter/map dispatching as HO functions when used as SVG/HTML tags
  (peek at first arg — Keyword means tag call, not function call)
- Add html: prefix escape hatch to force any name to render as an element
- Support custom elements (hyphenated names) per Web Components spec
- SVG/MathML namespace auto-detection: client threads ns param through
  render chain; server uses _svg_context ContextVar so unknown tags
  inside (svg ...) or (math ...) render as elements without enumeration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:53:08 +00:00

3112 lines
113 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 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;
function Macro(params, restParam, body, closure, name) {
this.params = params;
this.restParam = restParam;
this.body = body;
this.closure = closure || {};
this.name = name || null;
}
Macro.prototype._macro = true;
/** Thunk — deferred evaluation for tail-call optimization. */
function _Thunk(expr, env) { this.expr = expr; this.env = env; }
_Thunk.prototype._thunk = true;
function isThunk(x) { return x && x._thunk; }
/** Marker for pre-rendered HTML that bypasses escaping. */
function RawHTML(html) { this.html = html; }
RawHTML.prototype._raw = true;
/** CSSX StyleValue — generated CSS class with rules. */
function StyleValue(className, declarations, mediaRules, pseudoRules, keyframes) {
this.className = className;
this.declarations = declarations || "";
this.mediaRules = mediaRules || [];
this.pseudoRules = pseudoRules || [];
this.keyframes = keyframes || [];
}
StyleValue.prototype._styleValue = 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 isMacro(x) { return x && x._macro === true; }
function isRaw(x) { return x && x._raw === true; }
function isStyleValue(x) { return x && x._styleValue === true; }
// --- Parser ---
var RE_WS = /\s+/y;
var RE_COMMENT = /;[^\n]*/y;
var RE_STRING = /"(?:[^"\\]|\\[\s\S])*"/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) {
// Use peek() (raw character) for structural decisions so that string
// values like ")" or "(" don't get confused with actual delimiters.
var raw = tok.peek();
if (raw === null) throw parseErr("Unexpected end of input", tok);
if (raw === ")" || raw === "]" || raw === "}") {
tok.next(); // consume the delimiter
throw parseErr("Unexpected " + raw, tok);
}
if (raw === "(") { tok.next(); return parseList(tok, ")"); }
if (raw === "[") { tok.next(); return parseList(tok, "]"); }
if (raw === "{") { tok.next(); return parseMap(tok); }
// Quasiquote syntax
if (raw === "`") { tok._advance(1); return [new Symbol("quasiquote"), parseExpr(tok)]; }
if (raw === ",") {
tok._advance(1);
if (tok.pos < tok.text.length && tok.text[tok.pos] === "@") {
tok._advance(1);
return [new Symbol("splice-unquote"), parseExpr(tok)];
}
return [new Symbol("unquote"), parseExpr(tok)];
}
return tok.next();
}
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;
};
// --- CSSX Style Dictionary + Resolver ---
var _styleAtoms = {}; // atom → CSS declarations
var _pseudoVariants = {}; // variant → CSS pseudo-selector
var _responsiveBreakpoints = {}; // variant → media query
var _styleKeyframes = {}; // name → @keyframes rule
var _arbitraryPatterns = []; // [{re: RegExp, tmpl: string}, ...]
var _childSelectorPrefixes = []; // ["space-x-", "space-y-", ...]
var _styleCache = {}; // atoms-key → StyleValue
var _injectedStyles = {}; // className → true (already in <style>)
function _loadStyleDict(data) {
_styleAtoms = data.a || {};
_pseudoVariants = data.v || {};
_responsiveBreakpoints = data.b || {};
_styleKeyframes = data.k || {};
_childSelectorPrefixes = data.c || [];
_arbitraryPatterns = [];
var pats = data.p || [];
for (var i = 0; i < pats.length; i++) {
_arbitraryPatterns.push({ re: new RegExp("^" + pats[i][0] + "$"), tmpl: pats[i][1] });
}
_styleCache = {};
}
function _splitVariant(atom) {
// Check responsive prefix first
for (var bp in _responsiveBreakpoints) {
var prefix = bp + ":";
if (atom.indexOf(prefix) === 0) {
var rest = atom.substring(prefix.length);
for (var pv in _pseudoVariants) {
var inner = pv + ":";
if (rest.indexOf(inner) === 0) return [bp + ":" + pv, rest.substring(inner.length)];
}
return [bp, rest];
}
}
for (var pv2 in _pseudoVariants) {
var prefix2 = pv2 + ":";
if (atom.indexOf(prefix2) === 0) return [pv2, atom.substring(prefix2.length)];
}
return [null, atom];
}
function _resolveAtom(atom) {
var decls = _styleAtoms[atom];
if (decls !== undefined) return decls;
// Dynamic keyframes: animate-{name} → animation-name:{name}
if (atom.indexOf("animate-") === 0) {
var kfName = atom.substring(8);
if (_styleKeyframes[kfName]) return "animation-name:" + kfName;
}
for (var i = 0; i < _arbitraryPatterns.length; i++) {
var m = atom.match(_arbitraryPatterns[i].re);
if (m) {
var result = _arbitraryPatterns[i].tmpl;
for (var j = 1; j < m.length; j++) result = result.replace("{" + (j - 1) + "}", m[j]);
return result;
}
}
return null;
}
function _isChildSelectorAtom(atom) {
for (var i = 0; i < _childSelectorPrefixes.length; i++) {
if (atom.indexOf(_childSelectorPrefixes[i]) === 0) return true;
}
return false;
}
/** SHA-256 hash (first 6 hex chars) for content-addressed class names. */
function _hashStyle(input) {
// Simple FNV-1a 32-bit hash — fast, deterministic, good distribution
var h = 0x811c9dc5;
for (var i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0").substring(0, 6);
}
function _resolveStyle(atoms) {
var key = atoms.join("\0");
if (_styleCache[key]) return _styleCache[key];
var baseDecls = [], mediaRules = [], pseudoRules = [], kfNeeded = [];
for (var i = 0; i < atoms.length; i++) {
var a = atoms[i];
if (!a) continue;
if (a.charAt(0) === ":") a = a.substring(1);
var parts = _splitVariant(a);
var variant = parts[0], base = parts[1];
var decls = _resolveAtom(base);
if (!decls) continue;
// Check keyframes
if (base.indexOf("animate-") === 0) {
var kfName = base.substring(8);
if (_styleKeyframes[kfName]) kfNeeded.push([kfName, _styleKeyframes[kfName]]);
}
if (variant === null) {
baseDecls.push(decls);
} else if (_responsiveBreakpoints[variant]) {
mediaRules.push([_responsiveBreakpoints[variant], decls]);
} else if (_pseudoVariants[variant]) {
pseudoRules.push([_pseudoVariants[variant], decls]);
} else {
// Compound variant: "sm:hover" → split
var vparts = variant.split(":");
var mediaPart = null, pseudoPart = null;
for (var vi = 0; vi < vparts.length; vi++) {
if (_responsiveBreakpoints[vparts[vi]]) mediaPart = _responsiveBreakpoints[vparts[vi]];
else if (_pseudoVariants[vparts[vi]]) pseudoPart = _pseudoVariants[vparts[vi]];
}
if (mediaPart) mediaRules.push([mediaPart, decls]);
if (pseudoPart) pseudoRules.push([pseudoPart, decls]);
if (!mediaPart && !pseudoPart) baseDecls.push(decls);
}
}
// Build hash input
var hashInput = baseDecls.join(";");
for (var mi = 0; mi < mediaRules.length; mi++) hashInput += "@" + mediaRules[mi][0] + "{" + mediaRules[mi][1] + "}";
for (var pi = 0; pi < pseudoRules.length; pi++) hashInput += pseudoRules[pi][0] + "{" + pseudoRules[pi][1] + "}";
for (var ki = 0; ki < kfNeeded.length; ki++) hashInput += kfNeeded[ki][1];
var cn = "sx-" + _hashStyle(hashInput);
var sv = new StyleValue(cn, baseDecls.join(";"), mediaRules, pseudoRules, kfNeeded);
_styleCache[key] = sv;
// Inject CSS rules into <style id="sx-css">
_injectStyleValue(sv, atoms);
return sv;
}
function _injectStyleValue(sv, atoms) {
if (_injectedStyles[sv.className]) return;
_injectedStyles[sv.className] = true;
var cssTarget = document.getElementById("sx-css");
if (!cssTarget) return;
var rules = [];
if (sv.declarations) {
// Check if any atoms need child selectors
var hasChild = false;
if (atoms) {
for (var ai = 0; ai < atoms.length; ai++) {
if (_isChildSelectorAtom(atoms[ai])) { hasChild = true; break; }
}
}
if (hasChild) {
rules.push("." + sv.className + ">:not(:first-child){" + sv.declarations + "}");
} else {
rules.push("." + sv.className + "{" + sv.declarations + "}");
}
}
for (var pi = 0; pi < sv.pseudoRules.length; pi++) {
var sel = sv.pseudoRules[pi][0], decls = sv.pseudoRules[pi][1];
if (sel.indexOf("&") >= 0) {
rules.push(sel.replace(/&/g, "." + sv.className) + "{" + decls + "}");
} else {
rules.push("." + sv.className + sel + "{" + decls + "}");
}
}
for (var mi = 0; mi < sv.mediaRules.length; mi++) {
rules.push("@media " + sv.mediaRules[mi][0] + "{." + sv.className + "{" + sv.mediaRules[mi][1] + "}}");
}
for (var ki = 0; ki < sv.keyframes.length; ki++) {
rules.push(sv.keyframes[ki][1]);
}
cssTarget.textContent += rules.join("");
}
function _mergeStyleValues(styles) {
if (styles.length === 1) return styles[0];
var allDecls = [], allMedia = [], allPseudo = [], allKf = [];
var allAtoms = [];
for (var i = 0; i < styles.length; i++) {
var sv = styles[i];
if (sv.declarations) allDecls.push(sv.declarations);
allMedia = allMedia.concat(sv.mediaRules);
allPseudo = allPseudo.concat(sv.pseudoRules);
allKf = allKf.concat(sv.keyframes);
}
var hashInput = allDecls.join(";");
for (var mi = 0; mi < allMedia.length; mi++) hashInput += "@" + allMedia[mi][0] + "{" + allMedia[mi][1] + "}";
for (var pi = 0; pi < allPseudo.length; pi++) hashInput += allPseudo[pi][0] + "{" + allPseudo[pi][1] + "}";
for (var ki = 0; ki < allKf.length; ki++) hashInput += allKf[ki][1];
var cn = "sx-" + _hashStyle(hashInput);
var merged = new StyleValue(cn, allDecls.join(";"), allMedia, allPseudo, allKf);
_injectStyleValue(merged, allAtoms);
return merged;
}
// css primitive: (css :flex :gap-4 :hover:bg-sky-200) → StyleValue
PRIMITIVES["css"] = function () {
var atoms = [];
for (var i = 0; i < arguments.length; i++) {
var a = arguments[i];
if (isNil(a) || a === false) continue;
atoms.push(isKw(a) ? a.name : String(a));
}
if (!atoms.length) return NIL;
return _resolveStyle(atoms);
};
// merge-styles: combine multiple StyleValues
PRIMITIVES["merge-styles"] = function () {
var valid = [];
for (var i = 0; i < arguments.length; i++) {
if (isStyleValue(arguments[i])) valid.push(arguments[i]);
}
if (!valid.length) return NIL;
if (valid.length === 1) return valid[0];
return _mergeStyleValues(valid);
};
// --- Evaluator ---
/** Unwrap thunks by re-entering the evaluator until we get an actual value. */
function trampoline(val) {
while (isThunk(val)) val = _sxEval(val.expr, val.env);
return val;
}
/** Public evaluator — trampolines thunks from tail positions. */
function sxEval(expr, env) { return trampoline(_sxEval(expr, env)); }
/** Internal evaluator — may return _Thunk for tail positions. */
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) {
// If name is also an HTML tag and first arg is Keyword → not a HO call
if (!(HTML_TAGS[head.name] && expr.length > 1 && isKw(expr[1]))) return ho(expr, env);
}
// Macro expansion
if (head.name in env) {
var macroVal = env[head.name];
if (isMacro(macroVal)) {
var expanded = expandMacro(macroVal, expr.slice(1), env);
return new _Thunk(expanded, 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 new _Thunk(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 new _Thunk(comp.body, local);
}
// --- Shared helpers for special/render forms ---
function _processBindings(bindings, env) {
var local = merge({}, env);
if (Array.isArray(bindings)) {
if (bindings.length && Array.isArray(bindings[0])) {
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 {
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);
}
}
}
return local;
}
function _evalCond(clauses, env) {
if (!clauses.length) return null;
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 clauses[i][1];
if (isSxTruthy(sxEval(test, env))) return clauses[i][1];
}
} 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 clauses[j + 1];
if (isSxTruthy(sxEval(t, env))) return clauses[j + 1];
}
}
return null;
}
function _logParseError(label, text, err, windowSize) {
var colMatch = err.message && err.message.match(/col (\d+)/);
var lineMatch = err.message && err.message.match(/line (\d+)/);
if (colMatch && text) {
var errLine = lineMatch ? parseInt(lineMatch[1]) : 1;
var errCol = parseInt(colMatch[1]);
var lines = text.split("\n");
var pos = 0;
for (var i = 0; i < errLine - 1 && i < lines.length; i++) pos += lines[i].length + 1;
pos += errCol;
var start = Math.max(0, pos - windowSize);
var end = Math.min(text.length, pos + windowSize);
console.error("sx.js " + label + ":", err.message,
"\n total length:", text.length, "lines:", lines.length,
"\n error line " + errLine + ":", lines[errLine - 1] ? lines[errLine - 1].substring(0, 200) : "(no such line)",
"\n around error (pos ~" + pos + "):",
"\n «" + text.substring(start, pos) + "⛔" + text.substring(pos, end) + "»");
} else {
console.error("sx.js " + label + ":", err.message || err);
}
}
// --- Special forms -------------------------------------------------------
var SPECIAL_FORMS = {};
SPECIAL_FORMS["if"] = function (expr, env) {
var cond = sxEval(expr[1], env);
if (isSxTruthy(cond)) return new _Thunk(expr[2], env);
return expr.length > 3 ? new _Thunk(expr[3], env) : NIL;
};
SPECIAL_FORMS["when"] = function (expr, env) {
if (!isSxTruthy(sxEval(expr[1], env))) return NIL;
for (var i = 2; i < expr.length - 1; i++) sxEval(expr[i], env);
return new _Thunk(expr[expr.length - 1], env);
};
SPECIAL_FORMS["cond"] = function (expr, env) {
var branch = _evalCond(expr.slice(1), env);
return branch ? new _Thunk(branch, env) : 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 new _Thunk(expr[i + 1], env);
if (val == sxEval(t, env)) return new _Thunk(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 local = _processBindings(expr[1], env);
for (var k = 2; k < expr.length - 1; k++) sxEval(expr[k], local);
return expr.length > 2 ? new _Thunk(expr[expr.length - 1], local) : NIL;
};
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["defstyle"] = function (expr, env) {
var name = expr[1].name;
var value = sxEval(expr[2], env);
env[name] = value;
return value;
};
SPECIAL_FORMS["defkeyframes"] = function (expr, env) {
var kfName = expr[1].name;
var steps = [];
for (var i = 2; i < expr.length; i++) {
var step = expr[i];
var selector = isSym(step[0]) ? step[0].name : String(step[0]);
var body = sxEval(step[1], env);
var decls = isStyleValue(body) ? body.declarations : String(body);
steps.push(selector + "{" + decls + "}");
}
var kfRule = "@keyframes " + kfName + "{" + steps.join("") + "}";
// Register in keyframes dict for animate-{name} lookup
_styleKeyframes[kfName] = kfRule;
_styleCache = {}; // Clear cache so new keyframes are picked up
// Build a StyleValue for the animation
var cn = "sx-" + _hashStyle(kfRule);
var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]);
_injectStyleValue(sv, []);
env[kfName] = sv;
return sv;
};
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) {
for (var i = 1; i < expr.length - 1; i++) sxEval(expr[i], env);
return expr.length > 1 ? new _Thunk(expr[expr.length - 1], env) : NIL;
};
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 = trampoline(callLambda(fn, args, env));
else throw new Error("-> form not callable: " + fn);
}
return result;
};
SPECIAL_FORMS["defmacro"] = function (expr, env) {
var nameSym = expr[1];
var paramsExpr = expr[2];
var params = [], restParam = null;
for (var i = 0; i < paramsExpr.length; i++) {
var p = paramsExpr[i];
if (isSym(p) && p.name === "&rest") {
if (i + 1 < paramsExpr.length) {
var rp = paramsExpr[i + 1];
restParam = isSym(rp) ? rp.name : String(rp);
}
break;
}
if (isSym(p)) params.push(p.name);
else if (typeof p === "string") params.push(p);
}
var macro = new Macro(params, restParam, expr[3], merge({}, env), nameSym.name);
env[nameSym.name] = macro;
return macro;
};
SPECIAL_FORMS["quasiquote"] = function (expr, env) {
return qqExpand(expr[1], env);
};
function qqExpand(template, env) {
if (!Array.isArray(template)) return template;
if (!template.length) return [];
var head = template[0];
if (isSym(head)) {
if (head.name === "unquote") return sxEval(template[1], env);
if (head.name === "splice-unquote") throw new Error("splice-unquote not inside a list");
}
var result = [];
for (var i = 0; i < template.length; i++) {
var item = template[i];
if (Array.isArray(item) && item.length === 2 && isSym(item[0]) && item[0].name === "splice-unquote") {
var spliced = sxEval(item[1], env);
if (Array.isArray(spliced)) { for (var j = 0; j < spliced.length; j++) result.push(spliced[j]); }
else if (!isNil(spliced)) result.push(spliced);
} else {
result.push(qqExpand(item, env));
}
}
return result;
}
function expandMacro(macro, rawArgs, env) {
var local = merge({}, macro.closure, env);
for (var i = 0; i < macro.params.length; i++) {
local[macro.params[i]] = i < rawArgs.length ? rawArgs[i] : NIL;
}
if (macro.restParam !== null) {
local[macro.restParam] = rawArgs.slice(macro.params.length);
}
return sxEval(macro.body, local);
}
// --- 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) ? trampoline(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) ? trampoline(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) ? trampoline(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) ? trampoline(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) ? trampoline(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) ? trampoline(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) ? trampoline(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 " +
"feTurbulence feColorMatrix feBlend " +
"feComponentTransfer feFuncR feFuncG feFuncB feFuncA " +
"feDisplacementMap feComposite feFlood feImage " +
"feMorphology feSpecularLighting feDiffuseLighting " +
"fePointLight feSpotLight feDistantLight " +
"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 " +
"feTurbulence feColorMatrix feBlend " +
"feComponentTransfer feFuncR feFuncG feFuncB feFuncA " +
"feDisplacementMap feComposite feFlood feImage " +
"feMorphology feSpecularLighting feDiffuseLighting " +
"fePointLight feSpotLight feDistantLight " +
"animate animateTransform"
);
var SVG_NS = "http://www.w3.org/2000/svg";
var MATH_NS = "http://www.w3.org/1998/Math/MathML";
/**
* Render an s-expression to DOM node(s).
* Returns a DocumentFragment, Element, or Text node.
* @param {*} expr - s-expression
* @param {Object} env - variable bindings
* @param {string|null} ns - namespace URI (SVG_NS or MATH_NS) when inside svg/math
*/
function renderDOM(expr, env, ns) {
// 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, ns);
// 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, ns);
}
return document.createTextNode(String(expr));
}
/** Render-aware special forms for DOM output. */
var RENDER_FORMS = {};
RENDER_FORMS["if"] = function (expr, env, ns) {
var cond = sxEval(expr[1], env);
if (isSxTruthy(cond)) return renderDOM(expr[2], env, ns);
return expr.length > 3 ? renderDOM(expr[3], env, ns) : document.createDocumentFragment();
};
RENDER_FORMS["when"] = function (expr, env, ns) {
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, ns));
return frag;
};
RENDER_FORMS["cond"] = function (expr, env, ns) {
var branch = _evalCond(expr.slice(1), env);
return branch ? renderDOM(branch, env, ns) : document.createDocumentFragment();
};
RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env, ns) {
var local = _processBindings(expr[1], env);
var frag = document.createDocumentFragment();
for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local, ns));
return frag;
};
RENDER_FORMS["begin"] = RENDER_FORMS["do"] = function (expr, env, ns) {
var frag = document.createDocumentFragment();
for (var i = 1; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env, ns));
return frag;
};
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defstyle"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defkeyframes"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defmacro"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defhandler"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["map"] = function (expr, env, ns) {
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, ns) : renderDOM(fn(coll[i]), env, ns);
frag.appendChild(val);
}
return frag;
};
RENDER_FORMS["map-indexed"] = function (expr, env, ns) {
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, ns) : renderDOM(fn(i, coll[i]), env, ns);
frag.appendChild(val);
}
return frag;
};
RENDER_FORMS["filter"] = function (expr, env, ns) {
var result = sxEval(expr, env);
return renderDOM(result, env, ns);
};
RENDER_FORMS["for-each"] = function (expr, env, ns) {
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, ns) : renderDOM(fn(coll[i]), env, ns);
frag.appendChild(val);
}
return frag;
};
function renderLambdaDOM(fn, args, env, ns) {
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, ns);
}
/** 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) === "~" || n.indexOf("html:") === 0 || n.indexOf("-") > 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, ns) {
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, ns));
return f;
}
// html: prefix → force element rendering
if (name.indexOf("html:") === 0) return renderElement(name.substring(5), expr.slice(1), env, ns);
// Render-aware special forms
// If name is also an HTML tag and first arg is Keyword → tag call
if (RENDER_FORMS[name]) {
if (HTML_TAGS[name] && expr.length > 1 && isKw(expr[1])) return renderElement(name, expr.slice(1), env, ns);
return RENDER_FORMS[name](expr, env, ns);
}
// Macro expansion
if (name in env && isMacro(env[name])) {
var mExpanded = expandMacro(env[name], expr.slice(1), env);
return renderDOM(mExpanded, env, ns);
}
// HTML tag
if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env, ns);
// 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;
}
// Custom element (hyphenated name) → render as element
if (name.indexOf("-") > 0) return renderElement(name, expr.slice(1), env, ns);
// SVG/MathML namespace auto-detection: inside (svg ...) or (math ...),
// unknown tags are created with the inherited namespace
if (ns) return renderElement(name, expr.slice(1), env, ns);
// Fallback: evaluate then render
return renderDOM(sxEval(expr, env), env, ns);
}
// Lambda/list head → evaluate
if (isLambda(head) || Array.isArray(head)) return renderDOM(sxEval(expr, env), env, ns);
// Data list
var dl = document.createDocumentFragment();
for (var di = 0; di < expr.length; di++) dl.appendChild(renderDOM(expr[di], env, ns));
return dl;
}
function renderElement(tag, args, env, ns) {
// Detect namespace from tag: svg → SVG_NS, math → MATH_NS
if (tag === "svg") ns = SVG_NS;
else if (tag === "math") ns = MATH_NS;
var el = ns
? document.createElementNS(ns, tag)
: (SVG_TAGS[tag] ? document.createElementNS(SVG_NS, tag) : document.createElement(tag));
var extraClass = null;
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;
// :style StyleValue → convert to class
if (attrName === "style" && isStyleValue(attrVal)) {
extraClass = attrVal.className;
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, ns));
}
i++;
}
}
// Merge StyleValue class into element's class attribute
if (extraClass) {
var existing = el.getAttribute("class");
el.setAttribute("class", existing ? existing + " " + extraClass : extraClass);
}
return el;
}
// --- 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 ---
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 env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
if (typeof exprOrText === "string") {
// Try single expression first; fall back to multi-expression fragment
try {
return renderDOM(parse(exprOrText), env);
} catch (e) {
var exprs = parseAll(exprOrText);
if (exprs.length === 0) throw e;
var frag = document.createDocumentFragment();
for (var i = 0; i < exprs.length; i++) {
var node = renderDOM(exprs[i], env);
if (node) frag.appendChild(node);
}
return frag;
}
}
return renderDOM(exprOrText, 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) {
_logParseError("loadComponents PARSE ERROR", text, err, 120);
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;
try {
node = Sx.render(exprOrText, extraEnv);
} catch (e) {
if (typeof exprOrText === "string") _logParseError("MOUNT PARSE ERROR", exprOrText, e, 80);
throw e;
}
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;
// data-components: check before empty-text guard (may load from localStorage)
if (s.hasAttribute("data-components")) {
var hash = s.getAttribute("data-hash");
if (hash) {
var hasInline = text && text.trim();
try {
var cachedHash = localStorage.getItem("sx-components-hash");
if (cachedHash === hash) {
// Cache hit
if (hasInline) {
// Server sent full source (cookie was missing/stale) — update cache
localStorage.setItem("sx-components-src", text);
Sx.loadComponents(text);
console.log("[sx.js] components: downloaded (cookie stale)");
} else {
// Server omitted source — load from cache
var cached = localStorage.getItem("sx-components-src");
if (cached) {
Sx.loadComponents(cached);
console.log("[sx.js] components: cached (" + hash + ")");
} else {
// Cache entry missing — clear cookie and reload to get full source
_clearSxCompCookie();
location.reload();
return;
}
}
} else {
// Cache miss — hash mismatch
if (hasInline) {
// Server sent full source — parse and cache
localStorage.setItem("sx-components-hash", hash);
localStorage.setItem("sx-components-src", text);
Sx.loadComponents(text);
console.log("[sx.js] components: downloaded (" + hash + ")");
} else {
// Server omitted source but our cache is stale — clear and reload
localStorage.removeItem("sx-components-hash");
localStorage.removeItem("sx-components-src");
_clearSxCompCookie();
location.reload();
return;
}
}
} catch (e) {
// localStorage unavailable — fall back to inline
if (hasInline) Sx.loadComponents(text);
}
_setSxCompCookie(hash);
} else {
// Legacy: no hash attribute — just load inline
if (text && text.trim()) Sx.loadComponents(text);
}
continue;
}
if (!text || !text.trim()) 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 / sx-test.js
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML, StyleValue: StyleValue },
_eval: sxEval,
_expandMacro: expandMacro,
_callLambda: function (fn, args, env) { return trampoline(callLambda(fn, args, env)); },
_renderDOM: renderDOM,
};
global.Sx = Sx;
// --- SxEngine — native fetch/swap/history engine ---
var SxEngine = (function () {
if (typeof document === "undefined") return {};
// ---- helpers ----------------------------------------------------------
var PROCESSED = "_sxBound";
var VERBS = ["get", "post", "put", "delete", "patch"];
var DEFAULT_SWAP = "outerHTML";
var _config = { globalViewTransitions: false };
/** Wrap a function in View Transition API if supported and enabled. */
function _withTransition(enabled, fn) {
if (enabled && document.startViewTransition) {
document.startViewTransition(fn);
} else {
fn();
}
}
function dispatch(el, name, detail) {
var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} });
return el.dispatchEvent(evt);
}
/** Parse and dispatch SX-Trigger header events.
* Value can be: "myEvent" (plain string), '{"myEvent": {"key": "val"}}' (JSON). */
function _dispatchTriggerEvents(el, headerVal) {
if (!headerVal) return;
try {
var parsed = JSON.parse(headerVal);
if (typeof parsed === "object" && parsed !== null) {
for (var evtName in parsed) dispatch(el, evtName, parsed[evtName]);
} else {
dispatch(el, String(parsed), {});
}
} catch (e) {
// Plain string — may be comma-separated event names
headerVal.split(",").forEach(function (name) {
var n = name.trim();
if (n) dispatch(el, n, {});
});
}
}
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) {
// Re-read verb from element in case attributes were morphed since binding
var currentVerb = getVerb(el);
if (currentVerb) verbInfo = currentVerb;
var method = verbInfo.method;
var url = verbInfo.url;
// Reset retry backoff on fresh (non-retry) requests
if (!el.classList.contains("sx-error")) {
el.removeAttribute("data-sx-retry-ms");
}
// 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();
}
// sx-prompt: show prompt dialog, send result as SX-Prompt header
var promptMsg = el.getAttribute("sx-prompt");
if (promptMsg) {
var promptVal = window.prompt(promptMsg);
if (promptVal === null) return Promise.resolve(); // cancelled
extraParams = extraParams || {};
extraParams.promptValue = promptVal;
}
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 */ }
}
// SX-Prompt header from sx-prompt dialog result
if (extraParams && extraParams.promptValue !== undefined) {
headers["SX-Prompt"] = extraParams.promptValue;
}
// 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";
}
}
}
// sx-params: filter form parameters
var paramsSpec = el.getAttribute("sx-params");
if (paramsSpec && body instanceof URLSearchParams) {
if (paramsSpec === "none") {
body = new URLSearchParams();
} else if (paramsSpec.indexOf("not ") === 0) {
var excluded = paramsSpec.substring(4).split(",").map(function (s) { return s.trim(); });
excluded.forEach(function (k) { body.delete(k); });
} else if (paramsSpec !== "*") {
var allowed = paramsSpec.split(",").map(function (s) { return s.trim(); });
var filtered = new URLSearchParams();
allowed.forEach(function (k) {
body.getAll(k).forEach(function (v) { filtered.append(k, v); });
});
body = filtered;
}
}
// 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 inputs/selects/textareas with name attr
if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && 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");
// sx-indicator: show indicator element
var indicatorSel = el.getAttribute("sx-indicator");
var indicatorEl = indicatorSel ? (document.querySelector(indicatorSel) || el.closest(indicatorSel)) : null;
if (indicatorEl) {
indicatorEl.classList.add("sx-request");
indicatorEl.style.display = "";
}
// sx-disabled-elt: disable elements during request
var disabledEltSel = el.getAttribute("sx-disabled-elt");
var disabledElts = disabledEltSel ? Array.prototype.slice.call(document.querySelectorAll(disabledEltSel)) : [];
disabledElts.forEach(function (e) { e.disabled = 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;
// sx-preload: use cached response if available
var preloaded = method === "GET" ? _getPreloaded(url) : null;
var fetchPromise = preloaded
? Promise.resolve({ ok: true, status: 200, headers: new Headers({ "Content-Type": preloaded.contentType }), text: function () { return Promise.resolve(preloaded.text); }, _preloaded: true })
: fetch(url, fetchOpts);
return fetchPromise.then(function (resp) {
el.classList.remove("sx-request");
el.removeAttribute("aria-busy");
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
disabledElts.forEach(function (e) { e.disabled = false; });
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 });
// --- Response header processing ---
// SX-Redirect: navigate away (skip swap entirely)
var hdrRedirect = resp.headers.get("SX-Redirect");
if (hdrRedirect) { location.assign(hdrRedirect); return; }
// SX-Refresh: reload page (skip swap entirely)
var hdrRefresh = resp.headers.get("SX-Refresh");
if (hdrRefresh === "true") { location.reload(); return; }
// SX-Trigger: dispatch custom events on target
var hdrTrigger = resp.headers.get("SX-Trigger");
if (hdrTrigger) _dispatchTriggerEvents(el, hdrTrigger);
// Process the response
var rawSwap = el.getAttribute("sx-swap") || DEFAULT_SWAP;
var target = resolveTarget(el, null);
var selectSel = el.getAttribute("sx-select");
// SX-Retarget: server overrides target
var hdrRetarget = resp.headers.get("SX-Retarget");
if (hdrRetarget) target = document.querySelector(hdrRetarget) || target;
// SX-Reswap: server overrides swap strategy
var hdrReswap = resp.headers.get("SX-Reswap");
if (hdrReswap) rawSwap = hdrReswap;
// Parse swap style and modifiers (e.g. "innerHTML transition:true")
var swapParts = rawSwap.split(/\s+/);
var swapStyle = swapParts[0];
var useTransition = _config.globalViewTransitions;
for (var sp = 1; sp < swapParts.length; sp++) {
if (swapParts[sp] === "transition:true") useTransition = true;
else if (swapParts[sp] === "transition:false") useTransition = false;
}
// 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
_processOOBSwaps(container, _swapDOM);
// 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) {
_withTransition(useTransition, function () {
_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
_processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); });
// 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) {
_withTransition(useTransition, function () {
_swapContent(target, content, swapStyle);
_hoistHeadElements(target);
});
}
}
// SX-Location: server-driven client-side navigation
var hdrLocation = resp.headers.get("SX-Location");
if (hdrLocation) {
var locUrl = hdrLocation;
try { var locObj = JSON.parse(hdrLocation); locUrl = locObj.path || locObj; } catch (e) {}
fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function (r) {
return r.text().then(function (t) {
var main = document.getElementById("main-panel");
if (main) { _swapContent(main, t, "innerHTML"); _postSwap(main); }
try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {}
});
});
return;
}
// History: sx-push-url (pushState) and sx-replace-url (replaceState)
var pushUrl = el.getAttribute("sx-push-url");
var replaceUrl = el.getAttribute("sx-replace-url");
// SX-Replace-Url response header overrides client-side attribute
var hdrReplaceUrl = resp.headers.get("SX-Replace-Url");
if (hdrReplaceUrl) {
try { history.replaceState({ sxUrl: hdrReplaceUrl, scrollY: window.scrollY }, "", hdrReplaceUrl); } catch (e) {}
} else if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) {
var pushTarget = pushUrl === "true" ? url : pushUrl;
try {
history.pushState({ sxUrl: pushTarget, scrollY: window.scrollY }, "", pushTarget);
} catch (e) {
location.assign(pushTarget);
return;
}
} else if (replaceUrl === "true" || (replaceUrl && replaceUrl !== "false")) {
var replTarget = replaceUrl === "true" ? url : replaceUrl;
try {
history.replaceState({ sxUrl: replTarget, scrollY: window.scrollY }, "", replTarget);
} catch (e) { /* ignore */ }
}
dispatch(el, "sx:afterSwap", { target: target });
// SX-Trigger-After-Swap
var hdrTriggerSwap = resp.headers.get("SX-Trigger-After-Swap");
if (hdrTriggerSwap) _dispatchTriggerEvents(el, hdrTriggerSwap);
// Settle tick
requestAnimationFrame(function () {
dispatch(el, "sx:afterSettle", { target: target });
// SX-Trigger-After-Settle
var hdrTriggerSettle = resp.headers.get("SX-Trigger-After-Settle");
if (hdrTriggerSettle) _dispatchTriggerEvents(el, hdrTriggerSettle);
});
});
}).catch(function (err) {
el.classList.remove("sx-request");
el.removeAttribute("aria-busy");
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
disabledElts.forEach(function (e) { e.disabled = false; });
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) {
// sx-preserve / sx-ignore: skip morphing entirely
if (oldNode.hasAttribute && (oldNode.hasAttribute("sx-preserve") || oldNode.hasAttribute("sx-ignore"))) return;
// 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 (skip sx-preserve / sx-ignore)
while (oi < oldChildren.length) {
var leftover = oldChildren[oi];
if (leftover.parentNode === oldParent &&
!(leftover.hasAttribute && (leftover.hasAttribute("sx-preserve") || leftover.hasAttribute("sx-ignore")))) {
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);
}
_postSwap(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);
}
}
_postSwap(target);
}
// ---- Swap engine (string-based, kept as fallback) ----------------------
function _processOOBSwaps(container, swapFn, postSwapFn) {
["sx-swap-oob", "hx-swap-oob"].forEach(function (attr) {
container.querySelectorAll("[" + attr + "]").forEach(function (oob) {
var swapType = oob.getAttribute(attr) || "outerHTML";
var target = document.getElementById(oob.id);
oob.removeAttribute(attr);
if (oob.parentNode) oob.parentNode.removeChild(oob);
if (target) {
swapFn(target, oob, swapType);
if (postSwapFn) postSwapFn(target);
}
});
});
}
/** 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 _postSwap(root) {
_activateScripts(root);
Sx.processScripts(root);
Sx.hydrate(root);
SxEngine.process(root);
}
function _swapContent(target, html, strategy) {
switch (strategy) {
case "innerHTML":
// Detach sx-preserve elements, swap, then re-attach
var preserved = [];
target.querySelectorAll("[sx-preserve][id]").forEach(function (el) {
preserved.push({ id: el.id, node: el });
el.parentNode.removeChild(el);
});
target.innerHTML = html;
preserved.forEach(function (p) {
var placeholder = target.querySelector("#" + CSS.escape(p.id));
if (placeholder) placeholder.parentNode.replaceChild(p.node, placeholder);
else target.appendChild(p.node);
});
break;
case "outerHTML":
var tgt = target;
var parent = tgt.parentNode;
tgt.insertAdjacentHTML("afterend", html);
parent.removeChild(tgt);
_postSwap(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;
}
_postSwap(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 _parseTime(s) {
// Parse time string: "2s" → 2000, "500ms" → 500, "1.5s" → 1500
if (!s) return 0;
if (s.indexOf("ms") >= 0) return parseInt(s, 10);
if (s.indexOf("s") >= 0) return parseFloat(s) * 1000;
return parseInt(s, 10);
}
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+/);
// Handle "every <time>" as a special trigger
if (tokens[0] === "every" && tokens.length >= 2) {
triggers.push({ event: "every", modifiers: { interval: _parseTime(tokens[1]) } });
continue;
}
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 = _parseTime(tok.substring(6));
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 === "every") {
var ms = trig.modifiers.interval || 1000;
setInterval(function () { executeRequest(el, verbInfo); }, ms);
} else 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();
// sx-validate: run validation before request
var validateAttr = el.getAttribute("sx-validate");
if (validateAttr === null) {
var vForm = el.closest("[sx-validate]");
if (vForm) validateAttr = vForm.getAttribute("sx-validate");
}
if (validateAttr !== null) {
var formToValidate = el.tagName === "FORM" ? el : el.closest("form");
if (formToValidate && !formToValidate.reportValidity()) {
dispatch(el, "sx:validationFailed", {});
return;
}
// Custom validator function
if (validateAttr && validateAttr !== "true" && validateAttr !== "") {
var validatorFn = window[validateAttr];
if (typeof validatorFn === "function" && !validatorFn(el)) {
dispatch(el, "sx:validationFailed", {});
return;
}
}
}
// changed modifier: only fire if value changed
if (mods.changed && el.value !== undefined) {
if (el.value === lastVal) return;
lastVal = el.value;
}
// sx-optimistic: apply preview before request
var optimisticState = _applyOptimistic(el);
var _execAndReconcile = function () {
var p = executeRequest(el, verbInfo);
if (optimisticState && p && p.catch) {
p.catch(function () { _revertOptimistic(optimisticState); });
}
};
if (mods.delay) {
clearTimeout(timer);
timer = setTimeout(_execAndReconcile, mods.delay);
} else {
_execAndReconcile();
}
};
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);
// Process OOB swaps (sidebar, filter, menu, headers)
_processOOBSwaps(popContainer, _swapDOM, function (t) { Sx.hydrate(t); SxEngine.process(t); });
var newMain = popContainer.querySelector("#main-panel");
_morphChildren(main, newMain || popContainer);
_postSwap(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");
// Process OOB swaps from HTML response
_processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); });
var newMain = doc.getElementById("main-panel");
if (newMain) {
_morphChildren(main, newMain);
_postSwap(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));
}
}
}
// ---- sx-optimistic ----------------------------------------------------
function _applyOptimistic(el) {
var directive = el.getAttribute("sx-optimistic");
if (!directive) return null;
var target = resolveTarget(el, null) || el;
var state = { target: target, directive: directive };
if (directive === "remove") {
state.display = target.style.display;
state.opacity = target.style.opacity;
target.style.opacity = "0";
target.style.pointerEvents = "none";
} else if (directive === "disable") {
state.disabled = target.disabled;
target.disabled = true;
} else if (directive.indexOf("add-class:") === 0) {
var cls = directive.substring(10);
state.addClass = cls;
target.classList.add(cls);
}
return state;
}
function _revertOptimistic(state) {
if (!state) return;
var target = state.target;
if (state.directive === "remove") {
target.style.opacity = state.opacity || "";
target.style.pointerEvents = "";
} else if (state.directive === "disable") {
target.disabled = state.disabled || false;
} else if (state.addClass) {
target.classList.remove(state.addClass);
}
}
// ---- sx-preload -------------------------------------------------------
var _preloadCache = {};
var _PRELOAD_TTL = 30000; // 30 seconds
function _bindPreload(el) {
if (!el.hasAttribute("sx-preload")) return;
var mode = el.getAttribute("sx-preload") || "mousedown";
var events = mode === "mouseover" ? ["mouseenter", "focusin"] : ["mousedown", "focusin"];
var debounceTimer = null;
var debounceMs = mode === "mouseover" ? 100 : 0;
events.forEach(function (evt) {
el.addEventListener(evt, function () {
var verb = getVerb(el);
if (!verb) return;
var url = verb.url;
var cached = _preloadCache[url];
if (cached && (Date.now() - cached.timestamp < _PRELOAD_TTL)) return; // already cached
if (debounceMs) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () { _doPreload(url); }, debounceMs);
} else {
_doPreload(url);
}
});
});
}
function _doPreload(url) {
var headers = { "SX-Request": "true", "SX-Current-URL": location.href };
var cssH = _getSxCssHeader();
if (cssH) headers["SX-Css"] = cssH;
var loadedN = Object.keys(_componentEnv).filter(function (k) { return k.charAt(0) === "~"; });
if (loadedN.length) headers["SX-Components"] = loadedN.join(",");
fetch(url, { headers: headers }).then(function (resp) {
if (!resp.ok) return;
var ct = resp.headers.get("Content-Type") || "";
return resp.text().then(function (text) {
_preloadCache[url] = { text: text, contentType: ct, timestamp: Date.now() };
});
}).catch(function () { /* ignore preload errors */ });
}
function _getPreloaded(url) {
var cached = _preloadCache[url];
if (!cached) return null;
if (Date.now() - cached.timestamp > _PRELOAD_TTL) {
delete _preloadCache[url];
return null;
}
delete _preloadCache[url]; // consume once
return cached;
}
// ---- sx-boost ---------------------------------------------------------
function _processBoosted(root) {
var boostContainers = root.querySelectorAll("[sx-boost]");
if (root.matches && root.matches("[sx-boost]")) {
_boostDescendants(root);
}
for (var i = 0; i < boostContainers.length; i++) {
_boostDescendants(boostContainers[i]);
}
}
function _boostDescendants(container) {
// Boost links
var links = container.querySelectorAll("a[href]");
for (var i = 0; i < links.length; i++) {
var link = links[i];
if (link[PROCESSED] || link[PROCESSED + "boost"]) continue;
var href = link.getAttribute("href");
// Skip anchors, external, javascript:, mailto:, already sx-processed
if (!href || href.charAt(0) === "#" || href.indexOf("javascript:") === 0 ||
href.indexOf("mailto:") === 0 || !sameOrigin(href) ||
link.hasAttribute("sx-get") || link.hasAttribute("sx-post") ||
link.hasAttribute("sx-disable")) continue;
link[PROCESSED + "boost"] = true;
(function (el, url) {
el.addEventListener("click", function (e) {
e.preventDefault();
executeRequest(el, { method: "GET", url: url }).then(function () {
try { history.pushState({ sxUrl: url, scrollY: window.scrollY }, "", url); } catch (err) {}
});
});
})(link, href);
// Default target for boosted links
if (!link.hasAttribute("sx-target")) link.setAttribute("sx-target", "#main-panel");
if (!link.hasAttribute("sx-swap")) link.setAttribute("sx-swap", "innerHTML");
if (!link.hasAttribute("sx-select")) link.setAttribute("sx-select", "#main-panel");
}
// Boost forms
var forms = container.querySelectorAll("form");
for (var j = 0; j < forms.length; j++) {
var form = forms[j];
if (form[PROCESSED] || form[PROCESSED + "boost"]) continue;
if (form.hasAttribute("sx-get") || form.hasAttribute("sx-post") ||
form.hasAttribute("sx-disable")) continue;
form[PROCESSED + "boost"] = true;
(function (el) {
var method = (el.getAttribute("method") || "GET").toUpperCase();
var action = el.getAttribute("action") || location.href;
el.addEventListener("submit", function (e) {
e.preventDefault();
executeRequest(el, { method: method, url: action }).then(function () {
try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {}
});
});
})(form);
if (!form.hasAttribute("sx-target")) form.setAttribute("sx-target", "#main-panel");
if (!form.hasAttribute("sx-swap")) form.setAttribute("sx-swap", "innerHTML");
}
}
// ---- SSE (Server-Sent Events) ----------------------------------------
function _processSSE(root) {
var sseEls = root.querySelectorAll("[sx-sse]");
if (root.matches && root.matches("[sx-sse]")) _bindSSE(root);
for (var i = 0; i < sseEls.length; i++) _bindSSE(sseEls[i]);
}
function _bindSSE(el) {
if (el._sxSSE) return; // already connected
var url = el.getAttribute("sx-sse");
if (!url) return;
var source = new EventSource(url);
el._sxSSE = source;
// Bind swap handlers for sx-sse-swap="eventName" attributes on el and descendants
var swapEls = el.querySelectorAll("[sx-sse-swap]");
if (el.hasAttribute("sx-sse-swap")) _bindSSESwap(el, source);
for (var i = 0; i < swapEls.length; i++) _bindSSESwap(swapEls[i], source);
source.addEventListener("error", function () { dispatch(el, "sx:sseError", {}); });
source.addEventListener("open", function () { dispatch(el, "sx:sseOpen", {}); });
// Cleanup: close EventSource when element is removed from DOM
if (typeof MutationObserver !== "undefined") {
var obs = new MutationObserver(function () {
if (!document.body.contains(el)) {
source.close();
el._sxSSE = null;
obs.disconnect();
}
});
obs.observe(document.body, { childList: true, subtree: true });
}
}
function _bindSSESwap(el, source) {
var eventName = el.getAttribute("sx-sse-swap") || "message";
source.addEventListener(eventName, function (e) {
var target = resolveTarget(el, null) || el;
var swapStyle = el.getAttribute("sx-swap") || "innerHTML";
var data = e.data;
if (data.trim().charAt(0) === "(") {
try {
var dom = Sx.render(data);
_swapDOM(target, dom, swapStyle);
} catch (err) {
_swapContent(target, data, swapStyle);
}
} else {
_swapContent(target, data, swapStyle);
}
_postSwap(target);
dispatch(el, "sx:sseMessage", { data: data, event: eventName });
});
}
// ---- 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]);
}
// Process sx-boost containers
_processBoosted(root);
// Process SSE connections
_processSSE(root);
// 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);
_bindPreload(el);
}
// ---- Public API -------------------------------------------------------
var engine = {
process: process,
executeRequest: executeRequest,
config: _config,
version: "1.0.0"
};
return engine;
})();
global.SxEngine = SxEngine;
// --- Auto-init in browser ---
Sx.VERSION = "2026-03-04-cssx2";
// CSS class tracking for on-demand CSS delivery
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) _sxCssHash = content;
}
}
function _getSxCssHeader() {
return _sxCssHash;
}
function _processCssResponse(text, resp) {
var hashHeader = resp.headers.get("SX-Css-Hash");
if (hashHeader) _sxCssHash = hashHeader;
// 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;
}
// --- sx-comp-hash cookie helpers ---
function _setSxCompCookie(hash) {
document.cookie = "sx-comp-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax";
}
function _clearSxCompCookie() {
document.cookie = "sx-comp-hash=;path=/;max-age=0;SameSite=Lax";
}
// --- sx-styles-hash cookie helpers ---
function _setSxStylesCookie(hash) {
document.cookie = "sx-styles-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax";
}
function _clearSxStylesCookie() {
document.cookie = "sx-styles-hash=;path=/;max-age=0;SameSite=Lax";
}
function _initStyleDict() {
var scripts = document.querySelectorAll('script[type="text/sx-styles"]');
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s._sxProcessed) continue;
s._sxProcessed = true;
var text = s.textContent;
var hash = s.getAttribute("data-hash");
if (!hash) {
if (text && text.trim()) {
try { _loadStyleDict(JSON.parse(text)); } catch (e) { console.warn("[sx.js] style dict parse error", e); }
}
continue;
}
var hasInline = text && text.trim();
try {
var cachedHash = localStorage.getItem("sx-styles-hash");
if (cachedHash === hash) {
if (hasInline) {
localStorage.setItem("sx-styles-src", text);
_loadStyleDict(JSON.parse(text));
console.log("[sx.js] styles: downloaded (cookie stale)");
} else {
var cached = localStorage.getItem("sx-styles-src");
if (cached) {
_loadStyleDict(JSON.parse(cached));
console.log("[sx.js] styles: cached (" + hash + ")");
} else {
_clearSxStylesCookie();
location.reload();
return;
}
}
} else {
if (hasInline) {
localStorage.setItem("sx-styles-hash", hash);
localStorage.setItem("sx-styles-src", text);
_loadStyleDict(JSON.parse(text));
console.log("[sx.js] styles: downloaded (" + hash + ")");
} else {
localStorage.removeItem("sx-styles-hash");
localStorage.removeItem("sx-styles-src");
_clearSxStylesCookie();
location.reload();
return;
}
}
} catch (e) {
if (hasInline) {
try { _loadStyleDict(JSON.parse(text)); } catch (e2) { console.warn("[sx.js] style dict parse error", e2); }
}
}
_setSxStylesCookie(hash);
}
}
if (typeof document !== "undefined") {
var init = function () {
console.log("[sx.js] v" + Sx.VERSION + " init");
_initCssTracking();
_initStyleDict();
Sx.processScripts();
Sx.hydrate();
SxEngine.process();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}
})(typeof window !== "undefined" ? window : this);