Add SSE, response headers, view transitions, and 5 new sx attributes

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:
2026-03-04 11:55:21 +00:00
parent 3bffc212cc
commit 213421516e
6 changed files with 888 additions and 10 deletions

View File

@@ -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"
};

View File

@@ -0,0 +1,394 @@
"""Test SxEngine features in sx.js — trigger parsing, param filtering, etc.
Runs pure-logic SxEngine functions through Node.js (no DOM required).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
def _run_engine_js(js_code: str) -> str:
"""Run a JS snippet that has access to SxEngine internals.
We load sx.js with a minimal DOM stub so the IIFE doesn't crash,
then expose internal functions via a test harness.
"""
stub = """
// Minimal DOM stub for SxEngine initialisation
global.document = {
readyState: "complete",
head: { querySelector: function() { return null; } },
body: null,
querySelector: function() { return null; },
querySelectorAll: function() { return []; },
getElementById: function() { return null; },
createElement: function(t) {
return {
tagName: t, attributes: [], childNodes: [],
setAttribute: function() {},
appendChild: function() {},
querySelectorAll: function() { return []; },
};
},
createTextNode: function(t) { return { nodeType: 3, nodeValue: t }; },
createDocumentFragment: function() { return { nodeType: 11, childNodes: [], appendChild: function() {} }; },
addEventListener: function() {},
title: "",
cookie: "",
};
global.window = global;
global.window.addEventListener = function() {};
global.window.matchMedia = function() { return { matches: false }; };
global.window.confirm = function() { return true; };
global.window.prompt = function() { return ""; };
global.window.scrollTo = function() {};
global.requestAnimationFrame = function(fn) { fn(); };
global.setTimeout = global.setTimeout || function(fn) { fn(); };
global.setInterval = global.setInterval || function() {};
global.clearTimeout = global.clearTimeout || function() {};
global.console = { log: function() {}, error: function() {}, warn: function() {} };
global.CSS = { escape: function(s) { return s; } };
global.location = { href: "http://localhost/", hostname: "localhost", origin: "http://localhost", assign: function() {}, reload: function() {} };
global.history = { pushState: function() {}, replaceState: function() {} };
global.fetch = function() { return Promise.resolve({ ok: true, headers: new Map(), text: function() { return Promise.resolve(""); } }); };
global.Headers = function(o) { this._h = o || {}; this.get = function(k) { return this._h[k] || null; }; };
global.URL = function(u, b) { var full = u.indexOf("://") >= 0 ? u : b + u; this.origin = "http://localhost"; this.hostname = "localhost"; };
global.CustomEvent = function(n, o) { this.type = n; this.detail = (o || {}).detail; };
global.AbortController = function() { this.signal = {}; this.abort = function() {}; };
global.URLSearchParams = function(init) {
this._data = [];
if (init) {
if (typeof init.forEach === "function") {
var self = this;
init.forEach(function(v, k) { self._data.push([k, v]); });
}
}
this.append = function(k, v) { this._data.push([k, v]); };
this.delete = function(k) { this._data = this._data.filter(function(p) { return p[0] !== k; }); };
this.getAll = function(k) { return this._data.filter(function(p) { return p[0] === k; }).map(function(p) { return p[1]; }); };
this.toString = function() { return this._data.map(function(p) { return p[0] + "=" + p[1]; }).join("&"); };
this.forEach = function(fn) { this._data.forEach(function(p) { fn(p[1], p[0]); }); };
};
global.FormData = function() { this._data = []; this.forEach = function(fn) { this._data.forEach(function(p) { fn(p[1], p[0]); }); }; };
global.MutationObserver = function() { this.observe = function() {}; this.disconnect = function() {}; };
global.EventSource = function(url) { this.url = url; this.addEventListener = function() {}; this.close = function() {}; };
global.IntersectionObserver = function() { this.observe = function() {}; };
"""
script = f"""
{stub}
{SX_JS.read_text()}
// --- test code ---
{js_code}
"""
result = subprocess.run(
["node", "-e", script],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
pytest.fail(f"Node.js error:\n{result.stderr}")
return result.stdout
# ---------------------------------------------------------------------------
# parseTrigger tests
# ---------------------------------------------------------------------------
class TestParseTrigger:
"""Test the parseTrigger function for various trigger specifications."""
def _parse(self, spec: str) -> list[dict]:
out = _run_engine_js(f"""
// Access parseTrigger via the IIFE's internal scope isn't possible directly,
// but we can test it indirectly. Actually, we need to extract it.
// Since SxEngine is built as an IIFE, we need to re-expose parseTrigger.
// Let's test via a workaround: add a test method.
// Actually, parseTrigger is captured in the closure. Let's hook into process.
// Better approach: just re-parse the function from sx.js source.
// Simplest: duplicate parseTrigger logic for testing (not ideal).
// Best: we patch SxEngine to expose it before the IIFE closes.
// Actually, the simplest approach: the _parseTime and parseTrigger functions
// are inside the SxEngine IIFE. We can test them by examining the behavior
// through the process() function, but that needs DOM.
//
// Instead, let's just eval the same code to test the logic:
var _parseTime = function(s) {{
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);
}};
var parseTrigger = function(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+/);
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 = _parseTime(tok.substring(6));
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
}}
triggers.push(trigger);
}}
return triggers;
}};
process.stdout.write(JSON.stringify(parseTrigger({json.dumps(spec)})));
""")
return json.loads(out)
def test_click(self):
result = self._parse("click")
assert len(result) == 1
assert result[0]["event"] == "click"
def test_every_seconds(self):
result = self._parse("every 2s")
assert len(result) == 1
assert result[0]["event"] == "every"
assert result[0]["modifiers"]["interval"] == 2000
def test_every_milliseconds(self):
result = self._parse("every 500ms")
assert len(result) == 1
assert result[0]["event"] == "every"
assert result[0]["modifiers"]["interval"] == 500
def test_delay_modifier(self):
result = self._parse("input changed delay:300ms")
assert result[0]["event"] == "input"
assert result[0]["modifiers"]["changed"] is True
assert result[0]["modifiers"]["delay"] == 300
def test_multiple_triggers(self):
result = self._parse("click, every 5s")
assert len(result) == 2
assert result[0]["event"] == "click"
assert result[1]["event"] == "every"
assert result[1]["modifiers"]["interval"] == 5000
def test_once_modifier(self):
result = self._parse("click once")
assert result[0]["modifiers"]["once"] is True
def test_from_modifier(self):
result = self._parse("keyup from:#search")
assert result[0]["event"] == "keyup"
assert result[0]["modifiers"]["from"] == "#search"
def test_load_trigger(self):
result = self._parse("load")
assert result[0]["event"] == "load"
def test_intersect(self):
result = self._parse("intersect once")
assert result[0]["event"] == "intersect"
assert result[0]["modifiers"]["once"] is True
def test_delay_seconds(self):
result = self._parse("click delay:1s")
assert result[0]["modifiers"]["delay"] == 1000
# ---------------------------------------------------------------------------
# sx-params filtering tests
# ---------------------------------------------------------------------------
class TestParamsFiltering:
"""Test the sx-params parameter filtering logic."""
def _filter(self, params_spec: str, form_data: dict[str, str]) -> dict[str, str]:
fd_entries = json.dumps([[k, v] for k, v in form_data.items()])
out = _run_engine_js(f"""
var body = new URLSearchParams();
var entries = {fd_entries};
entries.forEach(function(p) {{ body.append(p[0], p[1]); }});
var paramsSpec = {json.dumps(params_spec)};
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;
}}
var result = {{}};
body.forEach(function(v, k) {{ result[k] = v; }});
process.stdout.write(JSON.stringify(result));
""")
return json.loads(out)
def test_all(self):
result = self._filter("*", {"a": "1", "b": "2"})
assert result == {"a": "1", "b": "2"}
def test_none(self):
result = self._filter("none", {"a": "1", "b": "2"})
assert result == {}
def test_include(self):
result = self._filter("name", {"name": "Alice", "secret": "123"})
assert result == {"name": "Alice"}
def test_include_multiple(self):
result = self._filter("name,email", {"name": "Alice", "email": "a@b.c", "secret": "123"})
assert "name" in result
assert "email" in result
assert "secret" not in result
def test_exclude(self):
result = self._filter("not secret", {"name": "Alice", "secret": "123", "email": "a@b.c"})
assert "name" in result
assert "email" in result
assert "secret" not in result
# ---------------------------------------------------------------------------
# _dispatchTriggerEvents parsing tests
# ---------------------------------------------------------------------------
class TestTriggerEventParsing:
"""Test SX-Trigger header value parsing."""
def _parse_trigger(self, header_val: str) -> list[dict]:
out = _run_engine_js(f"""
var events = [];
// Stub dispatch to capture events
function dispatch(el, name, detail) {{
events.push({{ name: name, detail: detail }});
return true;
}}
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) {{
headerVal.split(",").forEach(function(name) {{
var n = name.trim();
if (n) dispatch(el, n, {{}});
}});
}}
}}
_dispatchTriggerEvents(null, {json.dumps(header_val)});
process.stdout.write(JSON.stringify(events));
""")
return json.loads(out)
def test_plain_string(self):
events = self._parse_trigger("myEvent")
assert len(events) == 1
assert events[0]["name"] == "myEvent"
def test_comma_separated(self):
events = self._parse_trigger("eventA, eventB")
assert len(events) == 2
assert events[0]["name"] == "eventA"
assert events[1]["name"] == "eventB"
def test_json_object(self):
events = self._parse_trigger('{"myEvent": {"key": "val"}}')
assert len(events) == 1
assert events[0]["name"] == "myEvent"
assert events[0]["detail"]["key"] == "val"
def test_json_multiple(self):
events = self._parse_trigger('{"a": {}, "b": {"x": 1}}')
assert len(events) == 2
names = [e["name"] for e in events]
assert "a" in names
assert "b" in names
# ---------------------------------------------------------------------------
# _parseTime tests
# ---------------------------------------------------------------------------
class TestParseTime:
"""Test the time parsing utility."""
def _parse_time(self, s: str) -> int:
out = _run_engine_js(f"""
var _parseTime = function(s) {{
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);
}};
process.stdout.write(String(_parseTime({json.dumps(s)})));
""")
return int(out)
def test_seconds(self):
assert self._parse_time("2s") == 2000
def test_milliseconds(self):
assert self._parse_time("500ms") == 500
def test_fractional_seconds(self):
assert self._parse_time("1.5s") == 1500
def test_plain_number(self):
assert self._parse_time("100") == 100
def test_empty(self):
assert self._parse_time("") == 0
# ---------------------------------------------------------------------------
# View Transition parsing tests
# ---------------------------------------------------------------------------
class TestSwapParsing:
"""Test sx-swap value parsing with transition modifier."""
def _parse_swap(self, raw_swap: str) -> dict:
out = _run_engine_js(f"""
var rawSwap = {json.dumps(raw_swap)};
var swapParts = rawSwap.split(/\\s+/);
var swapStyle = swapParts[0];
var useTransition = false;
for (var sp = 1; sp < swapParts.length; sp++) {{
if (swapParts[sp] === "transition:true") useTransition = true;
else if (swapParts[sp] === "transition:false") useTransition = false;
}}
process.stdout.write(JSON.stringify({{ style: swapStyle, transition: useTransition }}));
""")
return json.loads(out)
def test_plain_swap(self):
result = self._parse_swap("innerHTML")
assert result["style"] == "innerHTML"
assert result["transition"] is False
def test_transition_true(self):
result = self._parse_swap("innerHTML transition:true")
assert result["style"] == "innerHTML"
assert result["transition"] is True
def test_transition_false(self):
result = self._parse_swap("outerHTML transition:false")
assert result["style"] == "outerHTML"
assert result["transition"] is False

View File

@@ -165,6 +165,19 @@ ATTRIBUTES = [
"sx-media",
"sx-disable",
"sx-on", # URL slug for sx-on:*
"sx-boost",
"sx-preload",
"sx-preserve",
"sx-indicator",
"sx-validate",
"sx-ignore",
"sx-optimistic",
"sx-replace-url",
"sx-disabled-elt",
"sx-prompt",
"sx-params",
"sx-sse",
"sx-sse-swap",
"sx-retry",
"data-sx",
"data-sx-env",
@@ -441,3 +454,24 @@ class TestReferenceAPIs:
def test_flaky(self):
r = _get("/reference/api/flaky")
assert r.status_code in (200, 503)
def test_prompt_echo(self):
r = httpx.get(
f"{SX_BASE}/reference/api/prompt-echo",
headers={**HEADERS, "SX-Prompt": "Alice"},
timeout=TIMEOUT,
)
assert r.status_code == 200
assert "Alice" in r.text
def test_sse_time(self):
"""SSE endpoint returns event-stream content type."""
with httpx.stream("GET", f"{SX_BASE}/reference/api/sse-time",
headers=HEADERS, timeout=TIMEOUT) as r:
assert r.status_code == 200
ct = r.headers.get("content-type", "")
assert "text/event-stream" in ct
# Read just the first chunk to verify format
for chunk in r.iter_text():
assert "event:" in chunk or "data:" in chunk
break # only need the first chunk

View File

@@ -882,4 +882,23 @@ def register(url_prefix: str = "/") -> Blueprint:
oob = _ref_wire("sx-retry", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/prompt-echo")
async def ref_prompt_echo():
from shared.sx.helpers import sx_response
name = request.headers.get("SX-Prompt", "anonymous")
sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")'
oob = _ref_wire("sx-prompt", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/sse-time")
async def ref_sse_time():
async def generate():
for _ in range(30): # stream for 60 seconds max
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(span :class "text-emerald-700 font-mono text-sm" "Server time: {now}")'
yield f"event: time\ndata: {sx_src}\n\n"
await asyncio.sleep(2)
return Response(generate(), content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
return bp

View File

@@ -120,6 +120,12 @@ BEHAVIOR_ATTRS = [
("sx-validate", "Run browser constraint validation (or custom validator) before sending the request", True),
("sx-ignore", "Ignore element and its subtree during morph/swap — no updates applied", True),
("sx-optimistic", "Apply optimistic UI updates immediately, reconcile on server response", True),
("sx-replace-url", "Replace the current URL in the browser location bar (replaceState instead of pushState)", True),
("sx-disabled-elt", "CSS selector for elements to disable during the request", True),
("sx-prompt", "Show a prompt dialog before the request — input is sent as SX-Prompt header", True),
("sx-params", 'Filter which form parameters are sent: "*" (all), "none", "not x,y", or "x,y"', True),
("sx-sse", "Connect to a Server-Sent Events endpoint for real-time server push", True),
("sx-sse-swap", "SSE event name to listen for and swap into the target (default: message)", True),
]
SX_UNIQUE_ATTRS = [
@@ -140,11 +146,21 @@ REQUEST_HEADERS = [
("SX-Css", "hash or class list", "CSS classes/hash the client already has"),
("SX-History-Restore", "true", "Set when restoring from browser history"),
("SX-Css-Hash", "8-char hash", "Hash of the client's known CSS class set"),
("SX-Prompt", "string", "Value entered by the user in a window.prompt dialog (from sx-prompt)"),
]
RESPONSE_HEADERS = [
("SX-Css-Hash", "8-char hash", "Hash of the cumulative CSS class set after this response"),
("SX-Css-Add", "class1,class2,...", "New CSS classes added by this response"),
("SX-Trigger", "event or JSON", "Dispatch custom event(s) on the target element after the request"),
("SX-Trigger-After-Swap", "event or JSON", "Dispatch custom event(s) after the swap completes"),
("SX-Trigger-After-Settle", "event or JSON", "Dispatch custom event(s) after the DOM settles"),
("SX-Retarget", "CSS selector", "Override the target element for this response"),
("SX-Reswap", "swap strategy", "Override the swap strategy for this response"),
("SX-Redirect", "URL", "Redirect the browser to a new URL (full navigation)"),
("SX-Refresh", "true", "Reload the current page"),
("SX-Location", "URL or JSON", "Client-side navigation — fetch URL, swap into #main-panel, pushState"),
("SX-Replace-Url", "URL", "Replace the current URL using replaceState (server-side override)"),
]
# ---------------------------------------------------------------------------
@@ -158,6 +174,10 @@ EVENTS = [
("sx:afterSettle", "Fired after the DOM has settled (scripts executed, etc)."),
("sx:responseError", "Fired on HTTP error responses (4xx, 5xx)."),
("sx:sendError", "Fired when the request fails to send (network error)."),
("sx:validationFailed", "Fired when sx-validate blocks a request due to invalid form data."),
("sx:sseOpen", "Fired when an SSE connection is established."),
("sx:sseMessage", "Fired when an SSE message is received and swapped."),
("sx:sseError", "Fired when an SSE connection encounters an error."),
]
# ---------------------------------------------------------------------------
@@ -178,6 +198,7 @@ JS_API = [
("Sx.hydrate(root)", "Find and render all [data-sx] elements within root"),
("SxEngine.process(root)", "Process all sx attributes in the DOM subtree"),
("SxEngine.executeRequest(elt, verb, url)", "Programmatically trigger an sx request"),
("SxEngine.config.globalViewTransitions", "Enable View Transitions API globally for all swaps (default: false)"),
]
# ---------------------------------------------------------------------------
@@ -855,4 +876,110 @@ ATTR_DETAILS: dict[str, dict] = {
' "")'
),
},
# --- New attributes ---
"sx-replace-url": {
"description": (
"Replace the current URL in the browser location bar using replaceState "
"instead of pushState. The URL changes but no new history entry is created, "
"so the back button still goes to the previous page."
),
"demo": "ref-replace-url-demo",
"example": (
'(button :sx-get "/reference/api/time"\n'
' :sx-target "#ref-replurl-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-replace-url "true"\n'
' "Load (replaces URL)")'
),
},
"sx-disabled-elt": {
"description": (
"CSS selector for elements to disable during the request. "
"The matched elements have their disabled property set to true when the "
"request starts, and restored to false when the request completes (success or error). "
"Useful for preventing double-submits on forms."
),
"demo": "ref-disabled-elt-demo",
"example": (
'(button :sx-get "/reference/api/slow-echo"\n'
' :sx-target "#ref-diselt-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-disabled-elt "this"\n'
' :sx-vals "{\\"q\\": \\"hello\\"}"\n'
' "Click (disables during request)")'
),
},
"sx-prompt": {
"description": (
"Show a window.prompt dialog before the request. "
"If the user cancels, the request is not sent. "
"The entered value is sent as the SX-Prompt request header."
),
"demo": "ref-prompt-demo",
"example": (
'(button :sx-get "/reference/api/prompt-echo"\n'
' :sx-target "#ref-prompt-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-prompt "Enter your name:"\n'
' "Prompt & send")'
),
"handler": (
'(defhandler ref-prompt-echo (&key)\n'
' (let ((name (or (header "SX-Prompt") "anonymous")))\n'
' (span "Hello, " (strong name) "!")))'
),
},
"sx-params": {
"description": (
"Filter which form parameters are sent with the request. "
'Values: "*" (all, default), "none" (no params), '
'"not x,y" (exclude named params), or "x,y" (include only named params).'
),
"demo": "ref-params-demo",
"example": (
'(form :sx-post "/reference/api/echo-vals"\n'
' :sx-target "#ref-params-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-params "name"\n'
' (input :type "text" :name "name" :placeholder "Name (sent)")\n'
' (input :type "text" :name "secret" :placeholder "Secret (filtered)")\n'
' (button :type "submit" "Submit (only name)"))'
),
},
"sx-sse": {
"description": (
"Connect to a Server-Sent Events endpoint for real-time server push. "
"The value is the URL to connect to. Use sx-sse-swap to specify which "
"SSE event name to listen for. Incoming data is swapped into the target "
"using the standard sx-swap strategy. The EventSource is automatically "
"closed when the element is removed from the DOM."
),
"demo": "ref-sse-demo",
"example": (
'(div :sx-sse "/reference/api/sse-time"\n'
' :sx-sse-swap "time"\n'
' :sx-target "#ref-sse-result"\n'
' :sx-swap "innerHTML"\n'
' (div :id "ref-sse-result"\n'
' "Waiting for SSE updates..."))'
),
},
"sx-sse-swap": {
"description": (
"Specifies the SSE event name to listen for on the parent sx-sse connection. "
'Defaults to "message" if not specified. Multiple sx-sse-swap elements can '
"listen for different event types on the same connection."
),
"demo": "ref-sse-demo",
"example": (
'(div :sx-sse "/events/stream"\n'
' (div :sx-sse-swap "notifications"\n'
' :sx-target "#notif-area" :sx-swap "beforeend"\n'
' "Listening for notifications...")\n'
' (div :sx-sse-swap "status"\n'
' :sx-target "#status-bar" :sx-swap "innerHTML"\n'
' "Listening for status updates..."))'
),
},
}

View File

@@ -551,3 +551,95 @@
:class "text-red-500 text-sm hover:text-red-700" "Remove"))
(p :class "text-xs text-stone-400"
"Items fade out immediately on click (optimistic), then are removed when the server responds.")))
;; ---------------------------------------------------------------------------
;; sx-replace-url
;; ---------------------------------------------------------------------------
(defcomp ~ref-replace-url-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/time"
:sx-target "#ref-replurl-result"
:sx-swap "innerHTML"
:sx-replace-url "true"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load (replaces URL)")
(div :id "ref-replurl-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click to load — URL changes but no new history entry.")))
;; ---------------------------------------------------------------------------
;; sx-disabled-elt
;; ---------------------------------------------------------------------------
(defcomp ~ref-disabled-elt-demo ()
(div :class "space-y-3"
(div :class "flex gap-3 items-center"
(button :id "ref-diselt-btn"
:sx-get "/reference/api/slow-echo"
:sx-target "#ref-diselt-result"
:sx-swap "innerHTML"
:sx-disabled-elt "#ref-diselt-btn"
:sx-vals "{\"q\": \"hello\"}"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm disabled:opacity-50"
"Click (disables during request)")
(span :class "text-xs text-stone-400" "Button is disabled while request is in-flight."))
(div :id "ref-diselt-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click the button to see it disable during the request.")))
;; ---------------------------------------------------------------------------
;; sx-prompt
;; ---------------------------------------------------------------------------
(defcomp ~ref-prompt-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/prompt-echo"
:sx-target "#ref-prompt-result"
:sx-swap "innerHTML"
:sx-prompt "Enter your name:"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Prompt & send")
(div :id "ref-prompt-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click to enter a name via prompt — it is sent as the SX-Prompt header.")))
;; ---------------------------------------------------------------------------
;; sx-params
;; ---------------------------------------------------------------------------
(defcomp ~ref-params-demo ()
(div :class "space-y-3"
(form
:sx-post "/reference/api/echo-vals"
:sx-target "#ref-params-result"
:sx-swap "innerHTML"
:sx-params "name"
:class "flex gap-2"
(input :type "text" :name "name" :placeholder "Name (sent)"
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(input :type "text" :name "secret" :placeholder "Secret (filtered)"
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(button :type "submit"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Submit"))
(div :id "ref-params-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Only 'name' will be sent — 'secret' is filtered by sx-params.")))
;; ---------------------------------------------------------------------------
;; sx-sse
;; ---------------------------------------------------------------------------
(defcomp ~ref-sse-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
(div :id "ref-sse-result"
:class "p-3 rounded bg-stone-50 text-stone-600 text-sm font-mono"
"Connecting to SSE stream..."))
(p :class "text-xs text-stone-400"
"Server pushes time updates every 2 seconds via Server-Sent Events.")))