Files
rose-ash/shared/sexp/helpers.py
giles a643b3532d
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m43s
Phase 5 cleanup: remove legacy HTML components, fix nav-tree fragment
- Remove old raw! layout components (~app-head, ~app-layout, ~oob-response,
  ~header-row, ~menu-row, ~oob-header, ~header-child) from layout.sexp
- Convert nav-tree fragment from Jinja HTML to sexp source, fixing the
  "Unexpected character: ." parse error caused by HTML leaking into sexp
- Add _as_sexp() helper to safely coerce HTML fragments to ~rich-text
- Fix federation/sexp/search.sexpr extra closing paren
- Remove dead _html() wrappers from blog and account sexp_components
- Remove stale render import from cart sexp_components
- Add dev_watcher.py to auto-reload on .sexp/.sexpr/.js/.css changes
- Add test_parse_all.py to parse-check all 59 sexpr/sexp files
- Fix test assertions for sx- attribute prefix (was hx-)
- Add sexp.js version logging for cache debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:12:03 +00:00

406 lines
16 KiB
Python

"""
Shared helper functions for s-expression page rendering.
These are used by per-service sexp_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 SexpExpr
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 ""
# ---------------------------------------------------------------------------
# Sexp-native helper functions — return sexp source (not HTML)
# ---------------------------------------------------------------------------
def _as_sexp(val: Any) -> SexpExpr | None:
"""Coerce a fragment value to SexpExpr.
If *val* is already a ``SexpExpr`` (from a ``text/sexp`` 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, SexpExpr):
return val
html = str(val)
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
return SexpExpr(f'(~rich-text :html "{escaped}")')
def root_header_sexp(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as a sexp 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 sexp_call("header-row-sx",
cart_mini=_as_sexp(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_sexp(ctx.get("nav_tree")),
auth_menu=_as_sexp(ctx.get("auth_menu")),
nav_panel=_as_sexp(ctx.get("nav_panel")),
settings_url=settings_url,
is_admin=is_admin,
oob=oob,
)
def search_mobile_sexp(ctx: dict) -> str:
"""Build mobile search input as sexp call string."""
return sexp_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_sexp(ctx: dict) -> str:
"""Build desktop search input as sexp call string."""
return sexp_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_sexp(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row as sexp 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_sexp = sexp_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(sexp_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_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sexp_call("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SexpExpr(label_sexp),
nav=SexpExpr(nav_sexp) if nav_sexp else None,
child_id="post-header-child",
oob=oob, external=True,
)
def post_admin_header_sexp(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sexp 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_sexp = "(<> " + " ".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_sexp = "(<> " + " ".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 sexp_call("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SexpExpr(label_sexp),
nav=SexpExpr(nav_sexp) if nav_sexp else None,
child_id="post-admin-header-child", oob=oob,
)
def oob_header_sexp(parent_id: str, child_id: str, row_sexp: str) -> str:
"""Wrap a header row sexp in an OOB swap."""
return sexp_call("oob-header-sx",
parent_id=parent_id, child_id=child_id,
row=SexpExpr(row_sexp),
)
def header_child_sexp(inner_sexp: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sexp in a header-child div."""
return sexp_call("header-child-sx",
id=id, inner=SexpExpr(inner_sexp),
)
def oob_page_sexp(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sexp call string."""
return sexp_call("oob-sexp",
oobs=SexpExpr(oobs) if oobs else None,
filter=SexpExpr(filter) if filter else None,
aside=SexpExpr(aside) if aside else None,
menu=SexpExpr(menu) if menu else None,
content=SexpExpr(content) if content else None,
)
def full_page_sexp(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
"""Build a full page using sexp_page() with ~app-body.
meta_html: raw HTML injected into the <head> shell (legacy).
meta: sexp source for meta tags — auto-hoisted to <head> by sexp.js.
"""
body_sexp = sexp_call("app-body",
header_rows=SexpExpr(header_rows) if header_rows else None,
filter=SexpExpr(filter) if filter else None,
aside=SexpExpr(aside) if aside else None,
menu=SexpExpr(menu) if menu else None,
content=SexpExpr(content) if content else None,
)
if meta:
# Wrap body + meta in a fragment so sexp.js renders both;
# auto-hoist moves meta/title/link elements to <head>.
body_sexp = "(<> " + meta + " " + body_sexp + ")"
return sexp_page(ctx, body_sexp, meta_html=meta_html)
def sexp_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
Converts snake_case to kebab-case automatically::
sexp_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 sexp_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 sexp string::
return sexp_response('(~test-row :nodeid "foo")')
Or with a component name + kwargs (builds the sexp call)::
return sexp_response("test-row", nodeid="foo", outcome="passed")
"""
from quart import Response
if kwargs:
source = sexp_call(source_or_component, **kwargs)
else:
source = source_or_component
resp = Response(source, status=status, content_type="text/sexp")
if headers:
for k, v in headers.items():
resp.headers[k] = v
return resp
# ---------------------------------------------------------------------------
# Sexp wire-format full page shell
# ---------------------------------------------------------------------------
_SEXP_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/sexp" data-components>{component_defs}</script>
<script type="text/sexp" data-mount="body">{page_sexp}</script>
<script src="{asset_url}/scripts/sexp.js"></script>
<script src="{asset_url}/scripts/body.js"></script>
</body>
</html>"""
def sexp_page(ctx: dict, page_sexp: str, *,
meta_html: str = "") -> str:
"""Return a minimal HTML shell that boots the page from sexp source.
The browser loads component definitions and page sexp, then sexp.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/sexp" 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 _SEXP_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
meta_html=meta_html,
csrf=_html_escape(csrf),
component_defs=component_defs,
page_sexp=page_sexp,
)
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;"))