Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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); });
|
||||
})();
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user