When an HTML tag like (div) appears as a kwarg value in SX wire format, callComponent evaluates it with sxEval (data mode) which doesn't handle HTML tags. Now sxEval delegates to renderDOM for any render expression (HTML tags, SVG tags, fragments, raw!, components). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2784 lines
102 KiB
JavaScript
2784 lines
102 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;
|
|
|
|
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; }
|
|
|
|
// --- 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 typeof c === "string" ? c.indexOf(k) !== -1 : 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["slice"] = function (c, start, end) { return c ? (end !== undefined && end !== NIL ? c.slice(start, end) : c.slice(start)) : c; };
|
|
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;
|
|
};
|
|
PRIMITIVES["dissoc"] = function (d) {
|
|
var out = {}; for (var k in d) out[k] = d[k];
|
|
for (var i = 1; i < arguments.length; i++) delete out[arguments[i]];
|
|
return out;
|
|
};
|
|
PRIMITIVES["into"] = function (target, src) {
|
|
if (Array.isArray(target)) return target.concat(src || []);
|
|
var out = {}; for (var k in target) out[k] = target[k]; for (var k2 in src) out[k2] = src[k2]; return out;
|
|
};
|
|
|
|
// String operations
|
|
PRIMITIVES["replace"] = function (s, from, to) { return s ? String(s).split(from).join(to) : ""; };
|
|
PRIMITIVES["upper"] = function (s) { return s ? String(s).toUpperCase() : ""; };
|
|
PRIMITIVES["lower"] = function (s) { return s ? String(s).toLowerCase() : ""; };
|
|
PRIMITIVES["trim"] = function (s) { return s ? String(s).trim() : ""; };
|
|
PRIMITIVES["starts-with?"] = function (s, pfx) { return s ? String(s).indexOf(pfx) === 0 : false; };
|
|
PRIMITIVES["ends-with?"] = function (s, sfx) { var str = String(s || ""); return str.indexOf(sfx, str.length - sfx.length) !== -1; };
|
|
PRIMITIVES["escape"] = function (s) {
|
|
if (!s) return "";
|
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
};
|
|
PRIMITIVES["strip-tags"] = function (s) { return s ? String(s).replace(/<[^>]*>/g, "") : ""; };
|
|
PRIMITIVES["split"] = function (s, sep) { return s ? String(s).split(sep) : []; };
|
|
PRIMITIVES["join"] = function (lst, sep) { return (lst || []).join(sep !== undefined ? sep : ""); };
|
|
PRIMITIVES["pluralize"] = function (n, singular, plural) { return n === 1 ? singular : (plural || singular + "s"); };
|
|
|
|
// Numeric
|
|
PRIMITIVES["clamp"] = function (val, lo, hi) { return Math.max(lo, Math.min(hi, val)); };
|
|
PRIMITIVES["parse-int"] = function (s, def) { var n = parseInt(s, 10); return isNaN(n) ? (def !== undefined ? def : 0) : n; };
|
|
PRIMITIVES["format-decimal"] = function (n, places) { return Number(n || 0).toFixed(places !== undefined ? places : 2); };
|
|
|
|
// Date formatting (basic)
|
|
PRIMITIVES["format-date"] = function (s, fmt) {
|
|
if (!s) return "";
|
|
try {
|
|
var d = new Date(s);
|
|
if (isNaN(d.getTime())) return String(s);
|
|
// Basic strftime-like: %Y %m %d %H %M %B %b %-d
|
|
var months = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
|
var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2))
|
|
.replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()])
|
|
.replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2))
|
|
.replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2));
|
|
} catch (e) { return String(s); }
|
|
};
|
|
PRIMITIVES["parse-datetime"] = function (s) { return s ? String(s) : NIL; };
|
|
PRIMITIVES["split-ids"] = function (s) {
|
|
if (!s) return [];
|
|
return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; });
|
|
};
|
|
|
|
// --- 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) 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);
|
|
}
|
|
}
|
|
|
|
// HTML tag or component in data position — delegate to renderDOM
|
|
if (_isRenderExpr(expr)) return renderDOM(expr, env);
|
|
}
|
|
|
|
// Function call
|
|
var fn = sxEval(head, env);
|
|
var args = [];
|
|
for (var ai = 1; ai < expr.length; ai++) args.push(sxEval(expr[ai], env));
|
|
|
|
if (typeof fn === "function") return fn.apply(null, args);
|
|
if (isLambda(fn)) return callLambda(fn, args, env);
|
|
if (isComponent(fn)) return callComponent(fn, expr.slice(1), env);
|
|
throw new Error("Not callable: " + fn);
|
|
}
|
|
|
|
function callLambda(fn, args, callerEnv) {
|
|
if (args.length !== fn.params.length) {
|
|
throw new Error((fn.name || "lambda") + " expects " + fn.params.length + " args, got " + args.length);
|
|
}
|
|
var local = merge({}, fn.closure, callerEnv);
|
|
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
|
return 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["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 animate animateTransform " +
|
|
"table thead tbody tfoot tr th td caption colgroup col " +
|
|
"form fieldset legend label input button select option optgroup textarea output " +
|
|
"datalist progress meter details summary dialog template slot"
|
|
);
|
|
|
|
var VOID_ELEMENTS = makeSet(
|
|
"area base br col embed hr img input link meta param source track wbr"
|
|
);
|
|
|
|
var BOOLEAN_ATTRS = makeSet(
|
|
"async autofocus autoplay checked controls default defer disabled " +
|
|
"formnovalidate hidden inert ismap loop multiple muted nomodule " +
|
|
"novalidate open playsinline readonly required reversed selected"
|
|
);
|
|
|
|
// SVG elements that need createElementNS
|
|
var SVG_TAGS = makeSet(
|
|
"svg path circle ellipse line polygon polyline rect g defs use text tspan " +
|
|
"clipPath mask linearGradient radialGradient stop filter " +
|
|
"feGaussianBlur feOffset feMerge feMergeNode animate animateTransform"
|
|
);
|
|
|
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
/**
|
|
* Render an s-expression to DOM node(s).
|
|
* Returns a DocumentFragment, Element, or Text node.
|
|
*/
|
|
function renderDOM(expr, env) {
|
|
// nil / false → empty
|
|
if (isNil(expr) || expr === false || expr === true) return document.createDocumentFragment();
|
|
|
|
// Pre-rendered HTML
|
|
if (isRaw(expr)) {
|
|
var tpl = document.createElement("template");
|
|
tpl.innerHTML = expr.html;
|
|
return tpl.content;
|
|
}
|
|
|
|
// String → text node
|
|
if (typeof expr === "string") return document.createTextNode(expr);
|
|
|
|
// Number → text node
|
|
if (typeof expr === "number") return document.createTextNode(String(expr));
|
|
|
|
// Symbol → evaluate then render
|
|
if (isSym(expr)) return renderDOM(sxEval(expr, env), env);
|
|
|
|
// Keyword → text
|
|
if (isKw(expr)) return document.createTextNode(expr.name);
|
|
|
|
// Pre-rendered DOM node → return as-is
|
|
if (expr && expr.nodeType) return expr;
|
|
|
|
// Dict → empty
|
|
if (expr && typeof expr === "object" && !Array.isArray(expr)) return document.createDocumentFragment();
|
|
|
|
// List → dispatch
|
|
if (Array.isArray(expr)) {
|
|
if (!expr.length) return document.createDocumentFragment();
|
|
return renderList(expr, env);
|
|
}
|
|
|
|
return document.createTextNode(String(expr));
|
|
}
|
|
|
|
/** Render-aware special forms for DOM output. */
|
|
var RENDER_FORMS = {};
|
|
|
|
RENDER_FORMS["if"] = function (expr, env) {
|
|
var cond = sxEval(expr[1], env);
|
|
if (isSxTruthy(cond)) return renderDOM(expr[2], env);
|
|
return expr.length > 3 ? renderDOM(expr[3], env) : document.createDocumentFragment();
|
|
};
|
|
|
|
RENDER_FORMS["when"] = function (expr, env) {
|
|
if (!isSxTruthy(sxEval(expr[1], env))) return document.createDocumentFragment();
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 2; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["cond"] = function (expr, env) {
|
|
var branch = _evalCond(expr.slice(1), env);
|
|
return branch ? renderDOM(branch, env) : document.createDocumentFragment();
|
|
};
|
|
|
|
RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) {
|
|
var local = _processBindings(expr[1], env);
|
|
var frag = document.createDocumentFragment();
|
|
for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["begin"] = RENDER_FORMS["do"] = function (expr, env) {
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 1; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
|
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
|
RENDER_FORMS["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) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
|
|
frag.appendChild(val);
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["map-indexed"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var val = isLambda(fn) ? renderLambdaDOM(fn, [i, coll[i]], env) : renderDOM(fn(i, coll[i]), env);
|
|
frag.appendChild(val);
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["filter"] = function (expr, env) {
|
|
var result = sxEval(expr, env);
|
|
return renderDOM(result, env);
|
|
};
|
|
|
|
RENDER_FORMS["for-each"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
|
|
frag.appendChild(val);
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
function renderLambdaDOM(fn, args, env) {
|
|
var local = merge({}, fn.closure, env);
|
|
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
|
return renderDOM(fn.body, local);
|
|
}
|
|
|
|
/** True when the array expr is a render-only form (HTML tag, <>, raw!, ~comp). */
|
|
function _isRenderExpr(v) {
|
|
if (!Array.isArray(v) || !v.length) return false;
|
|
var h = v[0];
|
|
if (!isSym(h)) return false;
|
|
var n = h.name;
|
|
return !!(HTML_TAGS[n] || SVG_TAGS[n] || n === "<>" || n === "raw!" || n.charAt(0) === "~");
|
|
}
|
|
|
|
function renderComponentDOM(comp, args, env) {
|
|
var kwargs = {}, children = [];
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
if (isKw(args[i]) && i + 1 < args.length) {
|
|
// Evaluate kwarg values eagerly in the caller's env so expressions
|
|
// like (get t "src") resolve while lambda params are still bound.
|
|
var v = args[i + 1];
|
|
if (typeof v === "string" || typeof v === "number" ||
|
|
typeof v === "boolean" || isNil(v) || isKw(v)) {
|
|
kwargs[args[i].name] = v;
|
|
} else if (isSym(v)) {
|
|
kwargs[args[i].name] = sxEval(v, env);
|
|
} else if (Array.isArray(v) && v.length && isSym(v[0])) {
|
|
// Expression with Symbol head — evaluate in caller's env.
|
|
// Render-only forms go through renderDOM; data exprs through sxEval.
|
|
if (_isRenderExpr(v)) {
|
|
kwargs[args[i].name] = renderDOM(v, env);
|
|
} else {
|
|
kwargs[args[i].name] = sxEval(v, env);
|
|
}
|
|
} else {
|
|
// Data arrays, dicts, etc — pass through as-is
|
|
kwargs[args[i].name] = v;
|
|
}
|
|
i += 2;
|
|
} else {
|
|
children.push(args[i]);
|
|
i++;
|
|
}
|
|
}
|
|
var local = merge({}, comp.closure, env);
|
|
for (var pi = 0; pi < comp.params.length; pi++) {
|
|
var p = comp.params[pi];
|
|
local[p] = (p in kwargs) ? kwargs[p] : NIL;
|
|
}
|
|
if (comp.hasChildren) {
|
|
// Pre-render children to a fragment, wrap as RawHTML for raw! compatibility
|
|
var childFrag = document.createDocumentFragment();
|
|
for (var ci = 0; ci < children.length; ci++) childFrag.appendChild(renderDOM(children[ci], env));
|
|
local["children"] = childFrag;
|
|
}
|
|
return renderDOM(comp.body, local);
|
|
}
|
|
|
|
function renderList(expr, env) {
|
|
var head = expr[0];
|
|
|
|
if (isSym(head)) {
|
|
var name = head.name;
|
|
|
|
// raw! → insert unescaped
|
|
if (name === "raw!") {
|
|
var frag = document.createDocumentFragment();
|
|
for (var ri = 1; ri < expr.length; ri++) {
|
|
var val = sxEval(expr[ri], env);
|
|
if (typeof val === "string") {
|
|
var tpl = document.createElement("template");
|
|
tpl.innerHTML = val;
|
|
// Scripts in innerHTML don't execute — recreate them as live elements
|
|
var deadScripts = tpl.content.querySelectorAll("script");
|
|
for (var si = 0; si < deadScripts.length; si++) {
|
|
var dead = deadScripts[si];
|
|
var live = document.createElement("script");
|
|
for (var ai = 0; ai < dead.attributes.length; ai++)
|
|
live.setAttribute(dead.attributes[ai].name, dead.attributes[ai].value);
|
|
live.textContent = dead.textContent;
|
|
dead.parentNode.replaceChild(live, dead);
|
|
}
|
|
frag.appendChild(tpl.content);
|
|
} else if (val && val.nodeType) {
|
|
// Already a DOM node (e.g. from children fragment)
|
|
frag.appendChild(val.cloneNode ? val.cloneNode(true) : val);
|
|
} else if (!isNil(val)) {
|
|
frag.appendChild(document.createTextNode(String(val)));
|
|
}
|
|
}
|
|
return frag;
|
|
}
|
|
|
|
// <> → fragment
|
|
if (name === "<>") {
|
|
var f = document.createDocumentFragment();
|
|
for (var fi = 1; fi < expr.length; fi++) f.appendChild(renderDOM(expr[fi], env));
|
|
return f;
|
|
}
|
|
|
|
// Render-aware special forms
|
|
if (RENDER_FORMS[name]) return RENDER_FORMS[name](expr, env);
|
|
|
|
// Macro expansion
|
|
if (name in env && isMacro(env[name])) {
|
|
var mExpanded = expandMacro(env[name], expr.slice(1), env);
|
|
return renderDOM(mExpanded, env);
|
|
}
|
|
|
|
// HTML tag
|
|
if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env);
|
|
|
|
// Component
|
|
if (name.charAt(0) === "~") {
|
|
var comp = env[name];
|
|
if (isComponent(comp)) return renderComponentDOM(comp, expr.slice(1), env);
|
|
// Unknown component — render a visible warning, don't crash
|
|
console.warn("sx.js: unknown component " + name);
|
|
var warn = document.createElement("div");
|
|
warn.setAttribute("style",
|
|
"background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;" +
|
|
"padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace");
|
|
warn.textContent = "Unknown component: " + name;
|
|
return warn;
|
|
}
|
|
|
|
// Fallback: evaluate then render
|
|
return renderDOM(sxEval(expr, env), env);
|
|
}
|
|
|
|
// Lambda/list head → evaluate
|
|
if (isLambda(head) || Array.isArray(head)) return renderDOM(sxEval(expr, env), env);
|
|
|
|
// Data list
|
|
var dl = document.createDocumentFragment();
|
|
for (var di = 0; di < expr.length; di++) dl.appendChild(renderDOM(expr[di], env));
|
|
return dl;
|
|
}
|
|
|
|
function renderElement(tag, args, env) {
|
|
var el = SVG_TAGS[tag]
|
|
? document.createElementNS(SVG_NS, tag)
|
|
: document.createElement(tag);
|
|
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
var arg = args[i];
|
|
if (isKw(arg) && i + 1 < args.length) {
|
|
var attrName = arg.name;
|
|
var attrVal = sxEval(args[i + 1], env);
|
|
i += 2;
|
|
if (isNil(attrVal) || attrVal === false) continue;
|
|
if (BOOLEAN_ATTRS[attrName]) {
|
|
if (attrVal) el.setAttribute(attrName, "");
|
|
} else if (attrVal === true) {
|
|
el.setAttribute(attrName, "");
|
|
} else {
|
|
el.setAttribute(attrName, String(attrVal));
|
|
}
|
|
} else {
|
|
// Child
|
|
if (!(tag in VOID_ELEMENTS)) {
|
|
el.appendChild(renderDOM(arg, env));
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
|
|
return el;
|
|
}
|
|
|
|
// --- 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 },
|
|
_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-01c-cssx";
|
|
|
|
// 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";
|
|
}
|
|
|
|
if (typeof document !== "undefined") {
|
|
var init = function () {
|
|
console.log("[sx.js] v" + Sx.VERSION + " init");
|
|
_initCssTracking();
|
|
Sx.processScripts();
|
|
Sx.hydrate();
|
|
SxEngine.process();
|
|
};
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
})(typeof window !== "undefined" ? window : this);
|