Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -812,7 +812,13 @@
|
||||
var i = 0;
|
||||
while (i < args.length) {
|
||||
if (isKw(args[i]) && i + 1 < args.length) {
|
||||
kwargs[args[i].name] = sexpEval(args[i + 1], env);
|
||||
// Keep kwarg values as AST — renderDOM will handle them when the
|
||||
// component body references the param symbol. Simple literals are
|
||||
// eval'd so strings/numbers resolve immediately.
|
||||
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) ? sexpEval(v, env) : v);
|
||||
i += 2;
|
||||
} else {
|
||||
children.push(args[i]);
|
||||
@@ -875,6 +881,14 @@
|
||||
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("sexp.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
|
||||
@@ -1000,11 +1014,48 @@
|
||||
}
|
||||
if (name === "define" || name === "defcomp") { sexpEval(expr, env); return ""; }
|
||||
|
||||
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
|
||||
if (name === "map") {
|
||||
var mapFn = sexpEval(expr[1], env), mapColl = sexpEval(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 = sexpEval(expr[1], env), mixColl = sexpEval(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 = sexpEval(expr[1], env), filtColl = sexpEval(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 (isSexpTruthy(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("sexp.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(sexpEval(expr, env), env);
|
||||
@@ -1033,12 +1084,21 @@
|
||||
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) {
|
||||
kwargs[args[i].name] = sexpEval(args[i + 1], env);
|
||||
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) ? sexpEval(v, env) : v);
|
||||
i += 2;
|
||||
} else { children.push(args[i]); i++; }
|
||||
}
|
||||
@@ -1082,6 +1142,50 @@
|
||||
|
||||
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 Sexp = {
|
||||
// Types
|
||||
NIL: NIL,
|
||||
@@ -1153,6 +1257,11 @@
|
||||
var node = Sexp.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);
|
||||
Sexp.hydrate(el);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1241,6 +1350,601 @@
|
||||
|
||||
global.Sexp = Sexp;
|
||||
|
||||
// =========================================================================
|
||||
// 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;
|
||||
|
||||
// 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 };
|
||||
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/sexp content type
|
||||
var ct = resp.headers.get("Content-Type") || "";
|
||||
if (ct.indexOf("text/sexp") >= 0) {
|
||||
try { text = Sexp.renderToString(text); }
|
||||
catch (err) {
|
||||
console.error("sexp.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");
|
||||
|
||||
// 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
|
||||
Sexp.processScripts(parent);
|
||||
Sexp.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;
|
||||
}
|
||||
Sexp.processScripts(target);
|
||||
Sexp.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];
|
||||
Sexp.processScripts(main);
|
||||
Sexp.hydrate(main);
|
||||
SxEngine.process(main);
|
||||
dispatch(document.body, "sx:afterSettle", { target: main });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fetch fresh
|
||||
fetch(url, {
|
||||
headers: { "SX-Request": "true", "SX-History-Restore": "true" }
|
||||
}).then(function (resp) {
|
||||
return resp.text();
|
||||
}).then(function (text) {
|
||||
var ct = "";
|
||||
// Response content-type is lost here, check for sexp
|
||||
if (text.charAt(0) === "(") {
|
||||
try { text = Sexp.renderToString(text); } catch (e) { /* not sexp */ }
|
||||
}
|
||||
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;
|
||||
Sexp.processScripts(main);
|
||||
Sexp.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
|
||||
// =========================================================================
|
||||
@@ -1249,6 +1953,7 @@
|
||||
var init = function () {
|
||||
Sexp.processScripts();
|
||||
Sexp.hydrate();
|
||||
SxEngine.process();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -1256,23 +1961,11 @@
|
||||
init();
|
||||
}
|
||||
|
||||
// Re-process after HTMX swaps
|
||||
document.addEventListener("htmx:afterSwap", function (e) {
|
||||
Sexp.processScripts(e.detail.target);
|
||||
Sexp.hydrate(e.detail.target);
|
||||
// Cache current page before navigation
|
||||
document.addEventListener("sx:beforeRequest", function () {
|
||||
if (typeof SxEngine._cacheCurrentPage === "function") SxEngine._cacheCurrentPage();
|
||||
});
|
||||
|
||||
// S-expression wire format: intercept text/sexp responses and render to HTML
|
||||
// before HTMX swaps them in. Server sends Content-Type: text/sexp with
|
||||
// s-expression body; sexp.js renders to HTML string for HTMX to swap.
|
||||
document.addEventListener("htmx:beforeSwap", function (e) {
|
||||
var xhr = e.detail.xhr;
|
||||
var ct = xhr.getResponseHeader("Content-Type") || "";
|
||||
if (ct.indexOf("text/sexp") === -1) return;
|
||||
// Render s-expression response to HTML string
|
||||
var html = Sexp.renderToString(xhr.responseText);
|
||||
e.detail.serverResponse = html;
|
||||
});
|
||||
}
|
||||
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
|
||||
Reference in New Issue
Block a user