Add SSE, response headers, view transitions, and 5 new sx attributes
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Implement missing SxEngine features: - SSE (sx-sse, sx-sse-swap) with EventSource management and auto-cleanup - Response headers: SX-Trigger, SX-Retarget, SX-Reswap, SX-Redirect, SX-Refresh, SX-Location, SX-Replace-Url, SX-Trigger-After-Swap/Settle - View Transitions API: transition:true swap modifier + global config - every:<time> trigger for polling (setInterval) - sx-replace-url (replaceState instead of pushState) - sx-disabled-elt (disable elements during request) - sx-prompt (window.prompt, value sent as SX-Prompt header) - sx-params (filter form parameters: *, none, not x,y, x,y) Adds docs (ATTR_DETAILS, BEHAVIOR_ATTRS, headers, events), demo components in reference.sx, API endpoints (prompt-echo, sse-time), and 27 new unit tests for engine logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1379,6 +1379,16 @@
|
||||
var PROCESSED = "_sxBound";
|
||||
var VERBS = ["get", "post", "put", "delete", "patch"];
|
||||
var DEFAULT_SWAP = "outerHTML";
|
||||
var _config = { globalViewTransitions: false };
|
||||
|
||||
/** Wrap a function in View Transition API if supported and enabled. */
|
||||
function _withTransition(enabled, fn) {
|
||||
if (enabled && document.startViewTransition) {
|
||||
document.startViewTransition(fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function dispatch(el, name, detail) {
|
||||
@@ -1386,6 +1396,26 @@
|
||||
return el.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Parse and dispatch SX-Trigger header events.
|
||||
* Value can be: "myEvent" (plain string), '{"myEvent": {"key": "val"}}' (JSON). */
|
||||
function _dispatchTriggerEvents(el, headerVal) {
|
||||
if (!headerVal) return;
|
||||
try {
|
||||
var parsed = JSON.parse(headerVal);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
for (var evtName in parsed) dispatch(el, evtName, parsed[evtName]);
|
||||
} else {
|
||||
dispatch(el, String(parsed), {});
|
||||
}
|
||||
} catch (e) {
|
||||
// Plain string — may be comma-separated event names
|
||||
headerVal.split(",").forEach(function (name) {
|
||||
var n = name.trim();
|
||||
if (n) dispatch(el, n, {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
var m = document.querySelector('meta[name="csrf-token"]');
|
||||
return m ? m.getAttribute("content") : null;
|
||||
@@ -1458,6 +1488,15 @@
|
||||
if (!window.confirm(confirmMsg)) return Promise.resolve();
|
||||
}
|
||||
|
||||
// sx-prompt: show prompt dialog, send result as SX-Prompt header
|
||||
var promptMsg = el.getAttribute("sx-prompt");
|
||||
if (promptMsg) {
|
||||
var promptVal = window.prompt(promptMsg);
|
||||
if (promptVal === null) return Promise.resolve(); // cancelled
|
||||
extraParams = extraParams || {};
|
||||
extraParams.promptValue = promptVal;
|
||||
}
|
||||
|
||||
return _doFetch(el, method, url, extraParams);
|
||||
}
|
||||
|
||||
@@ -1496,6 +1535,11 @@
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// SX-Prompt header from sx-prompt dialog result
|
||||
if (extraParams && extraParams.promptValue !== undefined) {
|
||||
headers["SX-Prompt"] = extraParams.promptValue;
|
||||
}
|
||||
|
||||
// CSRF for same-origin mutating requests
|
||||
if (method !== "GET" && sameOrigin(url)) {
|
||||
var csrf = csrfToken();
|
||||
@@ -1529,6 +1573,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// sx-params: filter form parameters
|
||||
var paramsSpec = el.getAttribute("sx-params");
|
||||
if (paramsSpec && body instanceof URLSearchParams) {
|
||||
if (paramsSpec === "none") {
|
||||
body = new URLSearchParams();
|
||||
} else if (paramsSpec.indexOf("not ") === 0) {
|
||||
var excluded = paramsSpec.substring(4).split(",").map(function (s) { return s.trim(); });
|
||||
excluded.forEach(function (k) { body.delete(k); });
|
||||
} else if (paramsSpec !== "*") {
|
||||
var allowed = paramsSpec.split(",").map(function (s) { return s.trim(); });
|
||||
var filtered = new URLSearchParams();
|
||||
allowed.forEach(function (k) {
|
||||
body.getAll(k).forEach(function (v) { filtered.append(k, v); });
|
||||
});
|
||||
body = filtered;
|
||||
}
|
||||
}
|
||||
|
||||
// Include extra inputs
|
||||
var includeSel = el.getAttribute("sx-include");
|
||||
if (includeSel && method !== "GET") {
|
||||
@@ -1587,6 +1649,11 @@
|
||||
indicatorEl.style.display = "";
|
||||
}
|
||||
|
||||
// sx-disabled-elt: disable elements during request
|
||||
var disabledEltSel = el.getAttribute("sx-disabled-elt");
|
||||
var disabledElts = disabledEltSel ? Array.prototype.slice.call(document.querySelectorAll(disabledEltSel)) : [];
|
||||
disabledElts.forEach(function (e) { e.disabled = true; });
|
||||
|
||||
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
|
||||
// Cross-origin credentials for known subdomains
|
||||
try {
|
||||
@@ -1608,6 +1675,7 @@
|
||||
el.classList.remove("sx-request");
|
||||
el.removeAttribute("aria-busy");
|
||||
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
|
||||
disabledElts.forEach(function (e) { e.disabled = false; });
|
||||
|
||||
if (!resp.ok) {
|
||||
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
|
||||
@@ -1617,11 +1685,42 @@
|
||||
return resp.text().then(function (text) {
|
||||
dispatch(el, "sx:afterRequest", { response: resp });
|
||||
|
||||
// --- Response header processing ---
|
||||
|
||||
// SX-Redirect: navigate away (skip swap entirely)
|
||||
var hdrRedirect = resp.headers.get("SX-Redirect");
|
||||
if (hdrRedirect) { location.assign(hdrRedirect); return; }
|
||||
|
||||
// SX-Refresh: reload page (skip swap entirely)
|
||||
var hdrRefresh = resp.headers.get("SX-Refresh");
|
||||
if (hdrRefresh === "true") { location.reload(); return; }
|
||||
|
||||
// SX-Trigger: dispatch custom events on target
|
||||
var hdrTrigger = resp.headers.get("SX-Trigger");
|
||||
if (hdrTrigger) _dispatchTriggerEvents(el, hdrTrigger);
|
||||
|
||||
// Process the response
|
||||
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
|
||||
var rawSwap = el.getAttribute("sx-swap") || DEFAULT_SWAP;
|
||||
var target = resolveTarget(el, null);
|
||||
var selectSel = el.getAttribute("sx-select");
|
||||
|
||||
// SX-Retarget: server overrides target
|
||||
var hdrRetarget = resp.headers.get("SX-Retarget");
|
||||
if (hdrRetarget) target = document.querySelector(hdrRetarget) || target;
|
||||
|
||||
// SX-Reswap: server overrides swap strategy
|
||||
var hdrReswap = resp.headers.get("SX-Reswap");
|
||||
if (hdrReswap) rawSwap = hdrReswap;
|
||||
|
||||
// Parse swap style and modifiers (e.g. "innerHTML transition:true")
|
||||
var swapParts = rawSwap.split(/\s+/);
|
||||
var swapStyle = swapParts[0];
|
||||
var useTransition = _config.globalViewTransitions;
|
||||
for (var sp = 1; sp < swapParts.length; sp++) {
|
||||
if (swapParts[sp] === "transition:true") useTransition = true;
|
||||
else if (swapParts[sp] === "transition:false") useTransition = false;
|
||||
}
|
||||
|
||||
// Check for text/sx content type — use direct DOM rendering path
|
||||
var ct = resp.headers.get("Content-Type") || "";
|
||||
if (ct.indexOf("text/sx") >= 0) {
|
||||
@@ -1663,8 +1762,10 @@
|
||||
|
||||
// Main swap using DOM morph
|
||||
if (swapStyle !== "none" && target) {
|
||||
_swapDOM(target, selectedDOM, swapStyle);
|
||||
_hoistHeadElements(target);
|
||||
_withTransition(useTransition, function () {
|
||||
_swapDOM(target, selectedDOM, swapStyle);
|
||||
_hoistHeadElements(target);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("sx.js render error [v2]:", err, "\nsxSource first 500:", sxSource ? sxSource.substring(0, 500) : "(empty)");
|
||||
@@ -1697,34 +1798,67 @@
|
||||
|
||||
// Main swap
|
||||
if (swapStyle !== "none" && target) {
|
||||
_swapContent(target, content, swapStyle);
|
||||
_hoistHeadElements(target);
|
||||
_withTransition(useTransition, function () {
|
||||
_swapContent(target, content, swapStyle);
|
||||
_hoistHeadElements(target);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// History
|
||||
// SX-Location: server-driven client-side navigation
|
||||
var hdrLocation = resp.headers.get("SX-Location");
|
||||
if (hdrLocation) {
|
||||
var locUrl = hdrLocation;
|
||||
try { var locObj = JSON.parse(hdrLocation); locUrl = locObj.path || locObj; } catch (e) {}
|
||||
fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var main = document.getElementById("main-panel");
|
||||
if (main) { _swapContent(main, t, "innerHTML"); _postSwap(main); }
|
||||
try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// History: sx-push-url (pushState) and sx-replace-url (replaceState)
|
||||
var pushUrl = el.getAttribute("sx-push-url");
|
||||
if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) {
|
||||
var replaceUrl = el.getAttribute("sx-replace-url");
|
||||
// SX-Replace-Url response header overrides client-side attribute
|
||||
var hdrReplaceUrl = resp.headers.get("SX-Replace-Url");
|
||||
if (hdrReplaceUrl) {
|
||||
try { history.replaceState({ sxUrl: hdrReplaceUrl, scrollY: window.scrollY }, "", hdrReplaceUrl); } catch (e) {}
|
||||
} else if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) {
|
||||
var pushTarget = pushUrl === "true" ? url : pushUrl;
|
||||
try {
|
||||
history.pushState({ sxUrl: pushTarget, scrollY: window.scrollY }, "", pushTarget);
|
||||
} catch (e) {
|
||||
// Cross-origin pushState not allowed — full navigation
|
||||
location.assign(pushTarget);
|
||||
return;
|
||||
}
|
||||
} else if (replaceUrl === "true" || (replaceUrl && replaceUrl !== "false")) {
|
||||
var replTarget = replaceUrl === "true" ? url : replaceUrl;
|
||||
try {
|
||||
history.replaceState({ sxUrl: replTarget, scrollY: window.scrollY }, "", replTarget);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
dispatch(el, "sx:afterSwap", { target: target });
|
||||
// SX-Trigger-After-Swap
|
||||
var hdrTriggerSwap = resp.headers.get("SX-Trigger-After-Swap");
|
||||
if (hdrTriggerSwap) _dispatchTriggerEvents(el, hdrTriggerSwap);
|
||||
// Settle tick
|
||||
requestAnimationFrame(function () {
|
||||
dispatch(el, "sx:afterSettle", { target: target });
|
||||
// SX-Trigger-After-Settle
|
||||
var hdrTriggerSettle = resp.headers.get("SX-Trigger-After-Settle");
|
||||
if (hdrTriggerSettle) _dispatchTriggerEvents(el, hdrTriggerSettle);
|
||||
});
|
||||
});
|
||||
}).catch(function (err) {
|
||||
el.classList.remove("sx-request");
|
||||
el.removeAttribute("aria-busy");
|
||||
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
|
||||
disabledElts.forEach(function (e) { e.disabled = false; });
|
||||
if (err.name === "AbortError") return;
|
||||
dispatch(el, "sx:sendError", { error: err });
|
||||
return _handleRetry(el, verbInfo, extraParams);
|
||||
@@ -2012,6 +2146,14 @@
|
||||
|
||||
// ---- Trigger system ---------------------------------------------------
|
||||
|
||||
function _parseTime(s) {
|
||||
// Parse time string: "2s" → 2000, "500ms" → 500, "1.5s" → 1500
|
||||
if (!s) return 0;
|
||||
if (s.indexOf("ms") >= 0) return parseInt(s, 10);
|
||||
if (s.indexOf("s") >= 0) return parseFloat(s) * 1000;
|
||||
return parseInt(s, 10);
|
||||
}
|
||||
|
||||
function parseTrigger(spec) {
|
||||
if (!spec) return null;
|
||||
var triggers = [];
|
||||
@@ -2020,12 +2162,17 @@
|
||||
var p = parts[i].trim();
|
||||
if (!p) continue;
|
||||
var tokens = p.split(/\s+/);
|
||||
// Handle "every <time>" as a special trigger
|
||||
if (tokens[0] === "every" && tokens.length >= 2) {
|
||||
triggers.push({ event: "every", modifiers: { interval: _parseTime(tokens[1]) } });
|
||||
continue;
|
||||
}
|
||||
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("delay:") === 0) trigger.modifiers.delay = _parseTime(tok.substring(6));
|
||||
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
|
||||
}
|
||||
triggers.push(trigger);
|
||||
@@ -2051,7 +2198,10 @@
|
||||
}
|
||||
|
||||
triggers.forEach(function (trig) {
|
||||
if (trig.event === "intersect") {
|
||||
if (trig.event === "every") {
|
||||
var ms = trig.modifiers.interval || 1000;
|
||||
setInterval(function () { executeRequest(el, verbInfo); }, ms);
|
||||
} else if (trig.event === "intersect") {
|
||||
_bindIntersect(el, verbInfo, trig.modifiers);
|
||||
} else if (trig.event === "load") {
|
||||
setTimeout(function () { executeRequest(el, verbInfo); }, 0);
|
||||
@@ -2394,6 +2544,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SSE (Server-Sent Events) ----------------------------------------
|
||||
|
||||
function _processSSE(root) {
|
||||
var sseEls = root.querySelectorAll("[sx-sse]");
|
||||
if (root.matches && root.matches("[sx-sse]")) _bindSSE(root);
|
||||
for (var i = 0; i < sseEls.length; i++) _bindSSE(sseEls[i]);
|
||||
}
|
||||
|
||||
function _bindSSE(el) {
|
||||
if (el._sxSSE) return; // already connected
|
||||
var url = el.getAttribute("sx-sse");
|
||||
if (!url) return;
|
||||
|
||||
var source = new EventSource(url);
|
||||
el._sxSSE = source;
|
||||
|
||||
// Bind swap handlers for sx-sse-swap="eventName" attributes on el and descendants
|
||||
var swapEls = el.querySelectorAll("[sx-sse-swap]");
|
||||
if (el.hasAttribute("sx-sse-swap")) _bindSSESwap(el, source);
|
||||
for (var i = 0; i < swapEls.length; i++) _bindSSESwap(swapEls[i], source);
|
||||
|
||||
source.addEventListener("error", function () { dispatch(el, "sx:sseError", {}); });
|
||||
source.addEventListener("open", function () { dispatch(el, "sx:sseOpen", {}); });
|
||||
|
||||
// Cleanup: close EventSource when element is removed from DOM
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
var obs = new MutationObserver(function () {
|
||||
if (!document.body.contains(el)) {
|
||||
source.close();
|
||||
el._sxSSE = null;
|
||||
obs.disconnect();
|
||||
}
|
||||
});
|
||||
obs.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
function _bindSSESwap(el, source) {
|
||||
var eventName = el.getAttribute("sx-sse-swap") || "message";
|
||||
source.addEventListener(eventName, function (e) {
|
||||
var target = resolveTarget(el, null) || el;
|
||||
var swapStyle = el.getAttribute("sx-swap") || "innerHTML";
|
||||
var data = e.data;
|
||||
if (data.trim().charAt(0) === "(") {
|
||||
try {
|
||||
var dom = Sx.render(data);
|
||||
_swapDOM(target, dom, swapStyle);
|
||||
} catch (err) {
|
||||
_swapContent(target, data, swapStyle);
|
||||
}
|
||||
} else {
|
||||
_swapContent(target, data, swapStyle);
|
||||
}
|
||||
_postSwap(target);
|
||||
dispatch(el, "sx:sseMessage", { data: data, event: eventName });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Process function -------------------------------------------------
|
||||
|
||||
function process(root) {
|
||||
@@ -2415,6 +2623,9 @@
|
||||
// Process sx-boost containers
|
||||
_processBoosted(root);
|
||||
|
||||
// Process SSE connections
|
||||
_processSSE(root);
|
||||
|
||||
// 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) {
|
||||
@@ -2442,6 +2653,7 @@
|
||||
var engine = {
|
||||
process: process,
|
||||
executeRequest: executeRequest,
|
||||
config: _config,
|
||||
version: "1.0.0"
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user