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

@@ -1,67 +0,0 @@
;; 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"))))

View File

@@ -32,6 +32,12 @@ from shared.sx.helpers import (
load_service_components(os.path.dirname(os.path.dirname(__file__)))
def _ctx_csrf(ctx: dict) -> str:
"""Get CSRF token from context, handling Jinja callable globals."""
val = ctx.get("csrf_token", "")
return val() if callable(val) else val
# ---------------------------------------------------------------------------
# OOB header helper — delegates to shared
# ---------------------------------------------------------------------------
@@ -269,7 +275,7 @@ def _blog_card_sx(post: dict, ctx: dict) -> str:
if user:
kwargs["liked"] = post.get("is_liked", False)
kwargs["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
kwargs["csrf_token"] = ctx.get("csrf_token", "")
kwargs["csrf_token"] = _ctx_csrf(ctx)
if tags:
kwargs["tags"] = tags
@@ -692,7 +698,7 @@ def _post_main_panel_sx(ctx: dict) -> str:
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
like_sx = sx_call("blog-detail-like",
like_url=like_url,
hx_headers=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}',
hx_headers=f'{{"X-CSRFToken": "{_ctx_csrf(ctx)}"}}',
heart="\u2764\ufe0f" if liked else "\U0001f90d",
)
@@ -790,7 +796,7 @@ def _settings_main_panel_sx(ctx: dict) -> str:
def _cache_main_panel_sx(ctx: dict) -> str:
from quart import url_for as qurl
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
clear_url = qurl("settings.cache_clear")
return sx_call("blog-cache-panel", clear_url=clear_url, csrf=csrf)
@@ -810,7 +816,7 @@ def _snippets_list_sx(ctx: dict) -> str:
snippets = ctx.get("snippets") or []
is_admin = ctx.get("is_admin", False)
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
user = getattr(g, "user", None)
user_id = getattr(user, "id", None)
@@ -881,7 +887,7 @@ def _menu_items_list_sx(ctx: dict) -> str:
from quart import url_for as qurl
menu_items = ctx.get("menu_items") or []
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
if not menu_items:
return sx_call("blog-menu-items-empty")
@@ -919,7 +925,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
groups = ctx.get("groups") or []
unassigned_tags = ctx.get("unassigned_tags") or []
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
create_url = qurl("blog.tag_groups_admin.create")
form_sx = sx_call("blog-tag-groups-create-form",
@@ -979,7 +985,7 @@ def _tag_groups_edit_main_panel_sx(ctx: dict) -> str:
group = ctx.get("group")
all_tags = ctx.get("all_tags") or []
assigned_tag_ids = ctx.get("assigned_tag_ids") or set()
csrf = ctx.get("csrf_token", "")
csrf = _ctx_csrf(ctx)
g_id = getattr(group, "id", None) or group.get("id") if group else None
g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "")
@@ -1046,12 +1052,12 @@ async def render_home_page(ctx: dict) -> str:
async def render_home_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
post_oob = _oob_header_sx("root-header-child", "post-header-child",
_post_header_sx(ctx))
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _home_main_panel_sx(ctx)
oobs = "(<> " + root_hdr + " " + post_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
return oob_page_sx(oobs=header_oob, content=content)
# ---- Blog index ----
@@ -1068,15 +1074,15 @@ async def render_blog_page(ctx: dict) -> str:
async def render_blog_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
blog_oob = _oob_header_sx("root-header-child", "blog-header-child",
_blog_header_sx(ctx))
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
content = _blog_main_panel_sx(ctx)
aside = _blog_aside_sx(ctx)
filter_sx = _blog_filter_sx(ctx)
nav = ctx.get("nav_sx", "") or ""
oobs = "(<> " + root_hdr + " " + blog_oob + ")"
return oob_page_sx(oobs=oobs, content=content, aside=aside,
return oob_page_sx(oobs=header_oob, content=content, aside=aside,
filter=filter_sx, menu=nav)
@@ -1136,7 +1142,9 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
# Editor JS + init script
init_js = (
"console.log('[EDITOR-DEBUG] init script running');\n"
"(function() {\n"
" console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n"
" function applyEditorFontSize() {\n"
" document.documentElement.style.fontSize = '62.5%';\n"
" document.body.style.fontSize = '1.6rem';\n"
@@ -1282,11 +1290,12 @@ async def render_new_post_page(ctx: dict) -> str:
async def render_new_post_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
blog_oob = _blog_header_sx(ctx, oob=True)
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
content = ctx.get("editor_html", "")
oobs = "(<> " + root_hdr + " " + blog_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
return oob_page_sx(oobs=header_oob, content=content)
# ---- Post detail ----
@@ -1303,12 +1312,13 @@ async def render_post_page(ctx: dict) -> str:
async def render_post_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
post_oob = _oob_header_sx("root-header-child", "post-header-child",
_post_header_sx(ctx))
root_hdr = root_header_sx(ctx) # non-OOB (nested inside root-header-child)
post_hdr = _post_header_sx(ctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _post_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
oobs = "(<> " + root_hdr + " " + post_oob + ")"
oobs = post_oob
return oob_page_sx(oobs=oobs, content=content, menu=menu)
@@ -1318,7 +1328,7 @@ async def render_post_admin_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _post_admin_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
return full_page_sx(ctx, header_rows=header_rows, content=content,
@@ -1341,7 +1351,7 @@ async def render_post_data_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="data")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("data_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1358,7 +1368,7 @@ async def render_post_entries_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="entries")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("entries_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1382,7 +1392,7 @@ async def render_post_edit_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="edit")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("edit_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1399,7 +1409,7 @@ async def render_post_settings_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="settings")
header_rows = "(<> " + root_hdr + " " + post_hdr + ")" + admin_hdr
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("settings_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1423,13 +1433,13 @@ async def render_settings_page(ctx: dict) -> str:
async def render_settings_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx, oob=True)
settings_oob = _oob_header_sx("root-header-child", "root-settings-header-child",
_settings_header_sx(ctx))
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "root-settings-header-child", rows)
content = _settings_main_panel_sx(ctx)
menu = _settings_nav_sx(ctx)
oobs = "(<> " + root_hdr + " " + settings_oob + ")"
return oob_page_sx(oobs=oobs, content=content, menu=menu)
return oob_page_sx(oobs=header_oob, content=content, menu=menu)
# ---- Cache ----
@@ -1442,7 +1452,7 @@ async def render_cache_page(ctx: dict) -> str:
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + cache_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + cache_hdr + ")"
content = _cache_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1471,7 +1481,7 @@ async def render_snippets_page(ctx: dict) -> str:
"snippets-row", "snippets-header-child",
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + snippets_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + snippets_hdr + ")"
content = _snippets_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1500,7 +1510,7 @@ async def render_menu_items_page(ctx: dict) -> str:
"menu_items-row", "menu_items-header-child",
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + mi_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + mi_hdr + ")"
content = _menu_items_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1529,7 +1539,7 @@ async def render_tag_groups_page(ctx: dict) -> str:
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + tg_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
@@ -1559,7 +1569,7 @@ async def render_tag_group_edit_page(ctx: dict) -> str:
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" + tg_hdr
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_edit_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)