Files
mono/shared/sx/helpers.py
giles b54f7b4b56 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>
2026-03-01 17:17:39 +00:00

463 lines
18 KiB
Python

"""
Shared helper functions for s-expression page rendering.
These are used by per-service sx_components.py files to build common
page elements (headers, search, etc.) from template context.
"""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr
def call_url(ctx: dict, key: str, path: str = "/") -> str:
"""Call a URL helper from context (e.g., blog_url, account_url)."""
fn = ctx.get(key)
if callable(fn):
return fn(path)
return str(fn or "") + path
def get_asset_url(ctx: dict) -> str:
"""Extract the asset URL base from context."""
au = ctx.get("asset_url")
if callable(au):
result = au("")
return result.rsplit("/", 1)[0] if "/" in result else result
return au or ""
# ---------------------------------------------------------------------------
# Sx-native helper functions — return sx source (not HTML)
# ---------------------------------------------------------------------------
def _as_sx(val: Any) -> SxExpr | None:
"""Coerce a fragment value to SxExpr.
If *val* is already a ``SxExpr`` (from a ``text/sx`` fragment),
return it as-is. If it's a non-empty string (HTML from a
``text/html`` fragment), wrap it in ``~rich-text``. Otherwise
return ``None``.
"""
if not val:
return None
if isinstance(val, SxExpr):
return val
html = str(val)
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
return SxExpr(f'(~rich-text :html "{escaped}")')
def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as a sx call string."""
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return sx_call("header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
app_label=ctx.get("app_label", ""),
nav_tree=_as_sx(ctx.get("nav_tree")),
auth_menu=_as_sx(ctx.get("auth_menu")),
nav_panel=_as_sx(ctx.get("nav_panel")),
settings_url=settings_url,
is_admin=is_admin,
oob=oob,
)
def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string."""
return sx_call("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
hx_select=ctx.get("hx_select", "#main-panel"),
search_headers_mobile=SEARCH_HEADERS_MOBILE,
)
def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx call string."""
return sx_call("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
hx_select=ctx.get("hx_select", "#main-panel"),
search_headers_desktop=SEARCH_HEADERS_DESKTOP,
)
def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row as sx call string."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
nav_parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(sx_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
container_nav = ctx.get("container_nav")
if container_nav:
nav_parts.append(
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
# Admin cog
admin_nav = ctx.get("post_admin_nav")
if not admin_nav:
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin and slug:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
if admin_nav:
nav_parts.append(admin_nav)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
child_id="post-header-child",
oob=oob, external=True,
)
def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sx call string."""
# Label
label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"']
if selected:
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
# Nav items
select_colours = ctx.get("select_colours", "")
base_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded bg-stone-200 text-black p-3")
selected_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded !bg-stone-500 !text-white p-3")
nav_parts: list[str] = []
items = [
("events_url", f"/{slug}/admin/", "calendars"),
("market_url", f"/{slug}/admin/", "markets"),
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
for url_key, path, label in items:
url_fn = ctx.get(url_key)
if not callable(url_fn):
continue
href = url_fn(path)
is_sel = label == selected
cls = selected_cls if is_sel else base_cls
aria = "true" if is_sel else None
nav_parts.append(
f'(div :class "relative nav-group"'
f' (a :href "{escape(href)}"'
+ (f' :aria-selected "true"' if aria else "")
+ f' :class "{cls} {escape(select_colours)}"'
+ f' "{escape(label)}"))'
)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
if not admin_href:
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return sx_call("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
child_id="post-admin-header-child", oob=oob,
)
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""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,
row=SxExpr(row_sx),
)
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return sx_call("header-child-sx",
id=id, inner=SxExpr(inner_sx),
)
def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx call string."""
return sx_call("oob-sx",
oobs=SxExpr(oobs) if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
content=SxExpr(content) if content else None,
)
def full_page_sx(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
"""Build a full page using sx_page() with ~app-body.
meta_html: raw HTML injected into the <head> shell (legacy).
meta: sx source for meta tags — auto-hoisted to <head> by sx.js.
"""
body_sx = sx_call("app-body",
header_rows=SxExpr(header_rows) if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
content=SxExpr(content) if content else None,
)
if meta:
# Wrap body + meta in a fragment so sx.js renders both;
# auto-hoist moves meta/title/link elements to <head>.
body_sx = "(<> " + meta + " " + body_sx + ")"
return sx_page(ctx, body_sx, meta_html=meta_html)
def sx_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
Converts snake_case to kebab-case automatically::
sx_call("test-row", nodeid="foo", outcome="passed")
# => '(~test-row :nodeid "foo" :outcome "passed")'
Values are serialized: strings are quoted, None becomes nil,
bools become true/false, numbers stay as-is.
"""
from .parser import serialize
name = component_name if component_name.startswith("~") else f"~{component_name}"
parts = [name]
for key, val in kwargs.items():
parts.append(f":{key.replace('_', '-')}")
parts.append(serialize(val))
return "(" + " ".join(parts) + ")"
def components_for_request() -> str:
"""Return defcomp source for components the client doesn't have yet.
Reads the ``SX-Components`` header (comma-separated component names
like ``~card,~nav-item``) and returns only the definitions the client
is missing. If the header is absent, returns all component defs.
"""
from quart import request
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
from .types import Component
from .parser import serialize
loaded_raw = request.headers.get("SX-Components", "")
if not loaded_raw:
# Client has nothing — send all
tag = client_components_tag()
if not tag:
return ""
start = tag.find(">") + 1
end = tag.rfind("</script>")
return tag[start:end] if start > 0 and end > start else ""
loaded = set(loaded_raw.split(","))
parts = []
for key, val in _COMPONENT_ENV.items():
if not isinstance(val, Component):
continue
# Skip components the client already has
if f"~{val.name}" in loaded or val.name in loaded:
continue
# Reconstruct defcomp source
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
return "\n".join(parts)
def sx_response(source_or_component: str, status: int = 200,
headers: dict | None = None, **kwargs: Any):
"""Return an s-expression wire-format response.
Can be called with a raw sx string::
return sx_response('(~test-row :nodeid "foo")')
Or with a component name + kwargs (builds the sx call)::
return sx_response("test-row", nodeid="foo", outcome="passed")
For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content.
"""
from quart import request, Response
if kwargs:
source = sx_call(source_or_component, **kwargs)
else:
source = source_or_component
body = source
# For SX requests, prepend missing component definitions
if request.headers.get("SX-Request"):
comp_defs = components_for_request()
if comp_defs:
body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}')
resp = Response(body, status=status, content_type="text/sx")
if headers:
for k, v in headers.items():
resp.headers[k] = v
return resp
# ---------------------------------------------------------------------------
# Sx wire-format full page shell
# ---------------------------------------------------------------------------
_SX_PAGE_TEMPLATE = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#ffffff">
<title>{title}</title>
{meta_html}
<style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style>
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/basics.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/cards.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/blog-content.css">
<meta name="csrf-token" content="{csrf}">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{asset_url}/fontawesome/css/all.min.css">
<link rel="stylesheet" href="{asset_url}/fontawesome/css/v4-shims.min.css">
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet">
<script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){{document.documentElement.classList.add('hover-capable')}}</script>
<script>document.addEventListener('click',function(e){{var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')}})</script>
<style>
details[data-toggle-group="mobile-panels"]>summary{{list-style:none}}
details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{{display:none}}
@media(min-width:768px){{.nav-group:focus-within .submenu,.nav-group:hover .submenu{{display:block}}}}
img{{max-width:100%;height:auto}}
.clamp-2{{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}
.clamp-3{{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}}
.no-scrollbar::-webkit-scrollbar{{display:none}}.no-scrollbar{{-ms-overflow-style:none;scrollbar-width:none}}
details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.group>summary::-webkit-details-marker{{display:none}}
.sx-indicator{{display:none}}.sx-request .sx-indicator{{display:inline-flex}}
.sx-error .sx-indicator{{display:none}}.sx-loading .sx-indicator{{display:inline-flex}}
.js-wrap.open .js-pop{{display:block}}.js-wrap.open .js-backdrop{{display:block}}
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sx" data-components>{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js"></script>
<script src="{asset_url}/scripts/body.js"></script>
</body>
</html>"""
def sx_page(ctx: dict, page_sx: str, *,
meta_html: str = "") -> str:
"""Return a minimal HTML shell that boots the page from sx source.
The browser loads component definitions and page sx, then sx.js
renders everything client-side.
"""
from .jinja_bridge import client_components_tag
components_tag = client_components_tag()
# Extract just the inner source from the <script> tag
component_defs = ""
if components_tag:
# Strip <script type="text/sx" data-components>...</script>
start = components_tag.find(">") + 1
end = components_tag.rfind("</script>")
if start > 0 and end > start:
component_defs = components_tag[start:end]
asset_url = get_asset_url(ctx)
title = ctx.get("base_title", "Rose Ash")
csrf = _get_csrf_token()
return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
meta_html=meta_html,
csrf=_html_escape(csrf),
component_defs=component_defs,
page_sx=page_sx,
)
def _get_csrf_token() -> str:
"""Get the CSRF token from the current request context."""
try:
from quart import g
return getattr(g, "csrf_token", "")
except Exception:
return ""
def _html_escape(s: str) -> str:
"""Minimal HTML escaping for attribute values."""
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))