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

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