- 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>
474 lines
18 KiB
Python
474 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 = ctx.get("is_admin_section") or "/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(f"(<> {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(f"(<> {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(f"(<> {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
|
|
# Validate the sx source parses as a single expression
|
|
try:
|
|
from .parser import parse as _parse_check
|
|
_parse_check(source)
|
|
except Exception as _e:
|
|
import logging
|
|
logging.getLogger("sx").error("sx_response parse error: %s\nSource (first 500): %s", _e, source[:500])
|
|
|
|
# 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")
|
|
resp.headers["X-SX-Body-Len"] = str(len(body))
|
|
resp.headers["X-SX-Source-Len"] = str(len(source))
|
|
resp.headers["X-SX-Has-Defs"] = "1" if "<script" in body else "0"
|
|
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?plugins=typography"></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("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """))
|