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:
2026-03-01 17:17:39 +00:00
parent 5ede32e21c
commit b54f7b4b56
5 changed files with 101 additions and 95 deletions

View File

@@ -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();
});
}

View File

@@ -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),
)

View File

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

View 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"))))