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:
@@ -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); });
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -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