Refactor SX templates: shared components, Python migration, cleanup
- 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:
11
shared/static/scripts/editor.css
Normal file
11
shared/static/scripts/editor.css
Normal file
File diff suppressed because one or more lines are too long
1872
shared/static/scripts/editor.js
Normal file
1872
shared/static/scripts/editor.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user