Send all responses as sexp wire format with client-side rendering

- 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:
2026-03-01 09:45:07 +00:00
parent 0d48fd22ee
commit 22802bd36b
270 changed files with 7153 additions and 5382 deletions

View File

@@ -36,7 +36,7 @@
// 2. Image gallery
// - Supports multiple galleries via [data-gallery-root]
// - Thumbnail navigation, prev/next arrows, keyboard arrows, touch swipe
// - HTMX-aware: runs on initial load and after HTMX swaps
// - Runs on initial load and after SxEngine swaps
// ============================================================================
(() => {
@@ -62,11 +62,11 @@
/**
* Initialize a single gallery instance.
* This attaches handlers only once, even if HTMX re-inserts the fragment.
* This attaches handlers only once, even if SxEngine re-inserts the fragment.
* @param {Element} root - Element with [data-gallery-root].
*/
function initOneGallery(root) {
// Prevent double-initialization (HTMX may re-insert the same fragment)
// Prevent double-initialization (SxEngine may re-insert the same fragment)
if (root.dataset.galleryInitialized === 'true') return;
root.dataset.galleryInitialized = 'true';
@@ -189,18 +189,10 @@
initGallery(document);
});
// Re-initialize galleries inside new fragments from HTMX
if (window.htmx) {
// htmx.onLoad runs on initial load and after each swap
htmx.onLoad((content) => {
initGallery(content);
});
// Alternative:
// htmx.on('htmx:afterSwap', (evt) => {
// initGallery(evt.detail.target);
// });
}
// Re-initialize galleries after SxEngine swaps
document.addEventListener('sx:afterSettle', (evt) => {
initGallery(evt.detail?.target || document);
});
})();
@@ -319,7 +311,7 @@
initPeek();
}
// Expose for dynamic inserts (e.g., from HTMX or other JS)
// Expose for dynamic inserts (e.g., from SxEngine or other JS)
window.initPeekScroll = initPeek;
})();
@@ -327,7 +319,7 @@
// ============================================================================
// 4. Exclusive <details> behavior
// - Only one <details> with the same [data-toggle-group] is open at a time
// - Respects HTMX swaps by re-attaching afterSwap
// - Respects SxEngine swaps by re-attaching afterSettle
// - Scrolls to top when opening a panel
// ============================================================================
@@ -369,25 +361,22 @@ function attachExclusiveDetailsBehavior(root = document) {
// Initial binding on page load
attachExclusiveDetailsBehavior();
// Re-bind for new content after HTMX swaps
document.body.addEventListener('htmx:afterSwap', function (evt) {
attachExclusiveDetailsBehavior(evt.target);
// Re-bind for new content after SxEngine swaps
document.body.addEventListener('sx:afterSettle', function (evt) {
attachExclusiveDetailsBehavior(evt.detail?.target || document);
});
// ============================================================================
// 5. Close <details> panels before HTMX requests
// - When a link/button inside a <details[data-toggle-group]> triggers HTMX,
// 5. Close <details> panels before SxEngine requests
// - When a link/button inside a <details[data-toggle-group]> triggers SxEngine,
// we close that panel and scroll to top.
// ============================================================================
document.body.addEventListener('htmx:beforeRequest', function (evt) {
document.body.addEventListener('sx:beforeRequest', function (evt) {
const triggerEl = evt.target;
// Find the closest <details> panel (e.g., mobile panel, filters, etc.)
const panel = triggerEl.closest('details[data-toggle-group]');
if (!panel) return;
panel.open = false;
window.scrollTo(0, 0);
});
@@ -397,7 +386,7 @@ document.body.addEventListener('htmx:beforeRequest', function (evt) {
// 6. Ghost / Koenig video card fix
// - Ghost/Koenig editors may output <figure class="kg-video-card"><video>...</video></figure>
// - This replaces the <figure> with just the <video>, and enforces some defaults.
// - Works on initial load, HTMX swaps, and general DOM inserts.
// - Works on initial load, SxEngine swaps, and general DOM inserts.
// ============================================================================
(function () {
@@ -433,9 +422,9 @@ document.body.addEventListener('htmx:beforeRequest', function (evt) {
replaceKgFigures();
}
// After HTMX swaps, fix any new figures
document.addEventListener('htmx:afterSwap', e =>
replaceKgFigures(e.target || document)
// After SxEngine swaps, fix any new figures
document.addEventListener('sx:afterSettle', e =>
replaceKgFigures(e.detail?.target || document)
);
// Fallback: MutationObserver for other dynamic content inserts
@@ -487,7 +476,7 @@ document.addEventListener('DOMContentLoaded', () => {
// - Targets iframes from youtube.com / youtube-nocookie.com
// - Removes width/height attributes so CSS can take over
// - Applies aspect-ratio if supported, else JS resize fallback
// - Works on initial load + after HTMX swaps
// - Works on initial load + after SxEngine swaps
// ============================================================================
(function () {
@@ -535,150 +524,100 @@ document.addEventListener('DOMContentLoaded', () => {
fixYouTubeIframes();
}
// Run after HTMX swaps
document.addEventListener('htmx:afterSwap', e =>
fixYouTubeIframes(e.target || document)
// Run after SxEngine swaps
document.addEventListener('sx:afterSettle', e =>
fixYouTubeIframes(e.detail?.target || document)
);
})();
// ============================================================================
// 9. HTMX global error handler (SweetAlert2)
// - Listens for htmx:responseError events
// 9. SxEngine global error handler (SweetAlert2)
// - Listens for sx:responseError events
// - Extracts error info from JSON or HTML responses
// - Shows a SweetAlert error modal with details
// ============================================================================
document.body.addEventListener("htmx:responseError", function (event) {
const xhr = event.detail.xhr;
const status = xhr.status;
const contentType = xhr.getResponseHeader("Content-Type") || "";
const triggerEl = event.detail.elt; // element that fired the request
const form = triggerEl ? triggerEl.closest("form") : null;
// SxEngine global error handler
document.body.addEventListener("sx:responseError", function (event) {
var resp = event.detail.response;
if (!resp) return;
var status = resp.status || 0;
var triggerEl = event.target;
var form = triggerEl ? triggerEl.closest("form") : null;
let title = "Something went wrong";
if (status >= 500) {
title = "Server error";
} else if (status >= 400) {
title = "There was a problem with your request";
}
var title = "Something went wrong";
if (status >= 500) title = "Server error";
else if (status >= 400) title = "There was a problem with your request";
let message = "";
let fieldErrors = null;
let html = "";
resp.text().then(function (text) {
var contentType = resp.headers.get("Content-Type") || "";
var message = "";
var html = "";
var fieldErrors = null;
if (contentType.includes("application/json")) {
try {
const data = JSON.parse(xhr.responseText || "{}");
message = data.message || "";
// We expect errors as an object: { field: [msg, ...], ... }
if (data.errors && typeof data.errors === "object" && !Array.isArray(data.errors)) {
fieldErrors = data.errors;
// Build a bullet list for SweetAlert
const allMessages = [];
for (const [field, msgs] of Object.entries(data.errors)) {
const arr = Array.isArray(msgs) ? msgs : [msgs];
allMessages.push(...arr);
}
if (allMessages.length) {
html =
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
allMessages.map((e) => `<li>${e}</li>`).join("") +
"</ul>";
}
} else if (Array.isArray(data.errors)) {
// Legacy shape: errors: ["msg1", "msg2"]
html =
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
data.errors.map((e) => `<li>${e}</li>`).join("") +
"</ul>";
} else if (data.error) {
html = data.error;
}
} catch (e) {
html = xhr.responseText;
}
} else {
// HTML or plain text
html = xhr.responseText;
}
// Apply field-level highlighting + scroll
if (form && fieldErrors) {
// Clear previous error state
form.querySelectorAll(".field-error").forEach((el) => {
el.classList.remove(
"field-error",
"border-red-500",
"ring-1",
"ring-red-500"
);
el.removeAttribute("aria-invalid");
});
let firstErrorInput = null;
for (const [field, msgs] of Object.entries(fieldErrors)) {
if (field === "__all__" || field === "_global") continue;
// Special case: days group
if (field === "days") {
const group = form.querySelector("[data-days-group]");
if (group) {
group.classList.add(
"field-error",
"border",
"border-red-500",
"rounded",
"ring-1",
"ring-red-500"
);
// Focus the first checkbox in the group
if (!firstErrorInput) {
const cb = group.querySelector('input[type="checkbox"]');
if (cb) {
firstErrorInput = cb;
}
if (contentType.includes("application/json")) {
try {
var data = JSON.parse(text || "{}");
message = data.message || "";
if (data.errors && typeof data.errors === "object" && !Array.isArray(data.errors)) {
fieldErrors = data.errors;
var allMessages = [];
for (var field in data.errors) {
var arr = Array.isArray(data.errors[field]) ? data.errors[field] : [data.errors[field]];
allMessages.push.apply(allMessages, arr);
}
if (allMessages.length) {
html = "<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
allMessages.map(function (e) { return "<li>" + e + "</li>"; }).join("") + "</ul>";
}
} else if (Array.isArray(data.errors)) {
html = "<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
data.errors.map(function (e) { return "<li>" + e + "</li>"; }).join("") + "</ul>";
} else if (data.error) {
html = data.error;
}
continue;
} catch (e) { html = text; }
} else {
html = text;
}
if (form && fieldErrors) {
form.querySelectorAll(".field-error").forEach(function (el) {
el.classList.remove("field-error", "border-red-500", "ring-1", "ring-red-500");
el.removeAttribute("aria-invalid");
});
var firstErrorInput = null;
for (var fld in fieldErrors) {
if (fld === "__all__" || fld === "_global") continue;
if (fld === "days") {
var group = form.querySelector("[data-days-group]");
if (group) {
group.classList.add("field-error", "border", "border-red-500", "rounded", "ring-1", "ring-red-500");
if (!firstErrorInput) { var cb = group.querySelector('input[type="checkbox"]'); if (cb) firstErrorInput = cb; }
}
continue;
}
var input = form.querySelector('[name="' + fld + '"]');
if (!input) continue;
input.classList.add("field-error", "border-red-500", "ring-1", "ring-red-500");
input.setAttribute("aria-invalid", "true");
if (!firstErrorInput) firstErrorInput = input;
}
// Normal fields: find by name
const input = form.querySelector(`[name="${field}"]`);
if (!input) continue;
input.classList.add(
"field-error",
"border-red-500",
"ring-1",
"ring-red-500"
);
input.setAttribute("aria-invalid", "true");
if (!firstErrorInput) {
firstErrorInput = input;
if (firstErrorInput) {
firstErrorInput.scrollIntoView({ behavior: "smooth", block: "center" });
firstErrorInput.focus({ preventScroll: true });
}
}
if (firstErrorInput) {
firstErrorInput.scrollIntoView({ behavior: "smooth", block: "center" });
firstErrorInput.focus({ preventScroll: true });
if (typeof Swal !== "undefined") {
Swal.fire({ icon: "error", title: message || title, html: html || "Please correct the highlighted fields and try again." });
}
}
Swal.fire({
icon: "error",
title: message || title,
html: html || "Please correct the highlighted fields and try again.",
});
}).catch(function () {});
});
document.addEventListener('toggle', function (event) {
const details = event.target;
// Only act on <details> elements that were just opened
@@ -737,7 +676,7 @@ document.body.addEventListener('click', function (e) {
const eventName = btn.getAttribute('data-confirm-event');
if (eventName) {
// HTMX-style: fire a custom event (e.g. "confirmed") for hx-trigger
// Fire a custom event (e.g. "confirmed") for sx-trigger
btn.dispatchEvent(new CustomEvent(eventName, { bubbles: true }));
return;
}
@@ -746,7 +685,7 @@ document.body.addEventListener('click', function (e) {
const form = btn.closest('form');
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(btn); // proper HTMX-visible submit
form.requestSubmit(btn); // proper submit
} else {
const ev = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(ev);
@@ -795,28 +734,38 @@ document.body.addEventListener('click', function (e) {
document.body.addEventListener('htmx:beforeSwap', function (event) {
const xhr = event.detail.xhr;
if (!xhr) return;
// Server can send: HX-Preserve-Search: keep | replace
const mode = xhr.getResponseHeader('HX-Preserve-Search');
// Only remove if no preserve header AND incoming response contains the element
if (!mode) {
const parser = new DOMParser();
const doc = parser.parseFromString(event.detail.serverResponse, 'text/html');
const el = document.getElementById('search-desktop');
if (el && doc.getElementById('search-desktop')) {
el.parentElement.removeChild(el);
}
const el2 = document.getElementById('search-mobile');
if (el2 && doc.getElementById('search-mobile')) {
el2.parentElement.removeChild(el2);
// ============================================================================
// Scrolling menu arrow visibility (replaces hyperscript scroll/load handlers)
// Elements with data-scroll-arrows="arrow-class" show/hide arrows on overflow.
// ============================================================================
(function () {
function updateArrows(container) {
var arrowClass = container.getAttribute('data-scroll-arrows');
if (!arrowClass) return;
var arrows = document.querySelectorAll('.' + arrowClass);
if (window.innerWidth >= 640 && container.scrollWidth > container.clientWidth) {
arrows.forEach(function (a) { a.classList.remove('hidden'); a.classList.add('flex'); });
} else {
arrows.forEach(function (a) { a.classList.add('hidden'); a.classList.remove('flex'); });
}
}
});
function initScrollArrows(root) {
(root || document).querySelectorAll('[data-scroll-arrows]').forEach(function (el) {
if (el._scrollArrowsBound) return;
el._scrollArrowsBound = true;
el.addEventListener('scroll', function () { updateArrows(el); }, { passive: true });
updateArrows(el);
});
}
document.addEventListener('DOMContentLoaded', function () { initScrollArrows(); });
window.addEventListener('load', function () {
document.querySelectorAll('[data-scroll-arrows]').forEach(updateArrows);
});
document.addEventListener('sx:afterSettle', function (e) { initScrollArrows(e.detail?.target); });
})();

View File

@@ -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);