- Nest admin header inside post-header-child (layouts.py/helpers.py) so full-page DOM matches OOB swap structure, eliminating duplicate headers - Clear post-header-child on post layout OOB to remove stale admin rows - Read SX initial content from #sx-content-input instead of window.__SX_INITIAL__ to avoid escaping issues through SX pipeline - Fix client-side SX parser RE_STRING to handle escaped newlines - Clear root element in SxEditor.mount() to prevent double content on HTMX re-mount - Remove unused ~blog-editor-sx-initial component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2645 lines
96 KiB
JavaScript
2645 lines
96 KiB
JavaScript
/**
|
|
* sx.js — S-expression parser, evaluator, and DOM renderer. [v2-debug]
|
|
*
|
|
* Client-side counterpart to shared/sx/ Python modules.
|
|
* Parses s-expression text, evaluates it, and renders to DOM nodes.
|
|
*
|
|
* Usage:
|
|
* Sx.loadComponents('(defcomp ~card (&key title) (div :class "c" title))');
|
|
* const node = Sx.render('(~card :title "Hello")');
|
|
* document.body.appendChild(node);
|
|
*/
|
|
;(function (global) {
|
|
"use strict";
|
|
|
|
// =========================================================================
|
|
// Types
|
|
// =========================================================================
|
|
|
|
/** Singleton nil — falsy placeholder. */
|
|
var NIL = Object.freeze({ _nil: true, toString: function () { return "nil"; } });
|
|
|
|
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
|
function isTruthy(x) { return x !== false && !isNil(x) && x !== 0 && x !== ""; }
|
|
// Note: 0 and "" are falsy in sx but we match Python semantics where
|
|
// only nil/false/None are falsy for control flow. Revisit if needed.
|
|
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
|
|
|
function Symbol(name) { this.name = name; }
|
|
Symbol.prototype.toString = function () { return this.name; };
|
|
Symbol.prototype._sym = true;
|
|
|
|
function Keyword(name) { this.name = name; }
|
|
Keyword.prototype.toString = function () { return ":" + this.name; };
|
|
Keyword.prototype._kw = true;
|
|
|
|
function Lambda(params, body, closure, name) {
|
|
this.params = params;
|
|
this.body = body;
|
|
this.closure = closure || {};
|
|
this.name = name || null;
|
|
}
|
|
Lambda.prototype._lambda = true;
|
|
|
|
function Component(name, params, hasChildren, body, closure) {
|
|
this.name = name;
|
|
this.params = params;
|
|
this.hasChildren = hasChildren;
|
|
this.body = body;
|
|
this.closure = closure || {};
|
|
}
|
|
Component.prototype._component = true;
|
|
|
|
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;
|
|
|
|
/** 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 Array.isArray(c) ? c.indexOf(k) !== -1 : k in c; };
|
|
PRIMITIVES["odd?"] = function (n) { return n % 2 !== 0; };
|
|
PRIMITIVES["even?"] = function (n) { return n % 2 === 0; };
|
|
PRIMITIVES["zero?"] = function (n) { return n === 0; };
|
|
|
|
// Collections
|
|
PRIMITIVES["list"] = function () { return Array.prototype.slice.call(arguments); };
|
|
PRIMITIVES["dict"] = function () {
|
|
var d = {};
|
|
for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1];
|
|
return d;
|
|
};
|
|
PRIMITIVES["get"] = function (c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); };
|
|
PRIMITIVES["len"] = function (c) { return Array.isArray(c) ? c.length : Object.keys(c).length; };
|
|
PRIMITIVES["first"] = function (c) { return c && c.length > 0 ? c[0] : NIL; };
|
|
PRIMITIVES["last"] = function (c) { return c && c.length > 0 ? c[c.length - 1] : NIL; };
|
|
PRIMITIVES["rest"] = function (c) { return c ? c.slice(1) : []; };
|
|
PRIMITIVES["nth"] = function (c, n) { return c && n < c.length ? c[n] : NIL; };
|
|
PRIMITIVES["cons"] = function (x, c) { return [x].concat(c || []); };
|
|
PRIMITIVES["append"] = function (c, x) { return (c || []).concat([x]); };
|
|
PRIMITIVES["keys"] = function (d) { return Object.keys(d || {}); };
|
|
PRIMITIVES["vals"] = function (d) { var r = []; for (var k in d) r.push(d[k]); return r; };
|
|
PRIMITIVES["merge"] = function () {
|
|
var out = {};
|
|
for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; for (var k in d) out[k] = d[k]; }
|
|
return out;
|
|
};
|
|
PRIMITIVES["assoc"] = function (d) {
|
|
var out = {}; for (var k in d) out[k] = d[k];
|
|
for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1];
|
|
return out;
|
|
};
|
|
PRIMITIVES["range"] = function (a, b, step) {
|
|
var r = []; step = step || 1;
|
|
if (b === undefined) { b = a; a = 0; }
|
|
for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i);
|
|
return r;
|
|
};
|
|
|
|
// =========================================================================
|
|
// Evaluator
|
|
// =========================================================================
|
|
|
|
function sxEval(expr, env) {
|
|
// Literals
|
|
if (typeof expr === "number" || typeof expr === "string" || typeof expr === "boolean") return expr;
|
|
if (isNil(expr)) return NIL;
|
|
|
|
// Symbol lookup
|
|
if (isSym(expr)) {
|
|
var name = expr.name;
|
|
if (name in env) return env[name];
|
|
if (name in PRIMITIVES) return PRIMITIVES[name];
|
|
if (name === "true") return true;
|
|
if (name === "false") return false;
|
|
if (name === "nil") return NIL;
|
|
throw new Error("Undefined symbol: " + name);
|
|
}
|
|
|
|
// Keyword → its name
|
|
if (isKw(expr)) return expr.name;
|
|
|
|
// Dict literal
|
|
if (expr && typeof expr === "object" && !Array.isArray(expr) && !expr._sym && !expr._kw && !expr._raw) {
|
|
var d = {};
|
|
for (var dk in expr) d[dk] = sxEval(expr[dk], env);
|
|
return d;
|
|
}
|
|
|
|
// List
|
|
if (!Array.isArray(expr)) return expr;
|
|
if (expr.length === 0) return [];
|
|
|
|
var head = expr[0];
|
|
|
|
// Non-callable head → data list
|
|
if (!isSym(head) && !isLambda(head) && !Array.isArray(head)) {
|
|
return expr.map(function (x) { return sxEval(x, env); });
|
|
}
|
|
|
|
// Special forms
|
|
if (isSym(head)) {
|
|
var sf = SPECIAL_FORMS[head.name];
|
|
if (sf) return sf(expr, env);
|
|
var ho = HO_FORMS[head.name];
|
|
if (ho) return ho(expr, env);
|
|
|
|
// Macro expansion
|
|
if (head.name in env) {
|
|
var macroVal = env[head.name];
|
|
if (isMacro(macroVal)) {
|
|
var expanded = expandMacro(macroVal, expr.slice(1), env);
|
|
return sxEval(expanded, env);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Function call
|
|
var fn = sxEval(head, env);
|
|
var args = [];
|
|
for (var ai = 1; ai < expr.length; ai++) args.push(sxEval(expr[ai], env));
|
|
|
|
if (typeof fn === "function") return fn.apply(null, args);
|
|
if (isLambda(fn)) return callLambda(fn, args, env);
|
|
if (isComponent(fn)) return callComponent(fn, expr.slice(1), env);
|
|
throw new Error("Not callable: " + fn);
|
|
}
|
|
|
|
function callLambda(fn, args, callerEnv) {
|
|
if (args.length !== fn.params.length) {
|
|
throw new Error((fn.name || "lambda") + " expects " + fn.params.length + " args, got " + args.length);
|
|
}
|
|
var local = merge({}, fn.closure, callerEnv);
|
|
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
|
return sxEval(fn.body, local);
|
|
}
|
|
|
|
function callComponent(comp, rawArgs, env) {
|
|
var kwargs = {}, children = [];
|
|
var i = 0;
|
|
while (i < rawArgs.length) {
|
|
if (isKw(rawArgs[i]) && i + 1 < rawArgs.length) {
|
|
kwargs[rawArgs[i].name] = sxEval(rawArgs[i + 1], env);
|
|
i += 2;
|
|
} else {
|
|
children.push(sxEval(rawArgs[i], env));
|
|
i++;
|
|
}
|
|
}
|
|
var local = merge({}, comp.closure, env);
|
|
for (var pi = 0; pi < comp.params.length; pi++) {
|
|
var p = comp.params[pi];
|
|
local[p] = (p in kwargs) ? kwargs[p] : NIL;
|
|
}
|
|
if (comp.hasChildren) local["children"] = children;
|
|
return sxEval(comp.body, local);
|
|
}
|
|
|
|
// --- Special forms -------------------------------------------------------
|
|
|
|
var SPECIAL_FORMS = {};
|
|
|
|
SPECIAL_FORMS["if"] = function (expr, env) {
|
|
var cond = sxEval(expr[1], env);
|
|
if (isSxTruthy(cond)) return sxEval(expr[2], env);
|
|
return expr.length > 3 ? sxEval(expr[3], env) : NIL;
|
|
};
|
|
|
|
SPECIAL_FORMS["when"] = function (expr, env) {
|
|
if (!isSxTruthy(sxEval(expr[1], env))) return NIL;
|
|
var result = NIL;
|
|
for (var i = 2; i < expr.length; i++) result = sxEval(expr[i], env);
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["cond"] = function (expr, env) {
|
|
var clauses = expr.slice(1);
|
|
if (!clauses.length) return NIL;
|
|
// Scheme-style
|
|
if (Array.isArray(clauses[0]) && clauses[0].length === 2) {
|
|
for (var i = 0; i < clauses.length; i++) {
|
|
var test = clauses[i][0];
|
|
if ((isSym(test) && (test.name === "else" || test.name === ":else")) ||
|
|
(isKw(test) && test.name === "else")) return sxEval(clauses[i][1], env);
|
|
if (isSxTruthy(sxEval(test, env))) return sxEval(clauses[i][1], env);
|
|
}
|
|
} else {
|
|
// Clojure-style
|
|
for (var j = 0; j < clauses.length - 1; j += 2) {
|
|
var t = clauses[j];
|
|
if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
|
|
return sxEval(clauses[j + 1], env);
|
|
if (isSxTruthy(sxEval(t, env))) return sxEval(clauses[j + 1], env);
|
|
}
|
|
}
|
|
return NIL;
|
|
};
|
|
|
|
SPECIAL_FORMS["case"] = function (expr, env) {
|
|
var val = sxEval(expr[1], env);
|
|
for (var i = 2; i < expr.length - 1; i += 2) {
|
|
var t = expr[i];
|
|
if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
|
|
return sxEval(expr[i + 1], env);
|
|
if (val == sxEval(t, env)) return sxEval(expr[i + 1], env);
|
|
}
|
|
return NIL;
|
|
};
|
|
|
|
SPECIAL_FORMS["and"] = function (expr, env) {
|
|
var result = true;
|
|
for (var i = 1; i < expr.length; i++) {
|
|
result = sxEval(expr[i], env);
|
|
if (!isSxTruthy(result)) return result;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["or"] = function (expr, env) {
|
|
var result = false;
|
|
for (var i = 1; i < expr.length; i++) {
|
|
result = sxEval(expr[i], env);
|
|
if (isSxTruthy(result)) return result;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["let"] = SPECIAL_FORMS["let*"] = function (expr, env) {
|
|
var bindings = expr[1], local = merge({}, env);
|
|
if (Array.isArray(bindings)) {
|
|
if (bindings.length && Array.isArray(bindings[0])) {
|
|
// Scheme-style
|
|
for (var i = 0; i < bindings.length; i++) {
|
|
var vname = isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0];
|
|
local[vname] = sxEval(bindings[i][1], local);
|
|
}
|
|
} else {
|
|
// Clojure-style
|
|
for (var j = 0; j < bindings.length; j += 2) {
|
|
var vn = isSym(bindings[j]) ? bindings[j].name : bindings[j];
|
|
local[vn] = sxEval(bindings[j + 1], local);
|
|
}
|
|
}
|
|
}
|
|
var result = NIL;
|
|
for (var k = 2; k < expr.length; k++) result = sxEval(expr[k], local);
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["lambda"] = SPECIAL_FORMS["fn"] = function (expr, env) {
|
|
var paramsExpr = expr[1], paramNames = [];
|
|
for (var i = 0; i < paramsExpr.length; i++) {
|
|
var p = paramsExpr[i];
|
|
paramNames.push(isSym(p) ? p.name : String(p));
|
|
}
|
|
return new Lambda(paramNames, expr[2], merge({}, env));
|
|
};
|
|
|
|
SPECIAL_FORMS["define"] = function (expr, env) {
|
|
var name = expr[1].name;
|
|
var value = sxEval(expr[2], env);
|
|
if (isLambda(value) && !value.name) value.name = name;
|
|
env[name] = value;
|
|
return value;
|
|
};
|
|
|
|
SPECIAL_FORMS["defcomp"] = function (expr, env) {
|
|
var nameSym = expr[1];
|
|
var compName = nameSym.name.replace(/^~/, "");
|
|
var paramsExpr = expr[2];
|
|
var params = [], hasChildren = false, inKey = false;
|
|
for (var i = 0; i < paramsExpr.length; i++) {
|
|
var p = paramsExpr[i];
|
|
if (isSym(p)) {
|
|
if (p.name === "&key") { inKey = true; continue; }
|
|
if (p.name === "&rest") { hasChildren = true; continue; }
|
|
if (inKey || hasChildren) { if (!hasChildren) params.push(p.name); }
|
|
else params.push(p.name);
|
|
}
|
|
}
|
|
var comp = new Component(compName, params, hasChildren, expr[3], merge({}, env));
|
|
env[nameSym.name] = comp;
|
|
return comp;
|
|
};
|
|
|
|
SPECIAL_FORMS["begin"] = SPECIAL_FORMS["do"] = function (expr, env) {
|
|
var result = NIL;
|
|
for (var i = 1; i < expr.length; i++) result = sxEval(expr[i], env);
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["quote"] = function (expr) { return expr[1]; };
|
|
|
|
SPECIAL_FORMS["set!"] = function (expr, env) {
|
|
var v = sxEval(expr[2], env);
|
|
env[expr[1].name] = v;
|
|
return v;
|
|
};
|
|
|
|
SPECIAL_FORMS["->"] = function (expr, env) {
|
|
var result = sxEval(expr[1], env);
|
|
for (var i = 2; i < expr.length; i++) {
|
|
var form = expr[i];
|
|
var fn, args;
|
|
if (Array.isArray(form)) {
|
|
fn = sxEval(form[0], env);
|
|
args = [result];
|
|
for (var j = 1; j < form.length; j++) args.push(sxEval(form[j], env));
|
|
} else {
|
|
fn = sxEval(form, env);
|
|
args = [result];
|
|
}
|
|
if (typeof fn === "function") result = fn.apply(null, args);
|
|
else if (isLambda(fn)) result = callLambda(fn, args, env);
|
|
else throw new Error("-> form not callable: " + fn);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
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) ? callLambda(fn, [item], env) : fn(item); });
|
|
};
|
|
|
|
HO_FORMS["map-indexed"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
return coll.map(function (item, i) { return isLambda(fn) ? callLambda(fn, [i, item], env) : fn(i, item); });
|
|
};
|
|
|
|
HO_FORMS["filter"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
return coll.filter(function (item) {
|
|
var r = isLambda(fn) ? callLambda(fn, [item], env) : fn(item);
|
|
return isSxTruthy(r);
|
|
});
|
|
};
|
|
|
|
HO_FORMS["reduce"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), acc = sxEval(expr[2], env), coll = sxEval(expr[3], env);
|
|
for (var i = 0; i < coll.length; i++) acc = isLambda(fn) ? callLambda(fn, [acc, coll[i]], env) : fn(acc, coll[i]);
|
|
return acc;
|
|
};
|
|
|
|
HO_FORMS["some"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var r = isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
|
|
if (isSxTruthy(r)) return r;
|
|
}
|
|
return NIL;
|
|
};
|
|
|
|
HO_FORMS["every?"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
for (var i = 0; i < coll.length; i++) {
|
|
if (!isSxTruthy(isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]))) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
HO_FORMS["for-each"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
for (var i = 0; i < coll.length; i++) isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
|
|
return NIL;
|
|
};
|
|
|
|
// =========================================================================
|
|
// DOM Renderer
|
|
// =========================================================================
|
|
|
|
var HTML_TAGS = makeSet(
|
|
"html head body title meta link style script base noscript " +
|
|
"header footer main nav aside section article address hgroup " +
|
|
"h1 h2 h3 h4 h5 h6 " +
|
|
"div p blockquote pre figure figcaption ul ol li dl dt dd hr " +
|
|
"a span em strong small s cite q abbr code var samp kbd sub sup " +
|
|
"i b u mark ruby rt rp bdi bdo br wbr time data " +
|
|
"ins del " +
|
|
"img picture source iframe embed object param video audio track canvas map area " +
|
|
"svg math path circle ellipse line polygon polyline rect g defs use text tspan " +
|
|
"clipPath mask linearGradient radialGradient stop filter " +
|
|
"feGaussianBlur feOffset feMerge feMergeNode animate animateTransform " +
|
|
"table thead tbody tfoot tr th td caption colgroup col " +
|
|
"form fieldset legend label input button select option optgroup textarea output " +
|
|
"datalist progress meter details summary dialog template slot"
|
|
);
|
|
|
|
var VOID_ELEMENTS = makeSet(
|
|
"area base br col embed hr img input link meta param source track wbr"
|
|
);
|
|
|
|
var BOOLEAN_ATTRS = makeSet(
|
|
"async autofocus autoplay checked controls default defer disabled " +
|
|
"formnovalidate hidden inert ismap loop multiple muted nomodule " +
|
|
"novalidate open playsinline readonly required reversed selected"
|
|
);
|
|
|
|
// SVG elements that need createElementNS
|
|
var SVG_TAGS = makeSet(
|
|
"svg path circle ellipse line polygon polyline rect g defs use text tspan " +
|
|
"clipPath mask linearGradient radialGradient stop filter " +
|
|
"feGaussianBlur feOffset feMerge feMergeNode animate animateTransform"
|
|
);
|
|
|
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
/**
|
|
* Render an s-expression to DOM node(s).
|
|
* Returns a DocumentFragment, Element, or Text node.
|
|
*/
|
|
function renderDOM(expr, env) {
|
|
// nil / false → empty
|
|
if (isNil(expr) || expr === false || expr === true) return document.createDocumentFragment();
|
|
|
|
// Pre-rendered HTML
|
|
if (isRaw(expr)) {
|
|
var tpl = document.createElement("template");
|
|
tpl.innerHTML = expr.html;
|
|
return tpl.content;
|
|
}
|
|
|
|
// String → text node
|
|
if (typeof expr === "string") return document.createTextNode(expr);
|
|
|
|
// Number → text node
|
|
if (typeof expr === "number") return document.createTextNode(String(expr));
|
|
|
|
// Symbol → evaluate then render
|
|
if (isSym(expr)) return renderDOM(sxEval(expr, env), env);
|
|
|
|
// Keyword → text
|
|
if (isKw(expr)) return document.createTextNode(expr.name);
|
|
|
|
// Pre-rendered DOM node → return as-is
|
|
if (expr && expr.nodeType) return expr;
|
|
|
|
// Dict → empty
|
|
if (expr && typeof expr === "object" && !Array.isArray(expr)) return document.createDocumentFragment();
|
|
|
|
// List → dispatch
|
|
if (Array.isArray(expr)) {
|
|
if (!expr.length) return document.createDocumentFragment();
|
|
return renderList(expr, env);
|
|
}
|
|
|
|
return document.createTextNode(String(expr));
|
|
}
|
|
|
|
/** Render-aware special forms for DOM output. */
|
|
var RENDER_FORMS = {};
|
|
|
|
RENDER_FORMS["if"] = function (expr, env) {
|
|
var cond = sxEval(expr[1], env);
|
|
if (isSxTruthy(cond)) return renderDOM(expr[2], env);
|
|
return expr.length > 3 ? renderDOM(expr[3], env) : document.createDocumentFragment();
|
|
};
|
|
|
|
RENDER_FORMS["when"] = function (expr, env) {
|
|
if (!isSxTruthy(sxEval(expr[1], env))) return document.createDocumentFragment();
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 2; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["cond"] = function (expr, env) {
|
|
var clauses = expr.slice(1);
|
|
if (!clauses.length) return document.createDocumentFragment();
|
|
if (Array.isArray(clauses[0]) && clauses[0].length === 2) {
|
|
for (var i = 0; i < clauses.length; i++) {
|
|
var test = clauses[i][0];
|
|
if ((isSym(test) && (test.name === "else" || test.name === ":else")) ||
|
|
(isKw(test) && test.name === "else")) return renderDOM(clauses[i][1], env);
|
|
if (isSxTruthy(sxEval(test, env))) return renderDOM(clauses[i][1], env);
|
|
}
|
|
} else {
|
|
for (var j = 0; j < clauses.length - 1; j += 2) {
|
|
var t = clauses[j];
|
|
if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
|
|
return renderDOM(clauses[j + 1], env);
|
|
if (isSxTruthy(sxEval(t, env))) return renderDOM(clauses[j + 1], env);
|
|
}
|
|
}
|
|
return document.createDocumentFragment();
|
|
};
|
|
|
|
RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) {
|
|
var bindings = expr[1], local = merge({}, env);
|
|
if (Array.isArray(bindings)) {
|
|
if (bindings.length && Array.isArray(bindings[0])) {
|
|
for (var i = 0; i < bindings.length; i++) {
|
|
local[isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]] = sxEval(bindings[i][1], local);
|
|
}
|
|
} else {
|
|
for (var j = 0; j < bindings.length; j += 2) {
|
|
local[isSym(bindings[j]) ? bindings[j].name : bindings[j]] = sxEval(bindings[j + 1], local);
|
|
}
|
|
}
|
|
}
|
|
var frag = document.createDocumentFragment();
|
|
for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["begin"] = RENDER_FORMS["do"] = function (expr, env) {
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 1; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
|
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
|
RENDER_FORMS["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;
|
|
}
|
|
|
|
// =========================================================================
|
|
// String Renderer (for SSR parity / testing)
|
|
// =========================================================================
|
|
|
|
function escapeText(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
|
function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); }
|
|
|
|
function renderStr(expr, env) {
|
|
if (isNil(expr) || expr === false || expr === true) return "";
|
|
if (isRaw(expr)) return expr.html;
|
|
if (typeof expr === "string") return escapeText(expr);
|
|
if (typeof expr === "number") return escapeText(String(expr));
|
|
if (isSym(expr)) return renderStr(sxEval(expr, env), env);
|
|
if (isKw(expr)) return escapeText(expr.name);
|
|
if (Array.isArray(expr)) { if (!expr.length) return ""; return renderStrList(expr, env); }
|
|
if (expr && typeof expr === "object") return "";
|
|
return escapeText(String(expr));
|
|
}
|
|
|
|
function renderStrList(expr, env) {
|
|
var head = expr[0];
|
|
if (!isSym(head)) {
|
|
var parts = [];
|
|
for (var i = 0; i < expr.length; i++) parts.push(renderStr(expr[i], env));
|
|
return parts.join("");
|
|
}
|
|
var name = head.name;
|
|
|
|
if (name === "raw!") {
|
|
var ps = [];
|
|
for (var ri = 1; ri < expr.length; ri++) {
|
|
var v = sxEval(expr[ri], env);
|
|
if (isRaw(v)) ps.push(v.html);
|
|
else if (typeof v === "string") ps.push(v);
|
|
else if (!isNil(v)) ps.push(String(v));
|
|
}
|
|
return ps.join("");
|
|
}
|
|
if (name === "<>") {
|
|
var fs = [];
|
|
for (var fi = 1; fi < expr.length; fi++) fs.push(renderStr(expr[fi], env));
|
|
return fs.join("");
|
|
}
|
|
if (name === "if") {
|
|
return isSxTruthy(sxEval(expr[1], env))
|
|
? renderStr(expr[2], env)
|
|
: (expr.length > 3 ? renderStr(expr[3], env) : "");
|
|
}
|
|
if (name === "when") {
|
|
if (!isSxTruthy(sxEval(expr[1], env))) return "";
|
|
var ws = [];
|
|
for (var wi = 2; wi < expr.length; wi++) ws.push(renderStr(expr[wi], env));
|
|
return ws.join("");
|
|
}
|
|
if (name === "let" || name === "let*") {
|
|
var bindings = expr[1], local = merge({}, env);
|
|
if (Array.isArray(bindings)) {
|
|
if (bindings.length && Array.isArray(bindings[0])) {
|
|
for (var li = 0; li < bindings.length; li++) {
|
|
local[isSym(bindings[li][0]) ? bindings[li][0].name : bindings[li][0]] = sxEval(bindings[li][1], local);
|
|
}
|
|
} else {
|
|
for (var lj = 0; lj < bindings.length; lj += 2) {
|
|
local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(bindings[lj + 1], local);
|
|
}
|
|
}
|
|
}
|
|
var ls = [];
|
|
for (var lk = 2; lk < expr.length; lk++) ls.push(renderStr(expr[lk], local));
|
|
return ls.join("");
|
|
}
|
|
if (name === "begin" || name === "do") {
|
|
var bs = [];
|
|
for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env));
|
|
return bs.join("");
|
|
}
|
|
if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; }
|
|
|
|
// Macro expansion in string renderer
|
|
if (name in env && isMacro(env[name])) {
|
|
var smExp = expandMacro(env[name], expr.slice(1), env);
|
|
return renderStr(smExp, env);
|
|
}
|
|
|
|
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
|
|
if (name === "map") {
|
|
var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env);
|
|
if (!Array.isArray(mapColl)) return "";
|
|
var mapParts = [];
|
|
for (var mi = 0; mi < mapColl.length; mi++) {
|
|
if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env));
|
|
else mapParts.push(renderStr(mapFn(mapColl[mi]), env));
|
|
}
|
|
return mapParts.join("");
|
|
}
|
|
if (name === "map-indexed") {
|
|
var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env);
|
|
if (!Array.isArray(mixColl)) return "";
|
|
var mixParts = [];
|
|
for (var mxi = 0; mxi < mixColl.length; mxi++) {
|
|
if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env));
|
|
else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env));
|
|
}
|
|
return mixParts.join("");
|
|
}
|
|
if (name === "filter") {
|
|
var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env);
|
|
if (!Array.isArray(filtColl)) return "";
|
|
var filtParts = [];
|
|
for (var fli = 0; fli < filtColl.length; fli++) {
|
|
var keep = isLambda(filtFn) ? callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]);
|
|
if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env));
|
|
}
|
|
return filtParts.join("");
|
|
}
|
|
|
|
if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env);
|
|
|
|
if (name.charAt(0) === "~") {
|
|
var comp = env[name];
|
|
if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env);
|
|
// Unknown component — return visible warning
|
|
console.warn("sx.js: unknown component " + name);
|
|
return '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
|
|
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
|
|
'Unknown component: ' + escapeText(name) + '</div>';
|
|
}
|
|
|
|
return renderStr(sxEval(expr, env), env);
|
|
}
|
|
|
|
function renderStrElement(tag, args, env) {
|
|
var attrs = [], children = [];
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
if (isKw(args[i]) && i + 1 < args.length) {
|
|
var aname = args[i].name, aval = sxEval(args[i + 1], env);
|
|
i += 2;
|
|
if (isNil(aval) || aval === false) continue;
|
|
if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); }
|
|
else if (aval === true) attrs.push(" " + aname);
|
|
else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"');
|
|
} else {
|
|
children.push(args[i]);
|
|
i++;
|
|
}
|
|
}
|
|
var open = "<" + tag + attrs.join("") + ">";
|
|
if (VOID_ELEMENTS[tag]) return open;
|
|
var isRawText = (tag === "script" || tag === "style");
|
|
var inner = [];
|
|
for (var ci = 0; ci < children.length; ci++) {
|
|
var child = children[ci];
|
|
if (isRawText && typeof child === "string") inner.push(child);
|
|
else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env)));
|
|
else inner.push(renderStr(child, env));
|
|
}
|
|
return open + inner.join("") + "</" + tag + ">";
|
|
}
|
|
|
|
function renderLambdaStr(fn, args, env) {
|
|
var local = merge({}, fn.closure, env);
|
|
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
|
return renderStr(fn.body, local);
|
|
}
|
|
|
|
function renderStrComponent(comp, args, env) {
|
|
var kwargs = {}, children = [];
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
if (isKw(args[i]) && i + 1 < args.length) {
|
|
// Evaluate kwarg values eagerly in the caller's env so expressions
|
|
// like (get t "src") resolve while lambda params are still bound.
|
|
var v = args[i + 1];
|
|
if (typeof v === "string" || typeof v === "number" ||
|
|
typeof v === "boolean" || isNil(v) || isKw(v)) {
|
|
kwargs[args[i].name] = v;
|
|
} else if (isSym(v)) {
|
|
kwargs[args[i].name] = sxEval(v, env);
|
|
} else if (Array.isArray(v) && v.length && isSym(v[0])) {
|
|
// Expression with Symbol head — evaluate in caller's env.
|
|
// Render-only forms go through renderStr; data exprs through sxEval.
|
|
if (_isRenderExpr(v)) {
|
|
kwargs[args[i].name] = new RawHTML(renderStr(v, env));
|
|
} else {
|
|
kwargs[args[i].name] = sxEval(v, env);
|
|
}
|
|
} else {
|
|
// Data arrays, dicts, etc — pass through as-is
|
|
kwargs[args[i].name] = v;
|
|
}
|
|
i += 2;
|
|
} else { children.push(args[i]); i++; }
|
|
}
|
|
var local = merge({}, comp.closure, env);
|
|
for (var pi = 0; pi < comp.params.length; pi++) {
|
|
var p = comp.params[pi];
|
|
local[p] = (p in kwargs) ? kwargs[p] : NIL;
|
|
}
|
|
if (comp.hasChildren) {
|
|
var cs = [];
|
|
for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env));
|
|
local["children"] = new RawHTML(cs.join(""));
|
|
}
|
|
return renderStr(comp.body, local);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Helpers
|
|
// =========================================================================
|
|
|
|
function merge(target) {
|
|
for (var i = 1; i < arguments.length; i++) {
|
|
var src = arguments[i];
|
|
if (src) for (var k in src) target[k] = src[k];
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function makeSet(str) {
|
|
var s = {}, parts = str.split(/\s+/);
|
|
for (var i = 0; i < parts.length; i++) if (parts[i]) s[parts[i]] = true;
|
|
return s;
|
|
}
|
|
|
|
/** Convert snake_case kwargs to kebab-case for sx conventions. */
|
|
function toKebab(s) { return s.replace(/_/g, "-"); }
|
|
|
|
// =========================================================================
|
|
// Public API
|
|
// =========================================================================
|
|
|
|
var _componentEnv = {};
|
|
|
|
// =========================================================================
|
|
// Head auto-hoist: move meta/title/link/script[ld+json] from body to <head>
|
|
// =========================================================================
|
|
|
|
var HEAD_HOIST_SELECTOR =
|
|
"meta, title, link[rel='canonical'], script[type='application/ld+json']";
|
|
|
|
function _hoistHeadElements(root) {
|
|
var els = root.querySelectorAll(HEAD_HOIST_SELECTOR);
|
|
if (!els.length) return;
|
|
var head = document.head;
|
|
for (var i = 0; i < els.length; i++) {
|
|
var el = els[i];
|
|
var tag = el.tagName.toLowerCase();
|
|
// For <title>, replace existing
|
|
if (tag === "title") {
|
|
document.title = el.textContent || "";
|
|
el.parentNode.removeChild(el);
|
|
continue;
|
|
}
|
|
// For <meta>, remove existing with same name/property to avoid duplicates
|
|
if (tag === "meta") {
|
|
var name = el.getAttribute("name");
|
|
var prop = el.getAttribute("property");
|
|
if (name) {
|
|
var old = head.querySelector('meta[name="' + name + '"]');
|
|
if (old) old.parentNode.removeChild(old);
|
|
}
|
|
if (prop) {
|
|
var old2 = head.querySelector('meta[property="' + prop + '"]');
|
|
if (old2) old2.parentNode.removeChild(old2);
|
|
}
|
|
}
|
|
// For <link rel=canonical>, remove existing
|
|
if (tag === "link" && el.getAttribute("rel") === "canonical") {
|
|
var oldLink = head.querySelector('link[rel="canonical"]');
|
|
if (oldLink) oldLink.parentNode.removeChild(oldLink);
|
|
}
|
|
// Move from body to head
|
|
el.parentNode.removeChild(el);
|
|
head.appendChild(el);
|
|
}
|
|
}
|
|
|
|
var Sx = {
|
|
// Types
|
|
NIL: NIL,
|
|
Symbol: Symbol,
|
|
Keyword: Keyword,
|
|
|
|
// Parser
|
|
parse: parse,
|
|
parseAll: parseAll,
|
|
|
|
// Evaluator
|
|
eval: function (expr, env) { return sxEval(expr, env || _componentEnv); },
|
|
|
|
// DOM Renderer
|
|
render: function (exprOrText, extraEnv) {
|
|
var 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);
|
|
},
|
|
|
|
// String Renderer (matches Python html.render output)
|
|
renderToString: function (exprOrText, extraEnv) {
|
|
var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText;
|
|
var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
|
|
return renderStr(expr, env);
|
|
},
|
|
|
|
/**
|
|
* Render a named component with keyword args (Python-style API).
|
|
* Sx.renderComponent("card", {title: "Hi"})
|
|
*/
|
|
renderComponent: function (name, kwargs, extraEnv) {
|
|
var fullName = name.charAt(0) === "~" ? name : "~" + name;
|
|
var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
|
|
var comp = env[fullName];
|
|
if (!isComponent(comp)) throw new Error("Unknown component: " + fullName);
|
|
// Build a synthetic call expression
|
|
var callExpr = [new Symbol(fullName)];
|
|
if (kwargs) {
|
|
for (var k in kwargs) {
|
|
callExpr.push(new Keyword(toKebab(k)));
|
|
callExpr.push(kwargs[k]);
|
|
}
|
|
}
|
|
return renderDOM(callExpr, env);
|
|
},
|
|
|
|
// Component management
|
|
loadComponents: function (text) {
|
|
try {
|
|
var exprs = parseAll(text);
|
|
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
|
|
} catch (err) {
|
|
// Enhanced error logging: show context around parse failure
|
|
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 li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1;
|
|
pos += errCol;
|
|
var start = Math.max(0, pos - 120);
|
|
var end = Math.min(text.length, pos + 120);
|
|
console.error("sx.js loadComponents PARSE ERROR:", 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 loadComponents error:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
getEnv: function () { return _componentEnv; },
|
|
|
|
// Utility
|
|
isTruthy: isSxTruthy,
|
|
isNil: isNil,
|
|
|
|
/**
|
|
* Mount a sx expression into a DOM element, replacing its contents.
|
|
* Sx.mount(el, '(~card :title "Hi")')
|
|
* Sx.mount("#target", '(~card :title "Hi")')
|
|
* Sx.mount(el, '(~card :title name)', {name: "Jo"})
|
|
*/
|
|
mount: function (target, exprOrText, extraEnv) {
|
|
var el = typeof target === "string" ? document.querySelector(target) : target;
|
|
if (!el) return;
|
|
var node;
|
|
try {
|
|
node = Sx.render(exprOrText, extraEnv);
|
|
} catch (e) {
|
|
if (typeof exprOrText === "string") {
|
|
var src = exprOrText;
|
|
// Find approx position from error message
|
|
var colMatch = e.message && e.message.match(/col (\d+)/);
|
|
var lineMatch = e.message && e.message.match(/line (\d+)/);
|
|
if (colMatch) {
|
|
var errLine = lineMatch ? parseInt(lineMatch[1]) : 1;
|
|
var errCol = parseInt(colMatch[1]);
|
|
var lines = src.split("\n");
|
|
var pos = 0;
|
|
for (var li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1;
|
|
pos += errCol;
|
|
var start = Math.max(0, pos - 80);
|
|
var end = Math.min(src.length, pos + 80);
|
|
console.error("sx.js MOUNT PARSE ERROR:", e.message,
|
|
"\n source length:", src.length,
|
|
"\n around error (pos ~" + pos + "):",
|
|
"\n «" + src.substring(start, pos) + "⛔" + src.substring(pos, end) + "»");
|
|
} else {
|
|
console.error("sx.js MOUNT PARSE ERROR:", e.message, "\n first 500:", src.substring(0, 500));
|
|
}
|
|
}
|
|
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
|
|
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
|
|
_eval: sxEval,
|
|
_renderStr: renderStr,
|
|
_renderDOM: renderDOM,
|
|
};
|
|
|
|
global.Sx = Sx;
|
|
|
|
// =========================================================================
|
|
// SxEngine — native fetch/swap/history engine (replaces HTMX)
|
|
// =========================================================================
|
|
|
|
var SxEngine = (function () {
|
|
if (typeof document === "undefined") return {};
|
|
|
|
// ---- helpers ----------------------------------------------------------
|
|
var PROCESSED = "_sxBound";
|
|
var VERBS = ["get", "post", "put", "delete", "patch"];
|
|
var DEFAULT_SWAP = "outerHTML";
|
|
|
|
|
|
function dispatch(el, name, detail) {
|
|
var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} });
|
|
return el.dispatchEvent(evt);
|
|
}
|
|
|
|
function csrfToken() {
|
|
var m = document.querySelector('meta[name="csrf-token"]');
|
|
return m ? m.getAttribute("content") : null;
|
|
}
|
|
|
|
function sameOrigin(url) {
|
|
try { return new URL(url, location.href).origin === location.origin; } catch (e) { return true; }
|
|
}
|
|
|
|
function resolveTarget(el, attr) {
|
|
var sel = el.getAttribute("sx-target") || attr;
|
|
if (!sel || sel === "this") return el;
|
|
if (sel === "closest") return el.parentElement;
|
|
return document.querySelector(sel);
|
|
}
|
|
|
|
function getVerb(el) {
|
|
for (var i = 0; i < VERBS.length; i++) {
|
|
var v = VERBS[i];
|
|
if (el.hasAttribute("sx-" + v)) return { method: v.toUpperCase(), url: el.getAttribute("sx-" + v) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---- Sync manager -----------------------------------------------------
|
|
var _controllers = new WeakMap();
|
|
|
|
function abortPrevious(el) {
|
|
var prev = _controllers.get(el);
|
|
if (prev) prev.abort();
|
|
}
|
|
|
|
function trackController(el, ctrl) {
|
|
_controllers.set(el, ctrl);
|
|
}
|
|
|
|
// ---- Request executor -------------------------------------------------
|
|
|
|
function executeRequest(el, verbInfo, extraParams) {
|
|
var method = verbInfo.method;
|
|
var url = verbInfo.url;
|
|
|
|
// sx-media: skip if media query doesn't match
|
|
var media = el.getAttribute("sx-media");
|
|
if (media && !window.matchMedia(media).matches) return Promise.resolve();
|
|
|
|
// sx-confirm: show dialog first
|
|
var confirmMsg = el.getAttribute("sx-confirm");
|
|
if (confirmMsg) {
|
|
if (typeof Swal !== "undefined") {
|
|
return Swal.fire({
|
|
title: confirmMsg,
|
|
icon: "warning",
|
|
showCancelButton: true,
|
|
confirmButtonText: "Yes",
|
|
cancelButtonText: "Cancel"
|
|
}).then(function (result) {
|
|
if (!result.isConfirmed) return;
|
|
return _doFetch(el, method, url, extraParams);
|
|
});
|
|
}
|
|
if (!window.confirm(confirmMsg)) return Promise.resolve();
|
|
}
|
|
|
|
return _doFetch(el, method, url, extraParams);
|
|
}
|
|
|
|
function _doFetch(el, method, url, extraParams) {
|
|
// sx-sync: abort previous
|
|
var sync = el.getAttribute("sx-sync");
|
|
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
|
|
|
|
var ctrl = new AbortController();
|
|
trackController(el, ctrl);
|
|
|
|
// Build headers
|
|
var headers = {
|
|
"SX-Request": "true",
|
|
"SX-Current-URL": location.href
|
|
};
|
|
var targetSel = el.getAttribute("sx-target");
|
|
if (targetSel) headers["SX-Target"] = targetSel;
|
|
|
|
// Send loaded component names so cross-domain responses can prepend missing defs
|
|
var loadedNames = Object.keys(_componentEnv).filter(function (k) {
|
|
return k.charAt(0) === "~";
|
|
});
|
|
if (loadedNames.length) headers["SX-Components"] = loadedNames.join(",");
|
|
|
|
// Send known CSS classes so server only sends new rules
|
|
var cssHeader = _getSxCssHeader();
|
|
if (cssHeader) headers["SX-Css"] = cssHeader;
|
|
|
|
// Extra headers from sx-headers
|
|
var extraH = el.getAttribute("sx-headers");
|
|
if (extraH) {
|
|
try {
|
|
var parsed = JSON.parse(extraH);
|
|
for (var k in parsed) headers[k] = parsed[k];
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// CSRF for same-origin mutating requests
|
|
if (method !== "GET" && sameOrigin(url)) {
|
|
var csrf = csrfToken();
|
|
if (csrf) headers["X-CSRFToken"] = csrf;
|
|
}
|
|
|
|
// Build body
|
|
var body = null;
|
|
var isJson = el.getAttribute("sx-encoding") === "json";
|
|
|
|
if (method !== "GET") {
|
|
var form = el.closest("form") || (el.tagName === "FORM" ? el : null);
|
|
if (form) {
|
|
if (isJson) {
|
|
var fd = new FormData(form);
|
|
var obj = {};
|
|
fd.forEach(function (v, k) {
|
|
if (obj[k] !== undefined) {
|
|
if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
|
|
obj[k].push(v);
|
|
} else {
|
|
obj[k] = v;
|
|
}
|
|
});
|
|
body = JSON.stringify(obj);
|
|
headers["Content-Type"] = "application/json";
|
|
} else {
|
|
body = new URLSearchParams(new FormData(form));
|
|
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Include extra inputs
|
|
var includeSel = el.getAttribute("sx-include");
|
|
if (includeSel && method !== "GET") {
|
|
var extras = document.querySelectorAll(includeSel);
|
|
if (!body) body = new URLSearchParams();
|
|
extras.forEach(function (inp) {
|
|
if (inp.name) body.append(inp.name, inp.value);
|
|
});
|
|
}
|
|
|
|
// sx-vals: merge extra key-value pairs
|
|
var valsAttr = el.getAttribute("sx-vals");
|
|
if (valsAttr) {
|
|
try {
|
|
var vals = JSON.parse(valsAttr);
|
|
if (method === "GET") {
|
|
for (var vk in vals) {
|
|
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);
|
|
}
|
|
} else if (body instanceof URLSearchParams) {
|
|
for (var vk2 in vals) body.append(vk2, vals[vk2]);
|
|
} else if (!body) {
|
|
body = new URLSearchParams();
|
|
for (var vk3 in vals) body.append(vk3, vals[vk3]);
|
|
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// For GET with form data, append to URL
|
|
if (method === "GET") {
|
|
var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null);
|
|
if (form2) {
|
|
var qs = new URLSearchParams(new FormData(form2)).toString();
|
|
if (qs) url += (url.indexOf("?") >= 0 ? "&" : "?") + qs;
|
|
}
|
|
// Also handle 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");
|
|
|
|
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
|
|
// Cross-origin credentials for known subdomains
|
|
try {
|
|
var urlHost = new URL(url, location.href).hostname;
|
|
if (urlHost !== location.hostname &&
|
|
(urlHost.endsWith(".rose-ash.com") || urlHost.endsWith(".localhost"))) {
|
|
fetchOpts.credentials = "include";
|
|
}
|
|
} catch (e) {}
|
|
if (body && method !== "GET") fetchOpts.body = body;
|
|
|
|
return fetch(url, fetchOpts).then(function (resp) {
|
|
el.classList.remove("sx-request");
|
|
el.removeAttribute("aria-busy");
|
|
|
|
if (!resp.ok) {
|
|
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
|
|
return _handleRetry(el, verbInfo, extraParams);
|
|
}
|
|
|
|
return resp.text().then(function (text) {
|
|
dispatch(el, "sx:afterRequest", { response: resp });
|
|
|
|
// Process the response
|
|
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
|
|
var target = resolveTarget(el, null);
|
|
var selectSel = el.getAttribute("sx-select");
|
|
|
|
// Check for text/sx content type — use direct DOM rendering path
|
|
var ct = resp.headers.get("Content-Type") || "";
|
|
if (ct.indexOf("text/sx") >= 0) {
|
|
try {
|
|
// Strip and load any <script type="text/sx" data-components> blocks
|
|
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
|
|
function (_, defs) { Sx.loadComponents(defs); return ""; });
|
|
// Process on-demand CSS: extract <style data-sx-css> and inject into head
|
|
text = _processCssResponse(text, resp);
|
|
var sxSource = text.trim();
|
|
|
|
// Parse and render to live DOM nodes (skip renderToString + DOMParser)
|
|
if (sxSource && sxSource.charAt(0) !== "(") {
|
|
console.error("sx.js: sxSource does not start with '(' — first 200 chars:", sxSource.substring(0, 200));
|
|
}
|
|
var sxDom = Sx.render(sxSource);
|
|
|
|
// Wrap in container for querySelectorAll (DocumentFragment doesn't support it)
|
|
var container = document.createElement("div");
|
|
container.appendChild(sxDom);
|
|
|
|
// OOB processing on live DOM nodes
|
|
var oobs = container.querySelectorAll("[sx-swap-oob]");
|
|
oobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("sx-swap-oob");
|
|
oob.parentNode.removeChild(oob);
|
|
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
|
|
});
|
|
|
|
// hx-swap-oob compat
|
|
var hxOobs = container.querySelectorAll("[hx-swap-oob]");
|
|
hxOobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("hx-swap-oob");
|
|
oob.parentNode.removeChild(oob);
|
|
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
|
|
});
|
|
|
|
// sx-select filtering
|
|
var selectedDOM;
|
|
if (selectSel) {
|
|
selectedDOM = document.createDocumentFragment();
|
|
selectSel.split(",").forEach(function (sel) {
|
|
container.querySelectorAll(sel.trim()).forEach(function (m) {
|
|
selectedDOM.appendChild(m);
|
|
});
|
|
});
|
|
} else {
|
|
// Use all remaining children
|
|
selectedDOM = document.createDocumentFragment();
|
|
while (container.firstChild) selectedDOM.appendChild(container.firstChild);
|
|
}
|
|
|
|
// Main swap using DOM morph
|
|
if (swapStyle !== "none" && target) {
|
|
_swapDOM(target, selectedDOM, swapStyle);
|
|
_hoistHeadElements(target);
|
|
}
|
|
} catch (err) {
|
|
console.error("sx.js render error [v2]:", err, "\nsxSource first 500:", sxSource ? sxSource.substring(0, 500) : "(empty)");
|
|
return;
|
|
}
|
|
} else {
|
|
// HTML string path — existing DOMParser pipeline
|
|
var parser = new DOMParser();
|
|
var doc = parser.parseFromString(text, "text/html");
|
|
|
|
// Process any sx script blocks in the response
|
|
Sx.processScripts(doc);
|
|
|
|
// OOB processing
|
|
var oobs = doc.querySelectorAll("[sx-swap-oob]");
|
|
oobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("sx-swap-oob");
|
|
if (oobTarget) {
|
|
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
|
}
|
|
oob.parentNode.removeChild(oob);
|
|
});
|
|
|
|
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
|
|
hxOobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("hx-swap-oob");
|
|
if (oobTarget) {
|
|
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
|
}
|
|
oob.parentNode.removeChild(oob);
|
|
});
|
|
|
|
// Build final content
|
|
var content;
|
|
if (selectSel) {
|
|
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
|
|
var frags = [];
|
|
parts.forEach(function (sel) {
|
|
var matches = doc.querySelectorAll(sel);
|
|
matches.forEach(function (m) { frags.push(m.outerHTML); });
|
|
});
|
|
content = frags.join("");
|
|
} else {
|
|
content = doc.body ? doc.body.innerHTML : text;
|
|
}
|
|
|
|
// Main swap
|
|
if (swapStyle !== "none" && target) {
|
|
_swapContent(target, content, swapStyle);
|
|
_hoistHeadElements(target);
|
|
}
|
|
}
|
|
|
|
// History
|
|
var pushUrl = el.getAttribute("sx-push-url");
|
|
if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) {
|
|
var pushTarget = pushUrl === "true" ? url : pushUrl;
|
|
try {
|
|
history.pushState({ sxUrl: pushTarget, scrollY: window.scrollY }, "", pushTarget);
|
|
} catch (e) {
|
|
// Cross-origin pushState not allowed — full navigation
|
|
location.assign(pushTarget);
|
|
return;
|
|
}
|
|
}
|
|
|
|
dispatch(el, "sx:afterSwap", { target: target });
|
|
// Settle tick
|
|
requestAnimationFrame(function () {
|
|
dispatch(el, "sx:afterSettle", { target: target });
|
|
});
|
|
});
|
|
}).catch(function (err) {
|
|
el.classList.remove("sx-request");
|
|
el.removeAttribute("aria-busy");
|
|
if (err.name === "AbortError") return;
|
|
dispatch(el, "sx:sendError", { error: err });
|
|
return _handleRetry(el, verbInfo, extraParams);
|
|
});
|
|
}
|
|
|
|
// ---- DOM morphing ------------------------------------------------------
|
|
|
|
/**
|
|
* Lightweight DOM reconciler — patches oldNode to match newNode in-place,
|
|
* preserving event listeners, focus, scroll position, and form state on
|
|
* keyed (id) elements.
|
|
*/
|
|
function _morphDOM(oldNode, newNode) {
|
|
// Different node types or tag names → replace wholesale
|
|
if (oldNode.nodeType !== newNode.nodeType ||
|
|
oldNode.nodeName !== newNode.nodeName) {
|
|
oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
|
|
return;
|
|
}
|
|
|
|
// Text/comment nodes → update content
|
|
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
|
|
if (oldNode.nodeValue !== newNode.nodeValue)
|
|
oldNode.nodeValue = newNode.nodeValue;
|
|
return;
|
|
}
|
|
|
|
// Element nodes → sync attributes, then recurse children
|
|
if (oldNode.nodeType === 1) {
|
|
// Skip morphing focused input to preserve user's in-progress edits
|
|
if (oldNode === document.activeElement &&
|
|
(oldNode.tagName === "INPUT" || oldNode.tagName === "TEXTAREA" || oldNode.tagName === "SELECT")) {
|
|
_syncAttrs(oldNode, newNode); // sync non-value attrs (class, style, etc.)
|
|
return; // don't touch value or children
|
|
}
|
|
_syncAttrs(oldNode, newNode);
|
|
_morphChildren(oldNode, newNode);
|
|
}
|
|
}
|
|
|
|
function _syncAttrs(old, neu) {
|
|
// Add/update attributes from new
|
|
var newAttrs = neu.attributes;
|
|
for (var i = 0; i < newAttrs.length; i++) {
|
|
var a = newAttrs[i];
|
|
if (old.getAttribute(a.name) !== a.value)
|
|
old.setAttribute(a.name, a.value);
|
|
}
|
|
// Remove attributes not in new
|
|
var oldAttrs = old.attributes;
|
|
for (var j = oldAttrs.length - 1; j >= 0; j--) {
|
|
if (!neu.hasAttribute(oldAttrs[j].name))
|
|
old.removeAttribute(oldAttrs[j].name);
|
|
}
|
|
}
|
|
|
|
function _morphChildren(oldParent, newParent) {
|
|
var oldChildren = Array.prototype.slice.call(oldParent.childNodes);
|
|
var newChildren = Array.prototype.slice.call(newParent.childNodes);
|
|
|
|
// Build ID map of old children for keyed matching
|
|
var oldById = {};
|
|
for (var k = 0; k < oldChildren.length; k++) {
|
|
var kid = oldChildren[k];
|
|
if (kid.id) oldById[kid.id] = kid;
|
|
}
|
|
|
|
var oi = 0;
|
|
for (var ni = 0; ni < newChildren.length; ni++) {
|
|
var newChild = newChildren[ni];
|
|
var matchById = newChild.id ? oldById[newChild.id] : null;
|
|
|
|
if (matchById) {
|
|
// Keyed match — move into position if needed, then morph
|
|
if (matchById !== oldChildren[oi]) {
|
|
oldParent.insertBefore(matchById, oldChildren[oi] || null);
|
|
}
|
|
_morphDOM(matchById, newChild);
|
|
oi++;
|
|
} else if (oi < oldChildren.length) {
|
|
// Positional match — morph in place
|
|
var oldChild = oldChildren[oi];
|
|
if (oldChild.id && !newChild.id) {
|
|
// Old has ID, new doesn't — insert new before old (don't clobber keyed)
|
|
oldParent.insertBefore(newChild.cloneNode(true), oldChild);
|
|
} else {
|
|
_morphDOM(oldChild, newChild);
|
|
oi++;
|
|
}
|
|
} else {
|
|
// Extra new children — append
|
|
oldParent.appendChild(newChild.cloneNode(true));
|
|
}
|
|
}
|
|
|
|
// Remove leftover old children
|
|
while (oi < oldChildren.length) {
|
|
var leftover = oldChildren[oi];
|
|
if (leftover.parentNode === oldParent) oldParent.removeChild(leftover);
|
|
oi++;
|
|
}
|
|
}
|
|
|
|
// ---- DOM-native swap engine --------------------------------------------
|
|
|
|
/**
|
|
* Swap using live DOM nodes (from Sx.render) instead of HTML strings.
|
|
* Uses _morphDOM for innerHTML/outerHTML to preserve state.
|
|
*/
|
|
function _swapDOM(target, newNodes, strategy) {
|
|
// newNodes is a DocumentFragment, Element, or Text node
|
|
var wrapper;
|
|
switch (strategy) {
|
|
case "innerHTML":
|
|
// Morph children of target to match newNodes
|
|
if (newNodes.nodeType === 11) {
|
|
// DocumentFragment — morph its children into target
|
|
_morphChildren(target, newNodes);
|
|
} else {
|
|
wrapper = document.createElement("div");
|
|
wrapper.appendChild(newNodes);
|
|
_morphChildren(target, wrapper);
|
|
}
|
|
break;
|
|
case "outerHTML":
|
|
var parent = target.parentNode;
|
|
if (newNodes.nodeType === 11) {
|
|
// Fragment — morph first child, insert rest
|
|
var first = newNodes.firstChild;
|
|
if (first) {
|
|
_morphDOM(target, first);
|
|
var sib = first.nextSibling; // skip first (used as morph template, not consumed)
|
|
while (sib) {
|
|
var next = sib.nextSibling;
|
|
parent.insertBefore(sib, target.nextSibling);
|
|
sib = next;
|
|
}
|
|
} else {
|
|
parent.removeChild(target);
|
|
}
|
|
} else {
|
|
_morphDOM(target, newNodes);
|
|
}
|
|
_activateScripts(parent);
|
|
Sx.processScripts(parent);
|
|
Sx.hydrate(parent);
|
|
SxEngine.process(parent);
|
|
return; // early return like existing outerHTML
|
|
case "afterend":
|
|
target.parentNode.insertBefore(newNodes, target.nextSibling);
|
|
break;
|
|
case "beforeend":
|
|
target.appendChild(newNodes);
|
|
break;
|
|
case "afterbegin":
|
|
target.insertBefore(newNodes, target.firstChild);
|
|
break;
|
|
case "beforebegin":
|
|
target.parentNode.insertBefore(newNodes, target);
|
|
break;
|
|
case "delete":
|
|
target.parentNode.removeChild(target);
|
|
return;
|
|
default: // fallback = innerHTML
|
|
if (newNodes.nodeType === 11) {
|
|
_morphChildren(target, newNodes);
|
|
} else {
|
|
wrapper = document.createElement("div");
|
|
wrapper.appendChild(newNodes);
|
|
_morphChildren(target, wrapper);
|
|
}
|
|
}
|
|
_activateScripts(target);
|
|
Sx.processScripts(target);
|
|
Sx.hydrate(target);
|
|
SxEngine.process(target);
|
|
}
|
|
|
|
// ---- Swap engine (string-based, kept as fallback) ----------------------
|
|
|
|
/** Scripts inserted via innerHTML/insertAdjacentHTML don't execute.
|
|
* Recreate them as live elements so the browser fetches & runs them. */
|
|
function _activateScripts(root) {
|
|
var dead = root.querySelectorAll("script:not([type]), script[type='text/javascript']");
|
|
for (var i = 0; i < dead.length; i++) {
|
|
var d = dead[i];
|
|
var live = document.createElement("script");
|
|
for (var a = 0; a < d.attributes.length; a++)
|
|
live.setAttribute(d.attributes[a].name, d.attributes[a].value);
|
|
live.textContent = d.textContent;
|
|
d.parentNode.replaceChild(live, d);
|
|
}
|
|
}
|
|
|
|
function _swapContent(target, html, strategy) {
|
|
switch (strategy) {
|
|
case "innerHTML":
|
|
target.innerHTML = html;
|
|
break;
|
|
case "outerHTML":
|
|
var tgt = target;
|
|
var parent = tgt.parentNode;
|
|
tgt.insertAdjacentHTML("afterend", html);
|
|
parent.removeChild(tgt);
|
|
// Process parent to catch all newly inserted siblings
|
|
_activateScripts(parent);
|
|
Sx.processScripts(parent);
|
|
Sx.hydrate(parent);
|
|
SxEngine.process(parent);
|
|
return; // early return — afterSwap handling done inline
|
|
case "afterend":
|
|
target.insertAdjacentHTML("afterend", html);
|
|
break;
|
|
case "beforeend":
|
|
target.insertAdjacentHTML("beforeend", html);
|
|
break;
|
|
case "afterbegin":
|
|
target.insertAdjacentHTML("afterbegin", html);
|
|
break;
|
|
case "beforebegin":
|
|
target.insertAdjacentHTML("beforebegin", html);
|
|
break;
|
|
case "delete":
|
|
target.parentNode.removeChild(target);
|
|
return;
|
|
default:
|
|
target.innerHTML = html;
|
|
}
|
|
_activateScripts(target);
|
|
Sx.processScripts(target);
|
|
Sx.hydrate(target);
|
|
SxEngine.process(target);
|
|
}
|
|
|
|
// ---- Retry system -----------------------------------------------------
|
|
|
|
function _handleRetry(el, verbInfo, extraParams) {
|
|
var retry = el.getAttribute("sx-retry");
|
|
if (!retry) return;
|
|
|
|
var parts = retry.split(":");
|
|
var strategy = parts[0]; // "exponential"
|
|
var startMs = parseInt(parts[1], 10) || 1000;
|
|
var capMs = parseInt(parts[2], 10) || 30000;
|
|
|
|
var currentMs = parseInt(el.getAttribute("data-sx-retry-ms"), 10) || startMs;
|
|
|
|
el.classList.add("sx-error");
|
|
el.classList.remove("sx-loading");
|
|
|
|
setTimeout(function () {
|
|
el.classList.remove("sx-error");
|
|
el.classList.add("sx-loading");
|
|
el.setAttribute("data-sx-retry-ms", Math.min(currentMs * 2, capMs));
|
|
executeRequest(el, verbInfo, extraParams);
|
|
}, currentMs);
|
|
}
|
|
|
|
// ---- Trigger system ---------------------------------------------------
|
|
|
|
function parseTrigger(spec) {
|
|
if (!spec) return null;
|
|
var triggers = [];
|
|
var parts = spec.split(",");
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var p = parts[i].trim();
|
|
if (!p) continue;
|
|
var tokens = p.split(/\s+/);
|
|
var trigger = { event: tokens[0], modifiers: {} };
|
|
for (var j = 1; j < tokens.length; j++) {
|
|
var tok = tokens[j];
|
|
if (tok === "once") trigger.modifiers.once = true;
|
|
else if (tok === "changed") trigger.modifiers.changed = true;
|
|
else if (tok.indexOf("delay:") === 0) trigger.modifiers.delay = parseInt(tok.substring(6), 10);
|
|
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
|
|
}
|
|
triggers.push(trigger);
|
|
}
|
|
return triggers;
|
|
}
|
|
|
|
function bindTriggers(el, verbInfo) {
|
|
var triggerSpec = el.getAttribute("sx-trigger");
|
|
var triggers;
|
|
|
|
if (triggerSpec) {
|
|
triggers = parseTrigger(triggerSpec);
|
|
} else {
|
|
// Defaults
|
|
if (el.tagName === "FORM") {
|
|
triggers = [{ event: "submit", modifiers: {} }];
|
|
} else if (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") {
|
|
triggers = [{ event: "change", modifiers: {} }];
|
|
} else {
|
|
triggers = [{ event: "click", modifiers: {} }];
|
|
}
|
|
}
|
|
|
|
triggers.forEach(function (trig) {
|
|
if (trig.event === "intersect") {
|
|
_bindIntersect(el, verbInfo, trig.modifiers);
|
|
} else if (trig.event === "load") {
|
|
setTimeout(function () { executeRequest(el, verbInfo); }, 0);
|
|
} else if (trig.event === "revealed") {
|
|
_bindIntersect(el, verbInfo, { once: true });
|
|
} else {
|
|
_bindEvent(el, verbInfo, trig);
|
|
}
|
|
});
|
|
}
|
|
|
|
function _bindEvent(el, verbInfo, trig) {
|
|
var eventName = trig.event;
|
|
var mods = trig.modifiers;
|
|
var listenTarget = mods.from ? document.querySelector(mods.from) || el : el;
|
|
var timer = null;
|
|
var lastVal = undefined;
|
|
|
|
var handler = function (e) {
|
|
// For form submissions, prevent default
|
|
if (eventName === "submit") e.preventDefault();
|
|
// For links, prevent navigation
|
|
if (eventName === "click" && el.tagName === "A") e.preventDefault();
|
|
|
|
// changed modifier: only fire if value changed
|
|
if (mods.changed && el.value !== undefined) {
|
|
if (el.value === lastVal) return;
|
|
lastVal = el.value;
|
|
}
|
|
|
|
if (mods.delay) {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(function () { executeRequest(el, verbInfo); }, mods.delay);
|
|
} else {
|
|
executeRequest(el, verbInfo);
|
|
}
|
|
};
|
|
|
|
listenTarget.addEventListener(eventName, handler, { once: !!mods.once });
|
|
}
|
|
|
|
function _bindIntersect(el, verbInfo, mods) {
|
|
if (!("IntersectionObserver" in window)) {
|
|
executeRequest(el, verbInfo);
|
|
return;
|
|
}
|
|
var fired = false;
|
|
var delay = mods.delay || 0;
|
|
var obs = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (!entry.isIntersecting) return;
|
|
if (mods.once && fired) return;
|
|
fired = true;
|
|
if (mods.once) obs.unobserve(el);
|
|
if (delay) {
|
|
setTimeout(function () { executeRequest(el, verbInfo); }, delay);
|
|
} else {
|
|
executeRequest(el, verbInfo);
|
|
}
|
|
});
|
|
});
|
|
obs.observe(el);
|
|
}
|
|
|
|
// ---- History manager --------------------------------------------------
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("popstate", function (e) {
|
|
var url = location.href;
|
|
var main = document.getElementById("main-panel");
|
|
if (!main) { location.reload(); return; }
|
|
|
|
var histHeaders = { "SX-Request": "true", "SX-History-Restore": "true" };
|
|
var cssH = _getSxCssHeader();
|
|
if (cssH) histHeaders["SX-Css"] = cssH;
|
|
var loadedN = Object.keys(_componentEnv).filter(function (k) { return k.charAt(0) === "~"; });
|
|
if (loadedN.length) histHeaders["SX-Components"] = loadedN.join(",");
|
|
var histOpts = { headers: histHeaders };
|
|
try {
|
|
var hHost = new URL(url, location.href).hostname;
|
|
if (hHost !== location.hostname &&
|
|
(hHost.endsWith(".rose-ash.com") || hHost.endsWith(".localhost"))) {
|
|
histOpts.credentials = "include";
|
|
}
|
|
} catch (e) {}
|
|
|
|
fetch(url, histOpts).then(function (resp) {
|
|
return resp.text().then(function (t) { return { text: t, resp: resp }; });
|
|
}).then(function (r) {
|
|
var text = r.text;
|
|
var resp = r.resp;
|
|
// Strip and load any <script type="text/sx" data-components> blocks
|
|
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
|
|
function (_, defs) { Sx.loadComponents(defs); return ""; });
|
|
// Process on-demand CSS
|
|
text = _processCssResponse(text, resp);
|
|
text = text.trim();
|
|
|
|
if (text.charAt(0) === "(") {
|
|
// sx response — render to live DOM, morph into main
|
|
try {
|
|
var popDom = Sx.render(text);
|
|
var popContainer = document.createElement("div");
|
|
popContainer.appendChild(popDom);
|
|
|
|
// Process OOB swaps (sidebar, filter, menu, headers)
|
|
var oobs = popContainer.querySelectorAll("[sx-swap-oob]");
|
|
oobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("sx-swap-oob");
|
|
oob.parentNode.removeChild(oob);
|
|
if (oobTarget) {
|
|
_swapDOM(oobTarget, oob, oobSwap);
|
|
Sx.hydrate(oobTarget);
|
|
SxEngine.process(oobTarget);
|
|
}
|
|
});
|
|
var hxOobs = popContainer.querySelectorAll("[hx-swap-oob]");
|
|
hxOobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("hx-swap-oob");
|
|
oob.parentNode.removeChild(oob);
|
|
if (oobTarget) {
|
|
_swapDOM(oobTarget, oob, oobSwap);
|
|
Sx.hydrate(oobTarget);
|
|
SxEngine.process(oobTarget);
|
|
}
|
|
});
|
|
|
|
var newMain = popContainer.querySelector("#main-panel");
|
|
_morphChildren(main, newMain || popContainer);
|
|
_activateScripts(main);
|
|
Sx.processScripts(main);
|
|
Sx.hydrate(main);
|
|
SxEngine.process(main);
|
|
dispatch(document.body, "sx:afterSettle", { target: main });
|
|
window.scrollTo(0, e.state && e.state.scrollY || 0);
|
|
} catch (err) {
|
|
console.error("sx.js popstate render error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
|
|
location.reload();
|
|
}
|
|
} else {
|
|
// HTML response — parse and morph
|
|
var parser = new DOMParser();
|
|
var doc = parser.parseFromString(text, "text/html");
|
|
|
|
// Process OOB swaps from HTML response
|
|
var hOobs = doc.querySelectorAll("[sx-swap-oob]");
|
|
hOobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("sx-swap-oob");
|
|
if (oobTarget) {
|
|
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
|
}
|
|
oob.parentNode.removeChild(oob);
|
|
});
|
|
var hhOobs = doc.querySelectorAll("[hx-swap-oob]");
|
|
hhOobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("hx-swap-oob");
|
|
if (oobTarget) {
|
|
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
|
}
|
|
oob.parentNode.removeChild(oob);
|
|
});
|
|
|
|
var newMain = doc.getElementById("main-panel");
|
|
if (newMain) {
|
|
_morphChildren(main, newMain);
|
|
_activateScripts(main);
|
|
Sx.processScripts(main);
|
|
Sx.hydrate(main);
|
|
SxEngine.process(main);
|
|
dispatch(document.body, "sx:afterSettle", { target: main });
|
|
window.scrollTo(0, e.state && e.state.scrollY || 0);
|
|
} else {
|
|
location.reload();
|
|
}
|
|
}
|
|
}).catch(function () {
|
|
location.reload();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- sx-on:* inline event handlers ------------------------------------
|
|
|
|
function _bindInlineHandlers(el) {
|
|
var attrs = el.attributes;
|
|
for (var i = 0; i < attrs.length; i++) {
|
|
var name = attrs[i].name;
|
|
if (name.indexOf("sx-on:") === 0) {
|
|
var evtName = name.substring(6);
|
|
el.addEventListener(evtName, new Function("event", attrs[i].value));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Process function -------------------------------------------------
|
|
|
|
function process(root) {
|
|
root = root || document.body;
|
|
if (!root || !root.querySelectorAll) return;
|
|
|
|
var selector = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]";
|
|
var elements = root.querySelectorAll(selector);
|
|
|
|
// Also check root itself
|
|
if (root.matches && root.matches(selector)) {
|
|
_processOne(root);
|
|
}
|
|
|
|
for (var i = 0; i < elements.length; i++) {
|
|
_processOne(elements[i]);
|
|
}
|
|
|
|
// Bind sx-on:* handlers on all elements
|
|
var allOnEls = root.querySelectorAll("[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]");
|
|
allOnEls.forEach(function (el) {
|
|
if (el[PROCESSED + "on"]) return;
|
|
el[PROCESSED + "on"] = true;
|
|
_bindInlineHandlers(el);
|
|
});
|
|
}
|
|
|
|
function _processOne(el) {
|
|
if (el[PROCESSED]) return;
|
|
// sx-disable: skip processing
|
|
if (el.hasAttribute("sx-disable") || el.closest("[sx-disable]")) return;
|
|
el[PROCESSED] = true;
|
|
|
|
var verbInfo = getVerb(el);
|
|
if (!verbInfo) return;
|
|
|
|
bindTriggers(el, verbInfo);
|
|
}
|
|
|
|
// ---- Public API -------------------------------------------------------
|
|
|
|
var engine = {
|
|
process: process,
|
|
executeRequest: executeRequest,
|
|
version: "1.0.0"
|
|
};
|
|
|
|
return engine;
|
|
})();
|
|
|
|
global.SxEngine = SxEngine;
|
|
|
|
// =========================================================================
|
|
// Auto-init in browser
|
|
// =========================================================================
|
|
|
|
Sx.VERSION = "2026-03-01c-cssx";
|
|
|
|
// CSS class tracking for on-demand CSS delivery
|
|
var _sxCssKnown = {};
|
|
var _sxCssHash = ""; // 8-char hex hash from server
|
|
|
|
function _initCssTracking() {
|
|
var meta = document.querySelector('meta[name="sx-css-classes"]');
|
|
if (meta) {
|
|
var content = meta.getAttribute("content");
|
|
if (content) {
|
|
// If content is short (≤16 chars), it's a hash from the server
|
|
if (content.length <= 16) {
|
|
_sxCssHash = content;
|
|
} else {
|
|
content.split(",").forEach(function (c) {
|
|
if (c) _sxCssKnown[c] = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function _getSxCssHeader() {
|
|
// Prefer sending the hash (compact) over the full class list
|
|
if (_sxCssHash) return _sxCssHash;
|
|
var names = Object.keys(_sxCssKnown);
|
|
return names.length ? names.join(",") : "";
|
|
}
|
|
|
|
function _processCssResponse(text, resp) {
|
|
// Read SX-Css-Hash response header — replaces local hash
|
|
var hashHeader = resp.headers.get("SX-Css-Hash");
|
|
if (hashHeader) _sxCssHash = hashHeader;
|
|
|
|
// Merge SX-Css-Add header into known set (kept for debugging/fallback)
|
|
var addHeader = resp.headers.get("SX-Css-Add");
|
|
if (addHeader) {
|
|
addHeader.split(",").forEach(function (c) {
|
|
if (c) _sxCssKnown[c] = true;
|
|
});
|
|
}
|
|
// Extract <style data-sx-css>...</style> blocks and inject into <style id="sx-css">
|
|
var cssTarget = document.getElementById("sx-css");
|
|
if (cssTarget) {
|
|
text = text.replace(/<style[^>]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi,
|
|
function (_, css) {
|
|
cssTarget.textContent += css;
|
|
return "";
|
|
});
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sx-comp-hash cookie helpers (component caching)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|