Fix SX history, OOB header swaps, cross-service nav components
- Always re-fetch on popstate (drop LRU cache) for fresh content on back/forward - Save/restore scroll position via pushState - Add id="root-header-child" to ~app-body so OOB swaps can target it - Fix OOB renderers: nest root-row inside root-header-child swap instead of separate OOB that clobbers it - Fix 3+ header rows dropped: wrap all headers in single fragment instead of concatenating outside (<> ...) - Strip <script data-components> from text/sx responses before renderToString - Fall back to location.assign for cross-origin pushState (SecurityError) - Move blog/sx/nav.sx to shared/sx/templates/ so all services have nav components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,7 +122,7 @@
|
||||
this._advance(m[0].length);
|
||||
var raw = m[0].slice(1, -1);
|
||||
return raw.replace(/\\n/g, "\n").replace(/\\t/g, "\t")
|
||||
.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
.replace(/\\"/g, '"').replace(/\\[/]/g, "/").replace(/\\\\/g, "\\");
|
||||
}
|
||||
|
||||
// Keyword
|
||||
@@ -1115,8 +1115,14 @@
|
||||
}
|
||||
var open = "<" + tag + attrs.join("") + ">";
|
||||
if (VOID_ELEMENTS[tag]) return open;
|
||||
var isRawText = (tag === "script" || tag === "style");
|
||||
var inner = [];
|
||||
for (var ci = 0; ci < children.length; ci++) inner.push(renderStr(children[ci], env));
|
||||
for (var ci = 0; ci < children.length; ci++) {
|
||||
var child = children[ci];
|
||||
if (isRawText && typeof child === "string") inner.push(child);
|
||||
else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env)));
|
||||
else inner.push(renderStr(child, env));
|
||||
}
|
||||
return open + inner.join("") + "</" + tag + ">";
|
||||
}
|
||||
|
||||
@@ -1413,7 +1419,7 @@
|
||||
var PROCESSED = "_sxBound";
|
||||
var VERBS = ["get", "post", "put", "delete", "patch"];
|
||||
var DEFAULT_SWAP = "outerHTML";
|
||||
var HISTORY_MAX = 20;
|
||||
|
||||
|
||||
function dispatch(el, name, detail) {
|
||||
var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} });
|
||||
@@ -1627,7 +1633,12 @@
|
||||
// Check for text/sx content type
|
||||
var ct = resp.headers.get("Content-Type") || "";
|
||||
if (ct.indexOf("text/sx") >= 0) {
|
||||
try { text = Sx.renderToString(text); }
|
||||
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);
|
||||
return;
|
||||
@@ -1696,10 +1707,15 @@
|
||||
|
||||
// History
|
||||
var pushUrl = el.getAttribute("sx-push-url");
|
||||
if (pushUrl === "true") {
|
||||
history.pushState({ sxUrl: url }, "", url);
|
||||
} else if (pushUrl && pushUrl !== "false") {
|
||||
history.pushState({ sxUrl: pushUrl }, "", pushUrl);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(el, "sx:afterSwap", { target: target });
|
||||
@@ -1905,39 +1921,12 @@
|
||||
|
||||
// ---- History manager --------------------------------------------------
|
||||
|
||||
var _historyCache = {};
|
||||
var _historyCacheKeys = [];
|
||||
|
||||
function _cacheCurrentPage() {
|
||||
var key = location.href;
|
||||
var main = document.getElementById("main-panel");
|
||||
if (!main) return;
|
||||
_historyCache[key] = main.innerHTML;
|
||||
// LRU eviction
|
||||
var idx = _historyCacheKeys.indexOf(key);
|
||||
if (idx >= 0) _historyCacheKeys.splice(idx, 1);
|
||||
_historyCacheKeys.push(key);
|
||||
while (_historyCacheKeys.length > HISTORY_MAX) {
|
||||
delete _historyCache[_historyCacheKeys.shift()];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("popstate", function (e) {
|
||||
var url = location.href;
|
||||
// Try cache first
|
||||
if (_historyCache[url]) {
|
||||
var main = document.getElementById("main-panel");
|
||||
if (main) {
|
||||
main.innerHTML = _historyCache[url];
|
||||
Sx.processScripts(main);
|
||||
Sx.hydrate(main);
|
||||
SxEngine.process(main);
|
||||
dispatch(document.body, "sx:afterSettle", { target: main });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fetch fresh
|
||||
var main = document.getElementById("main-panel");
|
||||
if (!main) { location.reload(); return; }
|
||||
|
||||
var histOpts = {
|
||||
headers: { "SX-Request": "true", "SX-History-Restore": "true" }
|
||||
};
|
||||
@@ -1948,24 +1937,31 @@
|
||||
histOpts.credentials = "include";
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
fetch(url, histOpts).then(function (resp) {
|
||||
return resp.text();
|
||||
}).then(function (text) {
|
||||
var ct = "";
|
||||
// Response content-type is lost here, check for sx
|
||||
// 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();
|
||||
if (text.charAt(0) === "(") {
|
||||
try { text = Sx.renderToString(text); } catch (e) { /* not sx */ }
|
||||
try { text = Sx.renderToString(text); } catch (e) {}
|
||||
}
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(text, "text/html");
|
||||
var newMain = doc.getElementById("main-panel");
|
||||
var main = document.getElementById("main-panel");
|
||||
if (main && newMain) {
|
||||
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);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}).catch(function () {
|
||||
location.reload();
|
||||
@@ -2057,10 +2053,7 @@
|
||||
init();
|
||||
}
|
||||
|
||||
// Cache current page before navigation
|
||||
document.addEventListener("sx:beforeRequest", function () {
|
||||
if (typeof SxEngine._cacheCurrentPage === "function") SxEngine._cacheCurrentPage();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -209,9 +209,13 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
|
||||
|
||||
|
||||
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
|
||||
"""Wrap a header row sx in an OOB swap."""
|
||||
"""Wrap a header row sx in an OOB swap.
|
||||
|
||||
child_id is accepted for call-site compatibility but no longer used —
|
||||
the child placeholder is created by ~menu-row-sx itself.
|
||||
"""
|
||||
return sx_call("oob-header-sx",
|
||||
parent_id=parent_id, child_id=child_id,
|
||||
parent_id=parent_id,
|
||||
row=SxExpr(row_sx),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
(header :class "z-50"
|
||||
(div :id "root-header-summary"
|
||||
:class "flex items-start gap-2 p-1 bg-sky-500"
|
||||
(div :class "flex flex-col w-full items-center"
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
(when header-rows header-rows)))))
|
||||
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(when menu menu))))
|
||||
@@ -107,13 +107,12 @@
|
||||
(div :id child-id :class "flex flex-col w-full items-center"
|
||||
(when child child))))))
|
||||
|
||||
(defcomp ~oob-header-sx (&key parent-id child-id row)
|
||||
(div :id parent-id :sx-swap-oob "outerHTML" :class "w-full"
|
||||
(div :class "w-full" row
|
||||
(div :id child-id))))
|
||||
(defcomp ~oob-header-sx (&key parent-id row)
|
||||
(div :id parent-id :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
|
||||
row))
|
||||
|
||||
(defcomp ~header-child-sx (&key id inner)
|
||||
(div :id (or id "root-header-child") :class "w-full" inner))
|
||||
(div :id (or id "root-header-child") :class "flex flex-col w-full items-center" inner))
|
||||
|
||||
(defcomp ~error-content (&key errnum message image)
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
|
||||
67
shared/sx/templates/nav.sx
Normal file
67
shared/sx/templates/nav.sx
Normal file
@@ -0,0 +1,67 @@
|
||||
;; Blog navigation components
|
||||
|
||||
(defcomp ~blog-nav-empty (&key wrapper-id)
|
||||
(div :id wrapper-id :sx-swap-oob "outerHTML"))
|
||||
|
||||
(defcomp ~blog-nav-item-image (&key src label)
|
||||
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
|
||||
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:aria-selected selected :class nav-cls
|
||||
img (span label))))
|
||||
|
||||
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
|
||||
(div (a :href href :aria-selected selected :class nav-cls
|
||||
img (span label))))
|
||||
|
||||
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "menu-items-nav-wrapper" :sx-swap-oob "outerHTML"
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll left"
|
||||
:_ left-hs (i :class "fa fa-chevron-left"))
|
||||
(div :id container-id
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll right"
|
||||
:_ right-hs (i :class "fa fa-chevron-right"))))
|
||||
|
||||
;; Nav entries
|
||||
|
||||
(defcomp ~blog-nav-entries-empty ()
|
||||
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
|
||||
|
||||
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
|
||||
(a :href href :class nav-cls
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||
|
||||
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
|
||||
(a :href href :class nav-cls
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
|
||||
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items)
|
||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
:id "entries-calendars-nav-wrapper" :sx-swap-oob "true"
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll left"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
|
||||
(i :class "fa fa-chevron-left"))
|
||||
(div :id "associated-items-container"
|
||||
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
:aria-label "Scroll right"
|
||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
||||
(i :class "fa fa-chevron-right"))))
|
||||
Reference in New Issue
Block a user