Refactor SX templates: shared components, Python migration, cleanup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s

- Extract shared components (empty-state, delete-btn, sentinel, crud-*,
  view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth
  forms, order tables/detail/checkout)
- Migrate all Python sx_call() callers to use shared components directly
- Remove 55+ thin wrapper defcomps from domain .sx files
- Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc)
- Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx
- Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx
- Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0)
- Add SX response validation and debug headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 20:34:34 +00:00
parent 755313bd29
commit c0d369eb8e
58 changed files with 3473 additions and 1210 deletions

View File

@@ -1,5 +1,5 @@
/**
* sx.js — S-expression parser, evaluator, and DOM renderer.
* sx.js — S-expression parser, evaluator, and DOM renderer. [v2-debug]
*
* Client-side counterpart to shared/sx/ Python modules.
* Parses s-expression text, evaluates it, and renders to DOM nodes.
@@ -1293,8 +1293,13 @@
// Component management
loadComponents: function (text) {
var exprs = parseAll(text);
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
try {
var exprs = parseAll(text);
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
} catch (err) {
console.error("sx.js loadComponents error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
throw err;
}
},
getEnv: function () { return _componentEnv; },
@@ -1630,79 +1635,124 @@
return resp.text().then(function (text) {
dispatch(el, "sx:afterRequest", { response: resp });
// Check for text/sx content type
// Process the response
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
var target = resolveTarget(el, null);
var selectSel = el.getAttribute("sx-select");
// Check for text/sx content type — use direct DOM rendering path
var ct = resp.headers.get("Content-Type") || "";
if (ct.indexOf("text/sx") >= 0) {
try {
// Strip and load any <script type="text/sx" data-components> blocks
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { Sx.loadComponents(defs); return ""; });
text = Sx.renderToString(text.trim());
}
catch (err) {
console.error("sx.js render error:", err);
var sxSource = text.trim();
// Parse and render to live DOM nodes (skip renderToString + DOMParser)
if (sxSource && sxSource.charAt(0) !== "(") {
console.error("sx.js: sxSource does not start with '(' — first 200 chars:", sxSource.substring(0, 200));
}
var sxDom = Sx.render(sxSource);
// Wrap in container for querySelectorAll (DocumentFragment doesn't support it)
var container = document.createElement("div");
container.appendChild(sxDom);
// OOB processing on live DOM nodes
var oobs = container.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");
oob.parentNode.removeChild(oob);
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
});
// hx-swap-oob compat
var hxOobs = container.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");
oob.parentNode.removeChild(oob);
if (oobTarget) _swapDOM(oobTarget, oob, oobSwap);
});
// sx-select filtering
var selectedDOM;
if (selectSel) {
selectedDOM = document.createDocumentFragment();
selectSel.split(",").forEach(function (sel) {
container.querySelectorAll(sel.trim()).forEach(function (m) {
selectedDOM.appendChild(m);
});
});
} else {
// Use all remaining children
selectedDOM = document.createDocumentFragment();
while (container.firstChild) selectedDOM.appendChild(container.firstChild);
}
// Main swap using DOM morph
if (swapStyle !== "none" && target) {
_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)");
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");
// Process any sx script blocks in the response (e.g. cross-domain component defs)
Sx.processScripts(doc);
// 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;
}
// HTML string path — existing DOMParser pipeline
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
// Main swap
if (swapStyle !== "none" && target) {
_swapContent(target, content, swapStyle);
// Auto-hoist any head elements that ended up in body
_hoistHeadElements(target);
// Process any sx script blocks in the response
Sx.processScripts(doc);
// OOB processing
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);
});
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) {
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);
_hoistHeadElements(target);
}
}
// History
@@ -1733,7 +1783,180 @@
});
}
// ---- Swap engine ------------------------------------------------------
// ---- DOM morphing ------------------------------------------------------
/**
* Lightweight DOM reconciler — patches oldNode to match newNode in-place,
* preserving event listeners, focus, scroll position, and form state on
* keyed (id) elements.
*/
function _morphDOM(oldNode, newNode) {
// Different node types or tag names → replace wholesale
if (oldNode.nodeType !== newNode.nodeType ||
oldNode.nodeName !== newNode.nodeName) {
oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
return;
}
// Text/comment nodes → update content
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
if (oldNode.nodeValue !== newNode.nodeValue)
oldNode.nodeValue = newNode.nodeValue;
return;
}
// Element nodes → sync attributes, then recurse children
if (oldNode.nodeType === 1) {
// Skip morphing focused input to preserve user's in-progress edits
if (oldNode === document.activeElement &&
(oldNode.tagName === "INPUT" || oldNode.tagName === "TEXTAREA" || oldNode.tagName === "SELECT")) {
_syncAttrs(oldNode, newNode); // sync non-value attrs (class, style, etc.)
return; // don't touch value or children
}
_syncAttrs(oldNode, newNode);
_morphChildren(oldNode, newNode);
}
}
function _syncAttrs(old, neu) {
// Add/update attributes from new
var newAttrs = neu.attributes;
for (var i = 0; i < newAttrs.length; i++) {
var a = newAttrs[i];
if (old.getAttribute(a.name) !== a.value)
old.setAttribute(a.name, a.value);
}
// Remove attributes not in new
var oldAttrs = old.attributes;
for (var j = oldAttrs.length - 1; j >= 0; j--) {
if (!neu.hasAttribute(oldAttrs[j].name))
old.removeAttribute(oldAttrs[j].name);
}
}
function _morphChildren(oldParent, newParent) {
var oldChildren = Array.prototype.slice.call(oldParent.childNodes);
var newChildren = Array.prototype.slice.call(newParent.childNodes);
// Build ID map of old children for keyed matching
var oldById = {};
for (var k = 0; k < oldChildren.length; k++) {
var kid = oldChildren[k];
if (kid.id) oldById[kid.id] = kid;
}
var oi = 0;
for (var ni = 0; ni < newChildren.length; ni++) {
var newChild = newChildren[ni];
var matchById = newChild.id ? oldById[newChild.id] : null;
if (matchById) {
// Keyed match — move into position if needed, then morph
if (matchById !== oldChildren[oi]) {
oldParent.insertBefore(matchById, oldChildren[oi] || null);
}
_morphDOM(matchById, newChild);
oi++;
} else if (oi < oldChildren.length) {
// Positional match — morph in place
var oldChild = oldChildren[oi];
if (oldChild.id && !newChild.id) {
// Old has ID, new doesn't — insert new before old (don't clobber keyed)
oldParent.insertBefore(newChild.cloneNode(true), oldChild);
} else {
_morphDOM(oldChild, newChild);
oi++;
}
} else {
// Extra new children — append
oldParent.appendChild(newChild.cloneNode(true));
}
}
// Remove leftover old children
while (oi < oldChildren.length) {
var leftover = oldChildren[oi];
if (leftover.parentNode === oldParent) oldParent.removeChild(leftover);
oi++;
}
}
// ---- DOM-native swap engine --------------------------------------------
/**
* Swap using live DOM nodes (from Sx.render) instead of HTML strings.
* Uses _morphDOM for innerHTML/outerHTML to preserve state.
*/
function _swapDOM(target, newNodes, strategy) {
// newNodes is a DocumentFragment, Element, or Text node
var wrapper;
switch (strategy) {
case "innerHTML":
// Morph children of target to match newNodes
if (newNodes.nodeType === 11) {
// DocumentFragment — morph its children into target
_morphChildren(target, newNodes);
} else {
wrapper = document.createElement("div");
wrapper.appendChild(newNodes);
_morphChildren(target, wrapper);
}
break;
case "outerHTML":
var parent = target.parentNode;
if (newNodes.nodeType === 11) {
// Fragment — morph first child, insert rest
var first = newNodes.firstChild;
if (first) {
_morphDOM(target, first);
var sib = first.nextSibling; // skip first (used as morph template, not consumed)
while (sib) {
var next = sib.nextSibling;
parent.insertBefore(sib, target.nextSibling);
sib = next;
}
} else {
parent.removeChild(target);
}
} else {
_morphDOM(target, newNodes);
}
_activateScripts(parent);
Sx.processScripts(parent);
Sx.hydrate(parent);
SxEngine.process(parent);
return; // early return like existing outerHTML
case "afterend":
target.parentNode.insertBefore(newNodes, target.nextSibling);
break;
case "beforeend":
target.appendChild(newNodes);
break;
case "afterbegin":
target.insertBefore(newNodes, target.firstChild);
break;
case "beforebegin":
target.parentNode.insertBefore(newNodes, target);
break;
case "delete":
target.parentNode.removeChild(target);
return;
default: // fallback = innerHTML
if (newNodes.nodeType === 11) {
_morphChildren(target, newNodes);
} else {
wrapper = document.createElement("div");
wrapper.appendChild(newNodes);
_morphChildren(target, wrapper);
}
}
_activateScripts(target);
Sx.processScripts(target);
Sx.hydrate(target);
SxEngine.process(target);
}
// ---- Swap engine (string-based, kept as fallback) ----------------------
/** Scripts inserted via innerHTML/insertAdjacentHTML don't execute.
* Recreate them as live elements so the browser fetches & runs them. */
@@ -1942,26 +2165,44 @@
return resp.text();
}).then(function (text) {
// Strip and load any <script type="text/sx" data-components> blocks
var hadScript = false;
text = text.replace(/<script[^>]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi,
function (_, defs) { hadScript = true; Sx.loadComponents(defs); return ""; });
if (hadScript) text = text.trim();
function (_, defs) { Sx.loadComponents(defs); return ""; });
text = text.trim();
if (text.charAt(0) === "(") {
try { text = Sx.renderToString(text); } catch (e) {}
}
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
var newMain = doc.getElementById("main-panel");
if (newMain) {
main.innerHTML = newMain.innerHTML;
_activateScripts(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0);
// sx response — render to live DOM, morph into main
try {
var popDom = Sx.render(text);
var popContainer = document.createElement("div");
popContainer.appendChild(popDom);
var newMain = popContainer.querySelector("#main-panel");
_morphChildren(main, newMain || popContainer);
_activateScripts(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0);
} catch (err) {
console.error("sx.js popstate render error [v2]:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)");
location.reload();
}
} else {
location.reload();
// HTML response — parse and morph
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
var newMain = doc.getElementById("main-panel");
if (newMain) {
_morphChildren(main, newMain);
_activateScripts(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0);
} else {
location.reload();
}
}
}).catch(function () {
location.reload();
@@ -2038,7 +2279,7 @@
// Auto-init in browser
// =========================================================================
Sx.VERSION = "2026-03-01a";
Sx.VERSION = "2026-03-01b-debug";
if (typeof document !== "undefined") {
var init = function () {