renderComponentDOM was deferring evaluation of complex expressions (arrays) passed as component kwargs, storing raw AST instead. When the component body later used these values as attributes, the caller's env (with lambda params like t, a) was no longer available, producing stringified arrays like "get,t,src" as attribute values — which browsers interpreted as relative URLs. Evaluate all non-literal kwarg values eagerly in the caller's env, matching the behavior of callComponent and the Python-side renderer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2000 lines
70 KiB
JavaScript
2000 lines
70 KiB
JavaScript
/**
|
|
* sx.js — S-expression parser, evaluator, and DOM renderer.
|
|
*
|
|
* Client-side counterpart to shared/sx/ Python modules.
|
|
* Parses s-expression text, evaluates it, and renders to DOM nodes.
|
|
*
|
|
* Usage:
|
|
* Sx.loadComponents('(defcomp ~card (&key title) (div :class "c" title))');
|
|
* const node = Sx.render('(~card :title "Hello")');
|
|
* document.body.appendChild(node);
|
|
*/
|
|
;(function (global) {
|
|
"use strict";
|
|
|
|
// =========================================================================
|
|
// Types
|
|
// =========================================================================
|
|
|
|
/** Singleton nil — falsy placeholder. */
|
|
var NIL = Object.freeze({ _nil: true, toString: function () { return "nil"; } });
|
|
|
|
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
|
function isTruthy(x) { return x !== false && !isNil(x) && x !== 0 && x !== ""; }
|
|
// Note: 0 and "" are falsy in sx but we match Python semantics where
|
|
// only nil/false/None are falsy for control flow. Revisit if needed.
|
|
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
|
|
|
function Symbol(name) { this.name = name; }
|
|
Symbol.prototype.toString = function () { return this.name; };
|
|
Symbol.prototype._sym = true;
|
|
|
|
function Keyword(name) { this.name = name; }
|
|
Keyword.prototype.toString = function () { return ":" + this.name; };
|
|
Keyword.prototype._kw = true;
|
|
|
|
function Lambda(params, body, closure, name) {
|
|
this.params = params;
|
|
this.body = body;
|
|
this.closure = closure || {};
|
|
this.name = name || null;
|
|
}
|
|
Lambda.prototype._lambda = true;
|
|
|
|
function Component(name, params, hasChildren, body, closure) {
|
|
this.name = name;
|
|
this.params = params;
|
|
this.hasChildren = hasChildren;
|
|
this.body = body;
|
|
this.closure = closure || {};
|
|
}
|
|
Component.prototype._component = true;
|
|
|
|
/** Marker for pre-rendered HTML that bypasses escaping. */
|
|
function RawHTML(html) { this.html = html; }
|
|
RawHTML.prototype._raw = true;
|
|
|
|
function isSym(x) { return x && x._sym === true; }
|
|
function isKw(x) { return x && x._kw === true; }
|
|
function isLambda(x) { return x && x._lambda === true; }
|
|
function isComponent(x) { return x && x._component === true; }
|
|
function isRaw(x) { return x && x._raw === true; }
|
|
|
|
// =========================================================================
|
|
// Parser
|
|
// =========================================================================
|
|
|
|
var RE_WS = /\s+/y;
|
|
var RE_COMMENT = /;[^\n]*/y;
|
|
var RE_STRING = /"(?:[^"\\]|\\.)*"/y;
|
|
var RE_NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y;
|
|
var RE_KEYWORD = /:[a-zA-Z_][a-zA-Z0-9_>:\-]*/y;
|
|
var RE_SYMBOL = /[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*/y;
|
|
|
|
function Tokenizer(text) {
|
|
this.text = text;
|
|
this.pos = 0;
|
|
this.line = 1;
|
|
this.col = 1;
|
|
}
|
|
|
|
Tokenizer.prototype._advance = function (count) {
|
|
for (var i = 0; i < count; i++) {
|
|
if (this.pos < this.text.length) {
|
|
if (this.text[this.pos] === "\n") { this.line++; this.col = 1; }
|
|
else { this.col++; }
|
|
this.pos++;
|
|
}
|
|
}
|
|
};
|
|
|
|
Tokenizer.prototype._skip = function () {
|
|
while (this.pos < this.text.length) {
|
|
RE_WS.lastIndex = this.pos;
|
|
var m = RE_WS.exec(this.text);
|
|
if (m && m.index === this.pos) { this._advance(m[0].length); continue; }
|
|
RE_COMMENT.lastIndex = this.pos;
|
|
m = RE_COMMENT.exec(this.text);
|
|
if (m && m.index === this.pos) { this._advance(m[0].length); continue; }
|
|
break;
|
|
}
|
|
};
|
|
|
|
Tokenizer.prototype.peek = function () {
|
|
this._skip();
|
|
return this.pos < this.text.length ? this.text[this.pos] : null;
|
|
};
|
|
|
|
Tokenizer.prototype.next = function () {
|
|
this._skip();
|
|
if (this.pos >= this.text.length) return null;
|
|
|
|
var ch = this.text[this.pos];
|
|
|
|
// Delimiters
|
|
if ("()[]{}".indexOf(ch) !== -1) { this._advance(1); return ch; }
|
|
|
|
// String
|
|
if (ch === '"') {
|
|
RE_STRING.lastIndex = this.pos;
|
|
var m = RE_STRING.exec(this.text);
|
|
if (!m || m.index !== this.pos) throw parseErr("Unterminated string", this);
|
|
this._advance(m[0].length);
|
|
var raw = m[0].slice(1, -1);
|
|
return raw.replace(/\\n/g, "\n").replace(/\\t/g, "\t")
|
|
.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
}
|
|
|
|
// Keyword
|
|
if (ch === ":") {
|
|
RE_KEYWORD.lastIndex = this.pos;
|
|
m = RE_KEYWORD.exec(this.text);
|
|
if (!m || m.index !== this.pos) throw parseErr("Invalid keyword", this);
|
|
this._advance(m[0].length);
|
|
return new Keyword(m[0].slice(1));
|
|
}
|
|
|
|
// Number (before symbol due to leading -)
|
|
if (isDigit(ch) || (ch === "-" && this.pos + 1 < this.text.length &&
|
|
(isDigit(this.text[this.pos + 1]) || this.text[this.pos + 1] === "."))) {
|
|
RE_NUMBER.lastIndex = this.pos;
|
|
m = RE_NUMBER.exec(this.text);
|
|
if (m && m.index === this.pos) {
|
|
this._advance(m[0].length);
|
|
var s = m[0];
|
|
return (s.indexOf(".") !== -1 || s.indexOf("e") !== -1 || s.indexOf("E") !== -1)
|
|
? parseFloat(s) : parseInt(s, 10);
|
|
}
|
|
}
|
|
|
|
// Symbol
|
|
RE_SYMBOL.lastIndex = this.pos;
|
|
m = RE_SYMBOL.exec(this.text);
|
|
if (m && m.index === this.pos) {
|
|
this._advance(m[0].length);
|
|
var name = m[0];
|
|
if (name === "true") return true;
|
|
if (name === "false") return false;
|
|
if (name === "nil") return NIL;
|
|
return new Symbol(name);
|
|
}
|
|
|
|
var ctx = this.text.substring(Math.max(0, this.pos - 40), this.pos + 40);
|
|
throw parseErr("Unexpected character: " + ch + " | context: «" + ctx.replace(/\n/g, "\\n") + "»", this);
|
|
};
|
|
|
|
function isDigit(c) { return c >= "0" && c <= "9"; }
|
|
|
|
function parseErr(msg, tok) {
|
|
return new Error(msg + " at line " + tok.line + ", col " + tok.col);
|
|
}
|
|
|
|
function parseExpr(tok) {
|
|
var token = tok.next();
|
|
if (token === null) throw parseErr("Unexpected end of input", tok);
|
|
if (token === "(") return parseList(tok, ")");
|
|
if (token === "[") return parseList(tok, "]");
|
|
if (token === "{") return parseMap(tok);
|
|
if (token === ")" || token === "]" || token === "}") {
|
|
throw parseErr("Unexpected " + token, tok);
|
|
}
|
|
return token;
|
|
}
|
|
|
|
function parseList(tok, closer) {
|
|
var items = [];
|
|
while (true) {
|
|
var c = tok.peek();
|
|
if (c === null) throw parseErr("Unterminated list, expected " + closer, tok);
|
|
if (c === closer) { tok.next(); return items; }
|
|
items.push(parseExpr(tok));
|
|
}
|
|
}
|
|
|
|
function parseMap(tok) {
|
|
var result = {};
|
|
while (true) {
|
|
var c = tok.peek();
|
|
if (c === null) throw parseErr("Unterminated map", tok);
|
|
if (c === "}") { tok.next(); return result; }
|
|
var key = parseExpr(tok);
|
|
var keyStr = isKw(key) ? key.name : String(key);
|
|
result[keyStr] = parseExpr(tok);
|
|
}
|
|
}
|
|
|
|
/** Parse a single s-expression. */
|
|
function parse(text) {
|
|
var tok = new Tokenizer(text);
|
|
var result = parseExpr(tok);
|
|
if (tok.peek() !== null) throw parseErr("Unexpected content after expression", tok);
|
|
return result;
|
|
}
|
|
|
|
/** Parse zero or more s-expressions. */
|
|
function parseAll(text) {
|
|
var tok = new Tokenizer(text);
|
|
var results = [];
|
|
while (tok.peek() !== null) results.push(parseExpr(tok));
|
|
return results;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Primitives
|
|
// =========================================================================
|
|
|
|
var PRIMITIVES = {};
|
|
|
|
// Arithmetic
|
|
PRIMITIVES["+"] = function () { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; };
|
|
PRIMITIVES["-"] = function (a, b) { return arguments.length === 1 ? -a : a - b; };
|
|
PRIMITIVES["*"] = function () { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; };
|
|
PRIMITIVES["/"] = function (a, b) { return a / b; };
|
|
PRIMITIVES["mod"] = function (a, b) { return a % b; };
|
|
PRIMITIVES["inc"] = function (n) { return n + 1; };
|
|
PRIMITIVES["dec"] = function (n) { return n - 1; };
|
|
PRIMITIVES["abs"] = Math.abs;
|
|
PRIMITIVES["floor"] = Math.floor;
|
|
PRIMITIVES["ceil"] = Math.ceil;
|
|
PRIMITIVES["round"] = Math.round;
|
|
PRIMITIVES["min"] = Math.min;
|
|
PRIMITIVES["max"] = Math.max;
|
|
PRIMITIVES["sqrt"] = Math.sqrt;
|
|
PRIMITIVES["pow"] = Math.pow;
|
|
|
|
// Comparison
|
|
PRIMITIVES["="] = function (a, b) { return a == b; }; // loose, matches Python sx
|
|
PRIMITIVES["!="] = function (a, b) { return a != b; };
|
|
PRIMITIVES["<"] = function (a, b) { return a < b; };
|
|
PRIMITIVES[">"] = function (a, b) { return a > b; };
|
|
PRIMITIVES["<="] = function (a, b) { return a <= b; };
|
|
PRIMITIVES[">="] = function (a, b) { return a >= b; };
|
|
|
|
// Logic
|
|
PRIMITIVES["not"] = function (x) { return !isSxTruthy(x); };
|
|
|
|
// String
|
|
PRIMITIVES["str"] = function () {
|
|
var parts = [];
|
|
for (var i = 0; i < arguments.length; i++) {
|
|
var v = arguments[i];
|
|
if (isNil(v)) continue;
|
|
parts.push(String(v));
|
|
}
|
|
return parts.join("");
|
|
};
|
|
PRIMITIVES["upper"] = function (s) { return String(s).toUpperCase(); };
|
|
PRIMITIVES["lower"] = function (s) { return String(s).toLowerCase(); };
|
|
PRIMITIVES["trim"] = function (s) { return String(s).trim(); };
|
|
PRIMITIVES["split"] = function (s, sep) { return String(s).split(sep); };
|
|
PRIMITIVES["join"] = function (sep, coll) { return coll.join(sep); };
|
|
PRIMITIVES["starts-with?"] = function (s, p) { return String(s).indexOf(p) === 0; };
|
|
PRIMITIVES["ends-with?"] = function (s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
|
PRIMITIVES["concat"] = function () {
|
|
var out = [];
|
|
for (var i = 0; i < arguments.length; i++) out = out.concat(arguments[i]);
|
|
return out;
|
|
};
|
|
|
|
// Predicates
|
|
PRIMITIVES["nil?"] = function (x) { return isNil(x); };
|
|
PRIMITIVES["number?"] = function (x) { return typeof x === "number"; };
|
|
PRIMITIVES["string?"] = function (x) { return typeof x === "string"; };
|
|
PRIMITIVES["list?"] = function (x) { return Array.isArray(x); };
|
|
PRIMITIVES["dict?"] = function (x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; };
|
|
PRIMITIVES["empty?"] = function (c) { return !c || (Array.isArray(c) ? c.length === 0 : Object.keys(c).length === 0); };
|
|
PRIMITIVES["contains?"] = function (c, k) { return Array.isArray(c) ? c.indexOf(k) !== -1 : k in c; };
|
|
PRIMITIVES["odd?"] = function (n) { return n % 2 !== 0; };
|
|
PRIMITIVES["even?"] = function (n) { return n % 2 === 0; };
|
|
PRIMITIVES["zero?"] = function (n) { return n === 0; };
|
|
|
|
// Collections
|
|
PRIMITIVES["list"] = function () { return Array.prototype.slice.call(arguments); };
|
|
PRIMITIVES["dict"] = function () {
|
|
var d = {};
|
|
for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1];
|
|
return d;
|
|
};
|
|
PRIMITIVES["get"] = function (c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); };
|
|
PRIMITIVES["len"] = function (c) { return Array.isArray(c) ? c.length : Object.keys(c).length; };
|
|
PRIMITIVES["first"] = function (c) { return c && c.length > 0 ? c[0] : NIL; };
|
|
PRIMITIVES["last"] = function (c) { return c && c.length > 0 ? c[c.length - 1] : NIL; };
|
|
PRIMITIVES["rest"] = function (c) { return c ? c.slice(1) : []; };
|
|
PRIMITIVES["nth"] = function (c, n) { return c && n < c.length ? c[n] : NIL; };
|
|
PRIMITIVES["cons"] = function (x, c) { return [x].concat(c || []); };
|
|
PRIMITIVES["append"] = function (c, x) { return (c || []).concat([x]); };
|
|
PRIMITIVES["keys"] = function (d) { return Object.keys(d || {}); };
|
|
PRIMITIVES["vals"] = function (d) { var r = []; for (var k in d) r.push(d[k]); return r; };
|
|
PRIMITIVES["merge"] = function () {
|
|
var out = {};
|
|
for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; for (var k in d) out[k] = d[k]; }
|
|
return out;
|
|
};
|
|
PRIMITIVES["assoc"] = function (d) {
|
|
var out = {}; for (var k in d) out[k] = d[k];
|
|
for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1];
|
|
return out;
|
|
};
|
|
PRIMITIVES["range"] = function (a, b, step) {
|
|
var r = []; step = step || 1;
|
|
if (b === undefined) { b = a; a = 0; }
|
|
for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i);
|
|
return r;
|
|
};
|
|
|
|
// =========================================================================
|
|
// Evaluator
|
|
// =========================================================================
|
|
|
|
function sxEval(expr, env) {
|
|
// Literals
|
|
if (typeof expr === "number" || typeof expr === "string" || typeof expr === "boolean") return expr;
|
|
if (isNil(expr)) return NIL;
|
|
|
|
// Symbol lookup
|
|
if (isSym(expr)) {
|
|
var name = expr.name;
|
|
if (name in env) return env[name];
|
|
if (name in PRIMITIVES) return PRIMITIVES[name];
|
|
if (name === "true") return true;
|
|
if (name === "false") return false;
|
|
if (name === "nil") return NIL;
|
|
throw new Error("Undefined symbol: " + name);
|
|
}
|
|
|
|
// Keyword → its name
|
|
if (isKw(expr)) return expr.name;
|
|
|
|
// Dict literal
|
|
if (expr && typeof expr === "object" && !Array.isArray(expr) && !expr._sym && !expr._kw && !expr._raw) {
|
|
var d = {};
|
|
for (var dk in expr) d[dk] = sxEval(expr[dk], env);
|
|
return d;
|
|
}
|
|
|
|
// List
|
|
if (!Array.isArray(expr)) return expr;
|
|
if (expr.length === 0) return [];
|
|
|
|
var head = expr[0];
|
|
|
|
// Non-callable head → data list
|
|
if (!isSym(head) && !isLambda(head) && !Array.isArray(head)) {
|
|
return expr.map(function (x) { return sxEval(x, env); });
|
|
}
|
|
|
|
// Special forms
|
|
if (isSym(head)) {
|
|
var sf = SPECIAL_FORMS[head.name];
|
|
if (sf) return sf(expr, env);
|
|
var ho = HO_FORMS[head.name];
|
|
if (ho) return ho(expr, env);
|
|
}
|
|
|
|
// Function call
|
|
var fn = sxEval(head, env);
|
|
var args = [];
|
|
for (var ai = 1; ai < expr.length; ai++) args.push(sxEval(expr[ai], env));
|
|
|
|
if (typeof fn === "function") return fn.apply(null, args);
|
|
if (isLambda(fn)) return callLambda(fn, args, env);
|
|
if (isComponent(fn)) return callComponent(fn, expr.slice(1), env);
|
|
throw new Error("Not callable: " + fn);
|
|
}
|
|
|
|
function callLambda(fn, args, callerEnv) {
|
|
if (args.length !== fn.params.length) {
|
|
throw new Error((fn.name || "lambda") + " expects " + fn.params.length + " args, got " + args.length);
|
|
}
|
|
var local = merge({}, fn.closure, callerEnv);
|
|
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
|
return sxEval(fn.body, local);
|
|
}
|
|
|
|
function callComponent(comp, rawArgs, env) {
|
|
var kwargs = {}, children = [];
|
|
var i = 0;
|
|
while (i < rawArgs.length) {
|
|
if (isKw(rawArgs[i]) && i + 1 < rawArgs.length) {
|
|
kwargs[rawArgs[i].name] = sxEval(rawArgs[i + 1], env);
|
|
i += 2;
|
|
} else {
|
|
children.push(sxEval(rawArgs[i], env));
|
|
i++;
|
|
}
|
|
}
|
|
var local = merge({}, comp.closure, env);
|
|
for (var pi = 0; pi < comp.params.length; pi++) {
|
|
var p = comp.params[pi];
|
|
local[p] = (p in kwargs) ? kwargs[p] : NIL;
|
|
}
|
|
if (comp.hasChildren) local["children"] = children;
|
|
return sxEval(comp.body, local);
|
|
}
|
|
|
|
// --- Special forms -------------------------------------------------------
|
|
|
|
var SPECIAL_FORMS = {};
|
|
|
|
SPECIAL_FORMS["if"] = function (expr, env) {
|
|
var cond = sxEval(expr[1], env);
|
|
if (isSxTruthy(cond)) return sxEval(expr[2], env);
|
|
return expr.length > 3 ? sxEval(expr[3], env) : NIL;
|
|
};
|
|
|
|
SPECIAL_FORMS["when"] = function (expr, env) {
|
|
if (!isSxTruthy(sxEval(expr[1], env))) return NIL;
|
|
var result = NIL;
|
|
for (var i = 2; i < expr.length; i++) result = sxEval(expr[i], env);
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["cond"] = function (expr, env) {
|
|
var clauses = expr.slice(1);
|
|
if (!clauses.length) return NIL;
|
|
// Scheme-style
|
|
if (Array.isArray(clauses[0]) && clauses[0].length === 2) {
|
|
for (var i = 0; i < clauses.length; i++) {
|
|
var test = clauses[i][0];
|
|
if ((isSym(test) && (test.name === "else" || test.name === ":else")) ||
|
|
(isKw(test) && test.name === "else")) return sxEval(clauses[i][1], env);
|
|
if (isSxTruthy(sxEval(test, env))) return sxEval(clauses[i][1], env);
|
|
}
|
|
} else {
|
|
// Clojure-style
|
|
for (var j = 0; j < clauses.length - 1; j += 2) {
|
|
var t = clauses[j];
|
|
if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
|
|
return sxEval(clauses[j + 1], env);
|
|
if (isSxTruthy(sxEval(t, env))) return sxEval(clauses[j + 1], env);
|
|
}
|
|
}
|
|
return NIL;
|
|
};
|
|
|
|
SPECIAL_FORMS["case"] = function (expr, env) {
|
|
var val = sxEval(expr[1], env);
|
|
for (var i = 2; i < expr.length - 1; i += 2) {
|
|
var t = expr[i];
|
|
if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
|
|
return sxEval(expr[i + 1], env);
|
|
if (val == sxEval(t, env)) return sxEval(expr[i + 1], env);
|
|
}
|
|
return NIL;
|
|
};
|
|
|
|
SPECIAL_FORMS["and"] = function (expr, env) {
|
|
var result = true;
|
|
for (var i = 1; i < expr.length; i++) {
|
|
result = sxEval(expr[i], env);
|
|
if (!isSxTruthy(result)) return result;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["or"] = function (expr, env) {
|
|
var result = false;
|
|
for (var i = 1; i < expr.length; i++) {
|
|
result = sxEval(expr[i], env);
|
|
if (isSxTruthy(result)) return result;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["let"] = SPECIAL_FORMS["let*"] = function (expr, env) {
|
|
var bindings = expr[1], local = merge({}, env);
|
|
if (Array.isArray(bindings)) {
|
|
if (bindings.length && Array.isArray(bindings[0])) {
|
|
// Scheme-style
|
|
for (var i = 0; i < bindings.length; i++) {
|
|
var vname = isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0];
|
|
local[vname] = sxEval(bindings[i][1], local);
|
|
}
|
|
} else {
|
|
// Clojure-style
|
|
for (var j = 0; j < bindings.length; j += 2) {
|
|
var vn = isSym(bindings[j]) ? bindings[j].name : bindings[j];
|
|
local[vn] = sxEval(bindings[j + 1], local);
|
|
}
|
|
}
|
|
}
|
|
var result = NIL;
|
|
for (var k = 2; k < expr.length; k++) result = sxEval(expr[k], local);
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["lambda"] = SPECIAL_FORMS["fn"] = function (expr, env) {
|
|
var paramsExpr = expr[1], paramNames = [];
|
|
for (var i = 0; i < paramsExpr.length; i++) {
|
|
var p = paramsExpr[i];
|
|
paramNames.push(isSym(p) ? p.name : String(p));
|
|
}
|
|
return new Lambda(paramNames, expr[2], merge({}, env));
|
|
};
|
|
|
|
SPECIAL_FORMS["define"] = function (expr, env) {
|
|
var name = expr[1].name;
|
|
var value = sxEval(expr[2], env);
|
|
if (isLambda(value) && !value.name) value.name = name;
|
|
env[name] = value;
|
|
return value;
|
|
};
|
|
|
|
SPECIAL_FORMS["defcomp"] = function (expr, env) {
|
|
var nameSym = expr[1];
|
|
var compName = nameSym.name.replace(/^~/, "");
|
|
var paramsExpr = expr[2];
|
|
var params = [], hasChildren = false, inKey = false;
|
|
for (var i = 0; i < paramsExpr.length; i++) {
|
|
var p = paramsExpr[i];
|
|
if (isSym(p)) {
|
|
if (p.name === "&key") { inKey = true; continue; }
|
|
if (p.name === "&rest") { hasChildren = true; continue; }
|
|
if (inKey || hasChildren) { if (!hasChildren) params.push(p.name); }
|
|
else params.push(p.name);
|
|
}
|
|
}
|
|
var comp = new Component(compName, params, hasChildren, expr[3], merge({}, env));
|
|
env[nameSym.name] = comp;
|
|
return comp;
|
|
};
|
|
|
|
SPECIAL_FORMS["begin"] = SPECIAL_FORMS["do"] = function (expr, env) {
|
|
var result = NIL;
|
|
for (var i = 1; i < expr.length; i++) result = sxEval(expr[i], env);
|
|
return result;
|
|
};
|
|
|
|
SPECIAL_FORMS["quote"] = function (expr) { return expr[1]; };
|
|
|
|
SPECIAL_FORMS["set!"] = function (expr, env) {
|
|
var v = sxEval(expr[2], env);
|
|
env[expr[1].name] = v;
|
|
return v;
|
|
};
|
|
|
|
SPECIAL_FORMS["->"] = function (expr, env) {
|
|
var result = sxEval(expr[1], env);
|
|
for (var i = 2; i < expr.length; i++) {
|
|
var form = expr[i];
|
|
var fn, args;
|
|
if (Array.isArray(form)) {
|
|
fn = sxEval(form[0], env);
|
|
args = [result];
|
|
for (var j = 1; j < form.length; j++) args.push(sxEval(form[j], env));
|
|
} else {
|
|
fn = sxEval(form, env);
|
|
args = [result];
|
|
}
|
|
if (typeof fn === "function") result = fn.apply(null, args);
|
|
else if (isLambda(fn)) result = callLambda(fn, args, env);
|
|
else throw new Error("-> form not callable: " + fn);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// --- Higher-order forms --------------------------------------------------
|
|
|
|
var HO_FORMS = {};
|
|
|
|
HO_FORMS["map"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
return coll.map(function (item) { return isLambda(fn) ? callLambda(fn, [item], env) : fn(item); });
|
|
};
|
|
|
|
HO_FORMS["map-indexed"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
return coll.map(function (item, i) { return isLambda(fn) ? callLambda(fn, [i, item], env) : fn(i, item); });
|
|
};
|
|
|
|
HO_FORMS["filter"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
return coll.filter(function (item) {
|
|
var r = isLambda(fn) ? callLambda(fn, [item], env) : fn(item);
|
|
return isSxTruthy(r);
|
|
});
|
|
};
|
|
|
|
HO_FORMS["reduce"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), acc = sxEval(expr[2], env), coll = sxEval(expr[3], env);
|
|
for (var i = 0; i < coll.length; i++) acc = isLambda(fn) ? callLambda(fn, [acc, coll[i]], env) : fn(acc, coll[i]);
|
|
return acc;
|
|
};
|
|
|
|
HO_FORMS["some"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var r = isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
|
|
if (isSxTruthy(r)) return r;
|
|
}
|
|
return NIL;
|
|
};
|
|
|
|
HO_FORMS["every?"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
for (var i = 0; i < coll.length; i++) {
|
|
if (!isSxTruthy(isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]))) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
HO_FORMS["for-each"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
for (var i = 0; i < coll.length; i++) isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
|
|
return NIL;
|
|
};
|
|
|
|
// =========================================================================
|
|
// DOM Renderer
|
|
// =========================================================================
|
|
|
|
var HTML_TAGS = makeSet(
|
|
"html head body title meta link style script base noscript " +
|
|
"header footer main nav aside section article address hgroup " +
|
|
"h1 h2 h3 h4 h5 h6 " +
|
|
"div p blockquote pre figure figcaption ul ol li dl dt dd hr " +
|
|
"a span em strong small s cite q abbr code var samp kbd sub sup " +
|
|
"i b u mark ruby rt rp bdi bdo br wbr time data " +
|
|
"ins del " +
|
|
"img picture source iframe embed object param video audio track canvas map area " +
|
|
"svg math path circle ellipse line polygon polyline rect g defs use text tspan " +
|
|
"clipPath mask linearGradient radialGradient stop filter " +
|
|
"feGaussianBlur feOffset feMerge feMergeNode animate animateTransform " +
|
|
"table thead tbody tfoot tr th td caption colgroup col " +
|
|
"form fieldset legend label input button select option optgroup textarea output " +
|
|
"datalist progress meter details summary dialog template slot"
|
|
);
|
|
|
|
var VOID_ELEMENTS = makeSet(
|
|
"area base br col embed hr img input link meta param source track wbr"
|
|
);
|
|
|
|
var BOOLEAN_ATTRS = makeSet(
|
|
"async autofocus autoplay checked controls default defer disabled " +
|
|
"formnovalidate hidden inert ismap loop multiple muted nomodule " +
|
|
"novalidate open playsinline readonly required reversed selected"
|
|
);
|
|
|
|
// SVG elements that need createElementNS
|
|
var SVG_TAGS = makeSet(
|
|
"svg path circle ellipse line polygon polyline rect g defs use text tspan " +
|
|
"clipPath mask linearGradient radialGradient stop filter " +
|
|
"feGaussianBlur feOffset feMerge feMergeNode animate animateTransform"
|
|
);
|
|
|
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
/**
|
|
* Render an s-expression to DOM node(s).
|
|
* Returns a DocumentFragment, Element, or Text node.
|
|
*/
|
|
function renderDOM(expr, env) {
|
|
// nil / false → empty
|
|
if (isNil(expr) || expr === false || expr === true) return document.createDocumentFragment();
|
|
|
|
// Pre-rendered HTML
|
|
if (isRaw(expr)) {
|
|
var tpl = document.createElement("template");
|
|
tpl.innerHTML = expr.html;
|
|
return tpl.content;
|
|
}
|
|
|
|
// String → text node
|
|
if (typeof expr === "string") return document.createTextNode(expr);
|
|
|
|
// Number → text node
|
|
if (typeof expr === "number") return document.createTextNode(String(expr));
|
|
|
|
// Symbol → evaluate then render
|
|
if (isSym(expr)) return renderDOM(sxEval(expr, env), env);
|
|
|
|
// Keyword → text
|
|
if (isKw(expr)) return document.createTextNode(expr.name);
|
|
|
|
// Dict → empty
|
|
if (expr && typeof expr === "object" && !Array.isArray(expr)) return document.createDocumentFragment();
|
|
|
|
// List → dispatch
|
|
if (Array.isArray(expr)) {
|
|
if (!expr.length) return document.createDocumentFragment();
|
|
return renderList(expr, env);
|
|
}
|
|
|
|
return document.createTextNode(String(expr));
|
|
}
|
|
|
|
/** Render-aware special forms for DOM output. */
|
|
var RENDER_FORMS = {};
|
|
|
|
RENDER_FORMS["if"] = function (expr, env) {
|
|
var cond = sxEval(expr[1], env);
|
|
if (isSxTruthy(cond)) return renderDOM(expr[2], env);
|
|
return expr.length > 3 ? renderDOM(expr[3], env) : document.createDocumentFragment();
|
|
};
|
|
|
|
RENDER_FORMS["when"] = function (expr, env) {
|
|
if (!isSxTruthy(sxEval(expr[1], env))) return document.createDocumentFragment();
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 2; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["cond"] = function (expr, env) {
|
|
var clauses = expr.slice(1);
|
|
if (!clauses.length) return document.createDocumentFragment();
|
|
if (Array.isArray(clauses[0]) && clauses[0].length === 2) {
|
|
for (var i = 0; i < clauses.length; i++) {
|
|
var test = clauses[i][0];
|
|
if ((isSym(test) && (test.name === "else" || test.name === ":else")) ||
|
|
(isKw(test) && test.name === "else")) return renderDOM(clauses[i][1], env);
|
|
if (isSxTruthy(sxEval(test, env))) return renderDOM(clauses[i][1], env);
|
|
}
|
|
} else {
|
|
for (var j = 0; j < clauses.length - 1; j += 2) {
|
|
var t = clauses[j];
|
|
if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
|
|
return renderDOM(clauses[j + 1], env);
|
|
if (isSxTruthy(sxEval(t, env))) return renderDOM(clauses[j + 1], env);
|
|
}
|
|
}
|
|
return document.createDocumentFragment();
|
|
};
|
|
|
|
RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) {
|
|
var bindings = expr[1], local = merge({}, env);
|
|
if (Array.isArray(bindings)) {
|
|
if (bindings.length && Array.isArray(bindings[0])) {
|
|
for (var i = 0; i < bindings.length; i++) {
|
|
local[isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]] = sxEval(bindings[i][1], local);
|
|
}
|
|
} else {
|
|
for (var j = 0; j < bindings.length; j += 2) {
|
|
local[isSym(bindings[j]) ? bindings[j].name : bindings[j]] = sxEval(bindings[j + 1], local);
|
|
}
|
|
}
|
|
}
|
|
var frag = document.createDocumentFragment();
|
|
for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["begin"] = RENDER_FORMS["do"] = function (expr, env) {
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 1; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
|
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
|
|
|
RENDER_FORMS["map"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
|
|
frag.appendChild(val);
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["map-indexed"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var val = isLambda(fn) ? renderLambdaDOM(fn, [i, coll[i]], env) : renderDOM(fn(i, coll[i]), env);
|
|
frag.appendChild(val);
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
RENDER_FORMS["filter"] = function (expr, env) {
|
|
var result = sxEval(expr, env);
|
|
return renderDOM(result, env);
|
|
};
|
|
|
|
RENDER_FORMS["for-each"] = function (expr, env) {
|
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
|
var frag = document.createDocumentFragment();
|
|
for (var i = 0; i < coll.length; i++) {
|
|
var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
|
|
frag.appendChild(val);
|
|
}
|
|
return frag;
|
|
};
|
|
|
|
function renderLambdaDOM(fn, args, env) {
|
|
var local = merge({}, fn.closure, env);
|
|
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
|
return renderDOM(fn.body, local);
|
|
}
|
|
|
|
function renderComponentDOM(comp, args, env) {
|
|
var kwargs = {}, children = [];
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
if (isKw(args[i]) && i + 1 < args.length) {
|
|
// Evaluate kwarg values eagerly so expressions like (get t "src")
|
|
// resolve in the caller's env (where lambda params are bound).
|
|
var v = args[i + 1];
|
|
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
|
|
typeof v === "boolean" || isNil(v))
|
|
? v : sxEval(v, env);
|
|
i += 2;
|
|
} else {
|
|
children.push(args[i]);
|
|
i++;
|
|
}
|
|
}
|
|
var local = merge({}, comp.closure, env);
|
|
for (var pi = 0; pi < comp.params.length; pi++) {
|
|
var p = comp.params[pi];
|
|
local[p] = (p in kwargs) ? kwargs[p] : NIL;
|
|
}
|
|
if (comp.hasChildren) {
|
|
// Pre-render children to a fragment, wrap as RawHTML for raw! compatibility
|
|
var childFrag = document.createDocumentFragment();
|
|
for (var ci = 0; ci < children.length; ci++) childFrag.appendChild(renderDOM(children[ci], env));
|
|
local["children"] = childFrag;
|
|
}
|
|
return renderDOM(comp.body, local);
|
|
}
|
|
|
|
function renderList(expr, env) {
|
|
var head = expr[0];
|
|
|
|
if (isSym(head)) {
|
|
var name = head.name;
|
|
|
|
// raw! → insert unescaped
|
|
if (name === "raw!") {
|
|
var frag = document.createDocumentFragment();
|
|
for (var ri = 1; ri < expr.length; ri++) {
|
|
var val = sxEval(expr[ri], env);
|
|
if (typeof val === "string") {
|
|
var tpl = document.createElement("template");
|
|
tpl.innerHTML = val;
|
|
frag.appendChild(tpl.content);
|
|
} else if (val && val.nodeType) {
|
|
// Already a DOM node (e.g. from children fragment)
|
|
frag.appendChild(val.cloneNode ? val.cloneNode(true) : val);
|
|
} else if (!isNil(val)) {
|
|
frag.appendChild(document.createTextNode(String(val)));
|
|
}
|
|
}
|
|
return frag;
|
|
}
|
|
|
|
// <> → fragment
|
|
if (name === "<>") {
|
|
var f = document.createDocumentFragment();
|
|
for (var fi = 1; fi < expr.length; fi++) f.appendChild(renderDOM(expr[fi], env));
|
|
return f;
|
|
}
|
|
|
|
// Render-aware special forms
|
|
if (RENDER_FORMS[name]) return RENDER_FORMS[name](expr, env);
|
|
|
|
// HTML tag
|
|
if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env);
|
|
|
|
// Component
|
|
if (name.charAt(0) === "~") {
|
|
var comp = env[name];
|
|
if (isComponent(comp)) return renderComponentDOM(comp, expr.slice(1), env);
|
|
// Unknown component — render a visible warning, don't crash
|
|
console.warn("sx.js: unknown component " + name);
|
|
var warn = document.createElement("div");
|
|
warn.setAttribute("style",
|
|
"background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;" +
|
|
"padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace");
|
|
warn.textContent = "Unknown component: " + name;
|
|
return warn;
|
|
}
|
|
|
|
// Fallback: evaluate then render
|
|
return renderDOM(sxEval(expr, env), env);
|
|
}
|
|
|
|
// Lambda/list head → evaluate
|
|
if (isLambda(head) || Array.isArray(head)) return renderDOM(sxEval(expr, env), env);
|
|
|
|
// Data list
|
|
var dl = document.createDocumentFragment();
|
|
for (var di = 0; di < expr.length; di++) dl.appendChild(renderDOM(expr[di], env));
|
|
return dl;
|
|
}
|
|
|
|
function renderElement(tag, args, env) {
|
|
var el = SVG_TAGS[tag]
|
|
? document.createElementNS(SVG_NS, tag)
|
|
: document.createElement(tag);
|
|
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
var arg = args[i];
|
|
if (isKw(arg) && i + 1 < args.length) {
|
|
var attrName = arg.name;
|
|
var attrVal = sxEval(args[i + 1], env);
|
|
i += 2;
|
|
if (isNil(attrVal) || attrVal === false) continue;
|
|
if (BOOLEAN_ATTRS[attrName]) {
|
|
if (attrVal) el.setAttribute(attrName, "");
|
|
} else if (attrVal === true) {
|
|
el.setAttribute(attrName, "");
|
|
} else {
|
|
el.setAttribute(attrName, String(attrVal));
|
|
}
|
|
} else {
|
|
// Child
|
|
if (!(tag in VOID_ELEMENTS)) {
|
|
el.appendChild(renderDOM(arg, env));
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
|
|
return el;
|
|
}
|
|
|
|
// =========================================================================
|
|
// String Renderer (for SSR parity / testing)
|
|
// =========================================================================
|
|
|
|
function escapeText(s) { return s.replace(/&/g, "&").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") { sxEval(expr, env); return ""; }
|
|
|
|
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
|
|
if (name === "map") {
|
|
var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env);
|
|
if (!Array.isArray(mapColl)) return "";
|
|
var mapParts = [];
|
|
for (var mi = 0; mi < mapColl.length; mi++) {
|
|
if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env));
|
|
else mapParts.push(renderStr(mapFn(mapColl[mi]), env));
|
|
}
|
|
return mapParts.join("");
|
|
}
|
|
if (name === "map-indexed") {
|
|
var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env);
|
|
if (!Array.isArray(mixColl)) return "";
|
|
var mixParts = [];
|
|
for (var mxi = 0; mxi < mixColl.length; mxi++) {
|
|
if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env));
|
|
else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env));
|
|
}
|
|
return mixParts.join("");
|
|
}
|
|
if (name === "filter") {
|
|
var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env);
|
|
if (!Array.isArray(filtColl)) return "";
|
|
var filtParts = [];
|
|
for (var fli = 0; fli < filtColl.length; fli++) {
|
|
var keep = isLambda(filtFn) ? callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]);
|
|
if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env));
|
|
}
|
|
return filtParts.join("");
|
|
}
|
|
|
|
if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env);
|
|
|
|
if (name.charAt(0) === "~") {
|
|
var comp = env[name];
|
|
if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env);
|
|
// Unknown component — return visible warning
|
|
console.warn("sx.js: unknown component " + name);
|
|
return '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
|
|
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
|
|
'Unknown component: ' + escapeText(name) + '</div>';
|
|
}
|
|
|
|
return renderStr(sxEval(expr, env), env);
|
|
}
|
|
|
|
function renderStrElement(tag, args, env) {
|
|
var attrs = [], children = [];
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
if (isKw(args[i]) && i + 1 < args.length) {
|
|
var aname = args[i].name, aval = sxEval(args[i + 1], env);
|
|
i += 2;
|
|
if (isNil(aval) || aval === false) continue;
|
|
if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); }
|
|
else if (aval === true) attrs.push(" " + aname);
|
|
else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"');
|
|
} else {
|
|
children.push(args[i]);
|
|
i++;
|
|
}
|
|
}
|
|
var open = "<" + tag + attrs.join("") + ">";
|
|
if (VOID_ELEMENTS[tag]) return open;
|
|
var inner = [];
|
|
for (var ci = 0; ci < children.length; ci++) inner.push(renderStr(children[ci], env));
|
|
return open + inner.join("") + "</" + tag + ">";
|
|
}
|
|
|
|
function renderLambdaStr(fn, args, env) {
|
|
var local = merge({}, fn.closure, env);
|
|
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
|
return renderStr(fn.body, local);
|
|
}
|
|
|
|
function renderStrComponent(comp, args, env) {
|
|
var kwargs = {}, children = [];
|
|
var i = 0;
|
|
while (i < args.length) {
|
|
if (isKw(args[i]) && i + 1 < args.length) {
|
|
var v = args[i + 1];
|
|
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
|
|
typeof v === "boolean" || isNil(v) || isKw(v))
|
|
? v : (isSym(v) ? sxEval(v, env) : v);
|
|
i += 2;
|
|
} else { children.push(args[i]); i++; }
|
|
}
|
|
var local = merge({}, comp.closure, env);
|
|
for (var pi = 0; pi < comp.params.length; pi++) {
|
|
var p = comp.params[pi];
|
|
local[p] = (p in kwargs) ? kwargs[p] : NIL;
|
|
}
|
|
if (comp.hasChildren) {
|
|
var cs = [];
|
|
for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env));
|
|
local["children"] = new RawHTML(cs.join(""));
|
|
}
|
|
return renderStr(comp.body, local);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Helpers
|
|
// =========================================================================
|
|
|
|
function merge(target) {
|
|
for (var i = 1; i < arguments.length; i++) {
|
|
var src = arguments[i];
|
|
if (src) for (var k in src) target[k] = src[k];
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function makeSet(str) {
|
|
var s = {}, parts = str.split(/\s+/);
|
|
for (var i = 0; i < parts.length; i++) if (parts[i]) s[parts[i]] = true;
|
|
return s;
|
|
}
|
|
|
|
/** Convert snake_case kwargs to kebab-case for sx conventions. */
|
|
function toKebab(s) { return s.replace(/_/g, "-"); }
|
|
|
|
// =========================================================================
|
|
// Public API
|
|
// =========================================================================
|
|
|
|
var _componentEnv = {};
|
|
|
|
// =========================================================================
|
|
// Head auto-hoist: move meta/title/link/script[ld+json] from body to <head>
|
|
// =========================================================================
|
|
|
|
var HEAD_HOIST_SELECTOR =
|
|
"meta, title, link[rel='canonical'], script[type='application/ld+json']";
|
|
|
|
function _hoistHeadElements(root) {
|
|
var els = root.querySelectorAll(HEAD_HOIST_SELECTOR);
|
|
if (!els.length) return;
|
|
var head = document.head;
|
|
for (var i = 0; i < els.length; i++) {
|
|
var el = els[i];
|
|
var tag = el.tagName.toLowerCase();
|
|
// For <title>, replace existing
|
|
if (tag === "title") {
|
|
document.title = el.textContent || "";
|
|
el.parentNode.removeChild(el);
|
|
continue;
|
|
}
|
|
// For <meta>, remove existing with same name/property to avoid duplicates
|
|
if (tag === "meta") {
|
|
var name = el.getAttribute("name");
|
|
var prop = el.getAttribute("property");
|
|
if (name) {
|
|
var old = head.querySelector('meta[name="' + name + '"]');
|
|
if (old) old.parentNode.removeChild(old);
|
|
}
|
|
if (prop) {
|
|
var old2 = head.querySelector('meta[property="' + prop + '"]');
|
|
if (old2) old2.parentNode.removeChild(old2);
|
|
}
|
|
}
|
|
// For <link rel=canonical>, remove existing
|
|
if (tag === "link" && el.getAttribute("rel") === "canonical") {
|
|
var oldLink = head.querySelector('link[rel="canonical"]');
|
|
if (oldLink) oldLink.parentNode.removeChild(oldLink);
|
|
}
|
|
// Move from body to head
|
|
el.parentNode.removeChild(el);
|
|
head.appendChild(el);
|
|
}
|
|
}
|
|
|
|
var Sx = {
|
|
// Types
|
|
NIL: NIL,
|
|
Symbol: Symbol,
|
|
Keyword: Keyword,
|
|
|
|
// Parser
|
|
parse: parse,
|
|
parseAll: parseAll,
|
|
|
|
// Evaluator
|
|
eval: function (expr, env) { return sxEval(expr, env || _componentEnv); },
|
|
|
|
// DOM Renderer
|
|
render: function (exprOrText, extraEnv) {
|
|
var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText;
|
|
var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
|
|
return renderDOM(expr, env);
|
|
},
|
|
|
|
// String Renderer (matches Python html.render output)
|
|
renderToString: function (exprOrText, extraEnv) {
|
|
var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText;
|
|
var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
|
|
return renderStr(expr, env);
|
|
},
|
|
|
|
/**
|
|
* Render a named component with keyword args (Python-style API).
|
|
* Sx.renderComponent("card", {title: "Hi"})
|
|
*/
|
|
renderComponent: function (name, kwargs, extraEnv) {
|
|
var fullName = name.charAt(0) === "~" ? name : "~" + name;
|
|
var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv;
|
|
var comp = env[fullName];
|
|
if (!isComponent(comp)) throw new Error("Unknown component: " + fullName);
|
|
// Build a synthetic call expression
|
|
var callExpr = [new Symbol(fullName)];
|
|
if (kwargs) {
|
|
for (var k in kwargs) {
|
|
callExpr.push(new Keyword(toKebab(k)));
|
|
callExpr.push(kwargs[k]);
|
|
}
|
|
}
|
|
return renderDOM(callExpr, env);
|
|
},
|
|
|
|
// Component management
|
|
loadComponents: function (text) {
|
|
var exprs = parseAll(text);
|
|
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
|
|
},
|
|
|
|
getEnv: function () { return _componentEnv; },
|
|
|
|
// Utility
|
|
isTruthy: isSxTruthy,
|
|
isNil: isNil,
|
|
|
|
/**
|
|
* Mount a sx expression into a DOM element, replacing its contents.
|
|
* Sx.mount(el, '(~card :title "Hi")')
|
|
* Sx.mount("#target", '(~card :title "Hi")')
|
|
* Sx.mount(el, '(~card :title name)', {name: "Jo"})
|
|
*/
|
|
mount: function (target, exprOrText, extraEnv) {
|
|
var el = typeof target === "string" ? document.querySelector(target) : target;
|
|
if (!el) return;
|
|
var node = Sx.render(exprOrText, extraEnv);
|
|
el.textContent = "";
|
|
el.appendChild(node);
|
|
// Auto-hoist head elements (meta, title, link, script[ld+json]) to <head>
|
|
_hoistHeadElements(el);
|
|
// Process sx- attributes and hydrate the newly mounted content
|
|
if (typeof SxEngine !== "undefined") SxEngine.process(el);
|
|
Sx.hydrate(el);
|
|
},
|
|
|
|
/**
|
|
* Process all <script type="text/sx"> tags in the document.
|
|
* Tags with data-components load component definitions.
|
|
* Tags with data-mount="<selector>" render into that element.
|
|
*/
|
|
processScripts: function (root) {
|
|
var scripts = (root || document).querySelectorAll('script[type="text/sx"]');
|
|
for (var i = 0; i < scripts.length; i++) {
|
|
var s = scripts[i];
|
|
if (s._sxProcessed) continue;
|
|
s._sxProcessed = true;
|
|
|
|
var text = s.textContent;
|
|
if (!text || !text.trim()) continue;
|
|
|
|
// data-components: load as component definitions
|
|
if (s.hasAttribute("data-components")) {
|
|
Sx.loadComponents(text);
|
|
continue;
|
|
}
|
|
|
|
// data-mount="<selector>": render into target
|
|
var mountSel = s.getAttribute("data-mount");
|
|
if (mountSel) {
|
|
var target = document.querySelector(mountSel);
|
|
if (target) Sx.mount(target, text);
|
|
continue;
|
|
}
|
|
|
|
// Default: load as components
|
|
Sx.loadComponents(text);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Bind client-side sx rendering to elements with data-sx-* attrs.
|
|
*
|
|
* Pattern:
|
|
* <div data-sx="(~card :title title)" data-sx-env='{"title":"Hi"}'>
|
|
* <!-- server-rendered HTML (hydration target) -->
|
|
* </div>
|
|
*
|
|
* Call Sx.update(el, {title: "New"}) to re-render with new data.
|
|
*/
|
|
update: function (target, newEnv) {
|
|
var el = typeof target === "string" ? document.querySelector(target) : target;
|
|
if (!el) return;
|
|
var source = el.getAttribute("data-sx");
|
|
if (!source) return;
|
|
var baseEnv = {};
|
|
var envAttr = el.getAttribute("data-sx-env");
|
|
if (envAttr) {
|
|
try { baseEnv = JSON.parse(envAttr); } catch (e) { /* ignore */ }
|
|
}
|
|
var env = merge({}, _componentEnv, baseEnv, newEnv || {});
|
|
var node = renderDOM(parse(source), env);
|
|
el.textContent = "";
|
|
el.appendChild(node);
|
|
if (newEnv) {
|
|
merge(baseEnv, newEnv);
|
|
el.setAttribute("data-sx-env", JSON.stringify(baseEnv));
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Find all [data-sx] elements within root and render them.
|
|
* Useful after HTMX swaps bring in new sx-enabled elements.
|
|
*/
|
|
hydrate: function (root) {
|
|
var els = (root || document).querySelectorAll("[data-sx]");
|
|
for (var i = 0; i < els.length; i++) {
|
|
if (els[i]._sxHydrated) continue;
|
|
els[i]._sxHydrated = true;
|
|
Sx.update(els[i]);
|
|
}
|
|
},
|
|
|
|
// For testing
|
|
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
|
|
_eval: sxEval,
|
|
_renderStr: renderStr,
|
|
_renderDOM: renderDOM,
|
|
};
|
|
|
|
global.Sx = Sx;
|
|
|
|
// =========================================================================
|
|
// SxEngine — native fetch/swap/history engine (replaces HTMX)
|
|
// =========================================================================
|
|
|
|
var SxEngine = (function () {
|
|
if (typeof document === "undefined") return {};
|
|
|
|
// ---- helpers ----------------------------------------------------------
|
|
var PROCESSED = "_sxBound";
|
|
var VERBS = ["get", "post", "put", "delete", "patch"];
|
|
var DEFAULT_SWAP = "outerHTML";
|
|
var HISTORY_MAX = 20;
|
|
|
|
function dispatch(el, name, detail) {
|
|
var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} });
|
|
return el.dispatchEvent(evt);
|
|
}
|
|
|
|
function csrfToken() {
|
|
var m = document.querySelector('meta[name="csrf-token"]');
|
|
return m ? m.getAttribute("content") : null;
|
|
}
|
|
|
|
function sameOrigin(url) {
|
|
try { return new URL(url, location.href).origin === location.origin; } catch (e) { return true; }
|
|
}
|
|
|
|
function resolveTarget(el, attr) {
|
|
var sel = el.getAttribute("sx-target") || attr;
|
|
if (!sel || sel === "this") return el;
|
|
if (sel === "closest") return el.parentElement;
|
|
return document.querySelector(sel);
|
|
}
|
|
|
|
function getVerb(el) {
|
|
for (var i = 0; i < VERBS.length; i++) {
|
|
var v = VERBS[i];
|
|
if (el.hasAttribute("sx-" + v)) return { method: v.toUpperCase(), url: el.getAttribute("sx-" + v) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---- Sync manager -----------------------------------------------------
|
|
var _controllers = new WeakMap();
|
|
|
|
function abortPrevious(el) {
|
|
var prev = _controllers.get(el);
|
|
if (prev) prev.abort();
|
|
}
|
|
|
|
function trackController(el, ctrl) {
|
|
_controllers.set(el, ctrl);
|
|
}
|
|
|
|
// ---- Request executor -------------------------------------------------
|
|
|
|
function executeRequest(el, verbInfo, extraParams) {
|
|
var method = verbInfo.method;
|
|
var url = verbInfo.url;
|
|
|
|
// sx-media: skip if media query doesn't match
|
|
var media = el.getAttribute("sx-media");
|
|
if (media && !window.matchMedia(media).matches) return Promise.resolve();
|
|
|
|
// sx-confirm: show dialog first
|
|
var confirmMsg = el.getAttribute("sx-confirm");
|
|
if (confirmMsg) {
|
|
if (typeof Swal !== "undefined") {
|
|
return Swal.fire({
|
|
title: confirmMsg,
|
|
icon: "warning",
|
|
showCancelButton: true,
|
|
confirmButtonText: "Yes",
|
|
cancelButtonText: "Cancel"
|
|
}).then(function (result) {
|
|
if (!result.isConfirmed) return;
|
|
return _doFetch(el, method, url, extraParams);
|
|
});
|
|
}
|
|
if (!window.confirm(confirmMsg)) return Promise.resolve();
|
|
}
|
|
|
|
return _doFetch(el, method, url, extraParams);
|
|
}
|
|
|
|
function _doFetch(el, method, url, extraParams) {
|
|
// sx-sync: abort previous
|
|
var sync = el.getAttribute("sx-sync");
|
|
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
|
|
|
|
var ctrl = new AbortController();
|
|
trackController(el, ctrl);
|
|
|
|
// Build headers
|
|
var headers = {
|
|
"SX-Request": "true",
|
|
"SX-Current-URL": location.href
|
|
};
|
|
var targetSel = el.getAttribute("sx-target");
|
|
if (targetSel) headers["SX-Target"] = targetSel;
|
|
|
|
// Send loaded component names so cross-domain responses can prepend missing defs
|
|
var loadedNames = Object.keys(_componentEnv).filter(function (k) {
|
|
return k.charAt(0) === "~";
|
|
});
|
|
if (loadedNames.length) headers["SX-Components"] = loadedNames.join(",");
|
|
|
|
// Extra headers from sx-headers
|
|
var extraH = el.getAttribute("sx-headers");
|
|
if (extraH) {
|
|
try {
|
|
var parsed = JSON.parse(extraH);
|
|
for (var k in parsed) headers[k] = parsed[k];
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// CSRF for same-origin mutating requests
|
|
if (method !== "GET" && sameOrigin(url)) {
|
|
var csrf = csrfToken();
|
|
if (csrf) headers["X-CSRFToken"] = csrf;
|
|
}
|
|
|
|
// Build body
|
|
var body = null;
|
|
var isJson = el.getAttribute("sx-encoding") === "json";
|
|
|
|
if (method !== "GET") {
|
|
var form = el.closest("form") || (el.tagName === "FORM" ? el : null);
|
|
if (form) {
|
|
if (isJson) {
|
|
var fd = new FormData(form);
|
|
var obj = {};
|
|
fd.forEach(function (v, k) {
|
|
if (obj[k] !== undefined) {
|
|
if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
|
|
obj[k].push(v);
|
|
} else {
|
|
obj[k] = v;
|
|
}
|
|
});
|
|
body = JSON.stringify(obj);
|
|
headers["Content-Type"] = "application/json";
|
|
} else {
|
|
body = new URLSearchParams(new FormData(form));
|
|
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Include extra inputs
|
|
var includeSel = el.getAttribute("sx-include");
|
|
if (includeSel && method !== "GET") {
|
|
var extras = document.querySelectorAll(includeSel);
|
|
if (!body) body = new URLSearchParams();
|
|
extras.forEach(function (inp) {
|
|
if (inp.name) body.append(inp.name, inp.value);
|
|
});
|
|
}
|
|
|
|
// sx-vals: merge extra key-value pairs
|
|
var valsAttr = el.getAttribute("sx-vals");
|
|
if (valsAttr) {
|
|
try {
|
|
var vals = JSON.parse(valsAttr);
|
|
if (method === "GET") {
|
|
for (var vk in vals) {
|
|
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);
|
|
}
|
|
} else if (body instanceof URLSearchParams) {
|
|
for (var vk2 in vals) body.append(vk2, vals[vk2]);
|
|
} else if (!body) {
|
|
body = new URLSearchParams();
|
|
for (var vk3 in vals) body.append(vk3, vals[vk3]);
|
|
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// For GET with form data, append to URL
|
|
if (method === "GET") {
|
|
var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null);
|
|
if (form2) {
|
|
var qs = new URLSearchParams(new FormData(form2)).toString();
|
|
if (qs) url += (url.indexOf("?") >= 0 ? "&" : "?") + qs;
|
|
}
|
|
// Also handle search inputs with name attr
|
|
if (el.tagName === "INPUT" && el.name) {
|
|
var param = encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value);
|
|
url += (url.indexOf("?") >= 0 ? "&" : "?") + param;
|
|
}
|
|
}
|
|
|
|
// Lifecycle: beforeRequest
|
|
if (!dispatch(el, "sx:beforeRequest", { method: method, url: url })) return Promise.resolve();
|
|
|
|
// Loading state
|
|
el.classList.add("sx-request");
|
|
el.setAttribute("aria-busy", "true");
|
|
|
|
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
|
|
// Cross-origin credentials for known subdomains
|
|
try {
|
|
var urlHost = new URL(url, location.href).hostname;
|
|
if (urlHost !== location.hostname &&
|
|
(urlHost.endsWith(".rose-ash.com") || urlHost.endsWith(".localhost"))) {
|
|
fetchOpts.credentials = "include";
|
|
}
|
|
} catch (e) {}
|
|
if (body && method !== "GET") fetchOpts.body = body;
|
|
|
|
return fetch(url, fetchOpts).then(function (resp) {
|
|
el.classList.remove("sx-request");
|
|
el.removeAttribute("aria-busy");
|
|
|
|
if (!resp.ok) {
|
|
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
|
|
return _handleRetry(el, verbInfo, extraParams);
|
|
}
|
|
|
|
return resp.text().then(function (text) {
|
|
dispatch(el, "sx:afterRequest", { response: resp });
|
|
|
|
// Check for text/sx content type
|
|
var ct = resp.headers.get("Content-Type") || "";
|
|
if (ct.indexOf("text/sx") >= 0) {
|
|
try { text = Sx.renderToString(text); }
|
|
catch (err) {
|
|
console.error("sx.js render error:", err);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Process the response
|
|
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
|
|
var target = resolveTarget(el, null);
|
|
|
|
// sx-select: extract subset from response
|
|
var selectSel = el.getAttribute("sx-select");
|
|
|
|
// Parse response into DOM for OOB + select processing
|
|
var parser = new DOMParser();
|
|
var doc = parser.parseFromString(text, "text/html");
|
|
|
|
// Process any sx script blocks in the response (e.g. cross-domain component defs)
|
|
Sx.processScripts(doc);
|
|
|
|
// OOB processing: extract elements with sx-swap-oob
|
|
var oobs = doc.querySelectorAll("[sx-swap-oob]");
|
|
oobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("sx-swap-oob");
|
|
if (oobTarget) {
|
|
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
|
}
|
|
oob.parentNode.removeChild(oob);
|
|
});
|
|
|
|
// Also support hx-swap-oob during migration
|
|
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
|
|
hxOobs.forEach(function (oob) {
|
|
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
|
|
var oobTarget = document.getElementById(oob.id);
|
|
oob.removeAttribute("hx-swap-oob");
|
|
if (oobTarget) {
|
|
_swapContent(oobTarget, oob.outerHTML, oobSwap);
|
|
}
|
|
oob.parentNode.removeChild(oob);
|
|
});
|
|
|
|
// Build final content
|
|
var content;
|
|
if (selectSel) {
|
|
// sx-select may be comma-separated
|
|
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
|
|
var frags = [];
|
|
parts.forEach(function (sel) {
|
|
var matches = doc.querySelectorAll(sel);
|
|
matches.forEach(function (m) { frags.push(m.outerHTML); });
|
|
});
|
|
content = frags.join("");
|
|
} else {
|
|
content = doc.body ? doc.body.innerHTML : text;
|
|
}
|
|
|
|
// Main swap
|
|
if (swapStyle !== "none" && target) {
|
|
_swapContent(target, content, swapStyle);
|
|
// Auto-hoist any head elements that ended up in body
|
|
_hoistHeadElements(target);
|
|
}
|
|
|
|
// History
|
|
var pushUrl = el.getAttribute("sx-push-url");
|
|
if (pushUrl === "true") {
|
|
history.pushState({ sxUrl: url }, "", url);
|
|
} else if (pushUrl && pushUrl !== "false") {
|
|
history.pushState({ sxUrl: pushUrl }, "", pushUrl);
|
|
}
|
|
|
|
dispatch(el, "sx:afterSwap", { target: target });
|
|
// Settle tick
|
|
requestAnimationFrame(function () {
|
|
dispatch(el, "sx:afterSettle", { target: target });
|
|
});
|
|
});
|
|
}).catch(function (err) {
|
|
el.classList.remove("sx-request");
|
|
el.removeAttribute("aria-busy");
|
|
if (err.name === "AbortError") return;
|
|
dispatch(el, "sx:sendError", { error: err });
|
|
return _handleRetry(el, verbInfo, extraParams);
|
|
});
|
|
}
|
|
|
|
// ---- Swap engine ------------------------------------------------------
|
|
|
|
function _swapContent(target, html, strategy) {
|
|
switch (strategy) {
|
|
case "innerHTML":
|
|
target.innerHTML = html;
|
|
break;
|
|
case "outerHTML":
|
|
var tgt = target;
|
|
var parent = tgt.parentNode;
|
|
tgt.insertAdjacentHTML("afterend", html);
|
|
parent.removeChild(tgt);
|
|
// Process parent to catch all newly inserted siblings
|
|
Sx.processScripts(parent);
|
|
Sx.hydrate(parent);
|
|
SxEngine.process(parent);
|
|
return; // early return — afterSwap handling done inline
|
|
case "afterend":
|
|
target.insertAdjacentHTML("afterend", html);
|
|
break;
|
|
case "beforeend":
|
|
target.insertAdjacentHTML("beforeend", html);
|
|
break;
|
|
case "afterbegin":
|
|
target.insertAdjacentHTML("afterbegin", html);
|
|
break;
|
|
case "beforebegin":
|
|
target.insertAdjacentHTML("beforebegin", html);
|
|
break;
|
|
case "delete":
|
|
target.parentNode.removeChild(target);
|
|
return;
|
|
default:
|
|
target.innerHTML = html;
|
|
}
|
|
Sx.processScripts(target);
|
|
Sx.hydrate(target);
|
|
SxEngine.process(target);
|
|
}
|
|
|
|
// ---- Retry system -----------------------------------------------------
|
|
|
|
function _handleRetry(el, verbInfo, extraParams) {
|
|
var retry = el.getAttribute("sx-retry");
|
|
if (!retry) return;
|
|
|
|
var parts = retry.split(":");
|
|
var strategy = parts[0]; // "exponential"
|
|
var startMs = parseInt(parts[1], 10) || 1000;
|
|
var capMs = parseInt(parts[2], 10) || 30000;
|
|
|
|
var currentMs = parseInt(el.getAttribute("data-sx-retry-ms"), 10) || startMs;
|
|
|
|
el.classList.add("sx-error");
|
|
el.classList.remove("sx-loading");
|
|
|
|
setTimeout(function () {
|
|
el.classList.remove("sx-error");
|
|
el.classList.add("sx-loading");
|
|
el.setAttribute("data-sx-retry-ms", Math.min(currentMs * 2, capMs));
|
|
executeRequest(el, verbInfo, extraParams);
|
|
}, currentMs);
|
|
}
|
|
|
|
// ---- Trigger system ---------------------------------------------------
|
|
|
|
function parseTrigger(spec) {
|
|
if (!spec) return null;
|
|
var triggers = [];
|
|
var parts = spec.split(",");
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var p = parts[i].trim();
|
|
if (!p) continue;
|
|
var tokens = p.split(/\s+/);
|
|
var trigger = { event: tokens[0], modifiers: {} };
|
|
for (var j = 1; j < tokens.length; j++) {
|
|
var tok = tokens[j];
|
|
if (tok === "once") trigger.modifiers.once = true;
|
|
else if (tok === "changed") trigger.modifiers.changed = true;
|
|
else if (tok.indexOf("delay:") === 0) trigger.modifiers.delay = parseInt(tok.substring(6), 10);
|
|
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
|
|
}
|
|
triggers.push(trigger);
|
|
}
|
|
return triggers;
|
|
}
|
|
|
|
function bindTriggers(el, verbInfo) {
|
|
var triggerSpec = el.getAttribute("sx-trigger");
|
|
var triggers;
|
|
|
|
if (triggerSpec) {
|
|
triggers = parseTrigger(triggerSpec);
|
|
} else {
|
|
// Defaults
|
|
if (el.tagName === "FORM") {
|
|
triggers = [{ event: "submit", modifiers: {} }];
|
|
} else if (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") {
|
|
triggers = [{ event: "change", modifiers: {} }];
|
|
} else {
|
|
triggers = [{ event: "click", modifiers: {} }];
|
|
}
|
|
}
|
|
|
|
triggers.forEach(function (trig) {
|
|
if (trig.event === "intersect") {
|
|
_bindIntersect(el, verbInfo, trig.modifiers);
|
|
} else if (trig.event === "load") {
|
|
setTimeout(function () { executeRequest(el, verbInfo); }, 0);
|
|
} else if (trig.event === "revealed") {
|
|
_bindIntersect(el, verbInfo, { once: true });
|
|
} else {
|
|
_bindEvent(el, verbInfo, trig);
|
|
}
|
|
});
|
|
}
|
|
|
|
function _bindEvent(el, verbInfo, trig) {
|
|
var eventName = trig.event;
|
|
var mods = trig.modifiers;
|
|
var listenTarget = mods.from ? document.querySelector(mods.from) || el : el;
|
|
var timer = null;
|
|
var lastVal = undefined;
|
|
|
|
var handler = function (e) {
|
|
// For form submissions, prevent default
|
|
if (eventName === "submit") e.preventDefault();
|
|
// For links, prevent navigation
|
|
if (eventName === "click" && el.tagName === "A") e.preventDefault();
|
|
|
|
// changed modifier: only fire if value changed
|
|
if (mods.changed && el.value !== undefined) {
|
|
if (el.value === lastVal) return;
|
|
lastVal = el.value;
|
|
}
|
|
|
|
if (mods.delay) {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(function () { executeRequest(el, verbInfo); }, mods.delay);
|
|
} else {
|
|
executeRequest(el, verbInfo);
|
|
}
|
|
};
|
|
|
|
listenTarget.addEventListener(eventName, handler, { once: !!mods.once });
|
|
}
|
|
|
|
function _bindIntersect(el, verbInfo, mods) {
|
|
if (!("IntersectionObserver" in window)) {
|
|
executeRequest(el, verbInfo);
|
|
return;
|
|
}
|
|
var fired = false;
|
|
var delay = mods.delay || 0;
|
|
var obs = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (!entry.isIntersecting) return;
|
|
if (mods.once && fired) return;
|
|
fired = true;
|
|
if (mods.once) obs.unobserve(el);
|
|
if (delay) {
|
|
setTimeout(function () { executeRequest(el, verbInfo); }, delay);
|
|
} else {
|
|
executeRequest(el, verbInfo);
|
|
}
|
|
});
|
|
});
|
|
obs.observe(el);
|
|
}
|
|
|
|
// ---- History manager --------------------------------------------------
|
|
|
|
var _historyCache = {};
|
|
var _historyCacheKeys = [];
|
|
|
|
function _cacheCurrentPage() {
|
|
var key = location.href;
|
|
var main = document.getElementById("main-panel");
|
|
if (!main) return;
|
|
_historyCache[key] = main.innerHTML;
|
|
// LRU eviction
|
|
var idx = _historyCacheKeys.indexOf(key);
|
|
if (idx >= 0) _historyCacheKeys.splice(idx, 1);
|
|
_historyCacheKeys.push(key);
|
|
while (_historyCacheKeys.length > HISTORY_MAX) {
|
|
delete _historyCache[_historyCacheKeys.shift()];
|
|
}
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("popstate", function (e) {
|
|
var url = location.href;
|
|
// Try cache first
|
|
if (_historyCache[url]) {
|
|
var main = document.getElementById("main-panel");
|
|
if (main) {
|
|
main.innerHTML = _historyCache[url];
|
|
Sx.processScripts(main);
|
|
Sx.hydrate(main);
|
|
SxEngine.process(main);
|
|
dispatch(document.body, "sx:afterSettle", { target: main });
|
|
return;
|
|
}
|
|
}
|
|
// Fetch fresh
|
|
var histOpts = {
|
|
headers: { "SX-Request": "true", "SX-History-Restore": "true" }
|
|
};
|
|
try {
|
|
var hHost = new URL(url, location.href).hostname;
|
|
if (hHost !== location.hostname &&
|
|
(hHost.endsWith(".rose-ash.com") || hHost.endsWith(".localhost"))) {
|
|
histOpts.credentials = "include";
|
|
}
|
|
} catch (e) {}
|
|
fetch(url, histOpts).then(function (resp) {
|
|
return resp.text();
|
|
}).then(function (text) {
|
|
var ct = "";
|
|
// Response content-type is lost here, check for sx
|
|
if (text.charAt(0) === "(") {
|
|
try { text = Sx.renderToString(text); } catch (e) { /* not sx */ }
|
|
}
|
|
var parser = new DOMParser();
|
|
var doc = parser.parseFromString(text, "text/html");
|
|
var newMain = doc.getElementById("main-panel");
|
|
var main = document.getElementById("main-panel");
|
|
if (main && newMain) {
|
|
main.innerHTML = newMain.innerHTML;
|
|
Sx.processScripts(main);
|
|
Sx.hydrate(main);
|
|
SxEngine.process(main);
|
|
dispatch(document.body, "sx:afterSettle", { target: main });
|
|
}
|
|
}).catch(function () {
|
|
location.reload();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- sx-on:* inline event handlers ------------------------------------
|
|
|
|
function _bindInlineHandlers(el) {
|
|
var attrs = el.attributes;
|
|
for (var i = 0; i < attrs.length; i++) {
|
|
var name = attrs[i].name;
|
|
if (name.indexOf("sx-on:") === 0) {
|
|
var evtName = name.substring(6);
|
|
el.addEventListener(evtName, new Function("event", attrs[i].value));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Process function -------------------------------------------------
|
|
|
|
function process(root) {
|
|
root = root || document.body;
|
|
if (!root || !root.querySelectorAll) return;
|
|
|
|
var selector = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]";
|
|
var elements = root.querySelectorAll(selector);
|
|
|
|
// Also check root itself
|
|
if (root.matches && root.matches(selector)) {
|
|
_processOne(root);
|
|
}
|
|
|
|
for (var i = 0; i < elements.length; i++) {
|
|
_processOne(elements[i]);
|
|
}
|
|
|
|
// Bind sx-on:* handlers on all elements
|
|
var allOnEls = root.querySelectorAll("[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]");
|
|
allOnEls.forEach(function (el) {
|
|
if (el[PROCESSED + "on"]) return;
|
|
el[PROCESSED + "on"] = true;
|
|
_bindInlineHandlers(el);
|
|
});
|
|
}
|
|
|
|
function _processOne(el) {
|
|
if (el[PROCESSED]) return;
|
|
// sx-disable: skip processing
|
|
if (el.hasAttribute("sx-disable") || el.closest("[sx-disable]")) return;
|
|
el[PROCESSED] = true;
|
|
|
|
var verbInfo = getVerb(el);
|
|
if (!verbInfo) return;
|
|
|
|
bindTriggers(el, verbInfo);
|
|
}
|
|
|
|
// ---- Public API -------------------------------------------------------
|
|
|
|
var engine = {
|
|
process: process,
|
|
executeRequest: executeRequest,
|
|
version: "1.0.0"
|
|
};
|
|
|
|
return engine;
|
|
})();
|
|
|
|
global.SxEngine = SxEngine;
|
|
|
|
// =========================================================================
|
|
// Auto-init in browser
|
|
// =========================================================================
|
|
|
|
Sx.VERSION = "2026-03-01a";
|
|
|
|
if (typeof document !== "undefined") {
|
|
var init = function () {
|
|
console.log("[sx.js] v" + Sx.VERSION + " init");
|
|
Sx.processScripts();
|
|
Sx.hydrate();
|
|
SxEngine.process();
|
|
};
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
// Cache current page before navigation
|
|
document.addEventListener("sx:beforeRequest", function () {
|
|
if (typeof SxEngine._cacheCurrentPage === "function") SxEngine._cacheCurrentPage();
|
|
});
|
|
|
|
}
|
|
|
|
})(typeof window !== "undefined" ? window : this);
|