Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,8 @@ from typing import Any
|
||||
|
||||
from markupsafe import escape
|
||||
|
||||
from .jinja_bridge import render
|
||||
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||
from .parser import SexpExpr
|
||||
|
||||
|
||||
def call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||
@@ -31,30 +31,33 @@ def get_asset_url(ctx: dict) -> str:
|
||||
return au or ""
|
||||
|
||||
|
||||
def root_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the root header row HTML."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sexp-native helper functions — return sexp source (not HTML)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 render(
|
||||
"header-row",
|
||||
cart_mini_html=ctx.get("cart_mini_html", ""),
|
||||
return sexp_call("header-row-sx",
|
||||
cart_mini=ctx.get("cart_mini") and SexpExpr(str(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_html=ctx.get("nav_tree_html", ""),
|
||||
auth_menu_html=ctx.get("auth_menu_html", ""),
|
||||
nav_panel_html=ctx.get("nav_panel_html", ""),
|
||||
nav_tree=ctx.get("nav_tree") and SexpExpr(str(ctx.get("nav_tree"))),
|
||||
auth_menu=ctx.get("auth_menu") and SexpExpr(str(ctx.get("auth_menu"))),
|
||||
nav_panel=ctx.get("nav_panel") and SexpExpr(str(ctx.get("nav_panel"))),
|
||||
settings_url=settings_url,
|
||||
is_admin=is_admin,
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def search_mobile_html(ctx: dict) -> str:
|
||||
"""Build mobile search input HTML."""
|
||||
return render(
|
||||
"search-mobile",
|
||||
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", ""),
|
||||
@@ -63,10 +66,9 @@ def search_mobile_html(ctx: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def search_desktop_html(ctx: dict) -> str:
|
||||
"""Build desktop search input HTML."""
|
||||
return render(
|
||||
"search-desktop",
|
||||
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", ""),
|
||||
@@ -75,8 +77,8 @@ def search_desktop_html(ctx: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the post-level header row (level 1). Used by all apps + error pages."""
|
||||
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:
|
||||
@@ -84,25 +86,24 @@ def post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
title = (post.get("title") or "")[:160]
|
||||
feature_image = post.get("feature_image")
|
||||
|
||||
label_html = render("post-label", feature_image=feature_image, title=title)
|
||||
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(render("page-cart-badge", href=cart_href, count=str(page_cart_count)))
|
||||
nav_parts.append(sexp_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
|
||||
|
||||
container_nav = ctx.get("container_nav_html", "")
|
||||
container_nav = ctx.get("container_nav")
|
||||
if container_nav:
|
||||
nav_parts.append(
|
||||
'<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">'
|
||||
f'{container_nav}</div>'
|
||||
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 — external link to blog admin (generic across all services)
|
||||
admin_nav = ctx.get("post_admin_nav_html", "")
|
||||
# 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)
|
||||
@@ -114,39 +115,37 @@ def post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
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="{escape(admin_href)}"'
|
||||
f' class="{base_cls} {sel_cls}">'
|
||||
f'<i class="fa fa-cog" aria-hidden="true"></i></a></div>'
|
||||
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_html = "".join(nav_parts)
|
||||
nav_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
|
||||
link_href = call_url(ctx, "blog_url", f"/{slug}/")
|
||||
|
||||
return render("menu-row",
|
||||
return sexp_call("menu-row-sx",
|
||||
id="post-row", level=1,
|
||||
link_href=link_href, link_label_html=label_html,
|
||||
nav_html=nav_html, child_id="post-header-child",
|
||||
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_html(ctx: dict, slug: str, *, oob: bool = False,
|
||||
selected: str = "", admin_href: str = "") -> str:
|
||||
"""Shared post admin header row with unified nav across all services.
|
||||
|
||||
Shows: calendars | markets | payments | entries | data | edit | settings
|
||||
All links are external (cross-service). The *selected* item is
|
||||
highlighted on the nav and shown in white next to the admin label.
|
||||
"""
|
||||
# Label: shield icon + "admin" + optional selected sub-page in white
|
||||
label_html = '<i class="fa fa-shield-halved" aria-hidden="true"></i> admin'
|
||||
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_html += f' <span class="text-white">{escape(selected)}</span>'
|
||||
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
|
||||
label_sexp = "(<> " + " ".join(label_parts) + ")"
|
||||
|
||||
# Nav items — all external links to the appropriate service
|
||||
# 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")
|
||||
@@ -169,75 +168,117 @@ def post_admin_header_html(ctx: dict, slug: str, *, oob: bool = False,
|
||||
href = url_fn(path)
|
||||
is_sel = label == selected
|
||||
cls = selected_cls if is_sel else base_cls
|
||||
aria = ' aria-selected="true"' if is_sel else ""
|
||||
aria = "true" if is_sel else None
|
||||
nav_parts.append(
|
||||
f'<div class="relative nav-group">'
|
||||
f'<a href="{escape(href)}"{aria}'
|
||||
f' class="{cls} {escape(select_colours)}">'
|
||||
f'{escape(label)}</a></div>'
|
||||
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_html = "".join(nav_parts)
|
||||
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 render("menu-row",
|
||||
return sexp_call("menu-row-sx",
|
||||
id="post-admin-row", level=2,
|
||||
link_href=admin_href, link_label_html=label_html,
|
||||
nav_html=nav_html, child_id="post-admin-header-child", oob=oob,
|
||||
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_html(parent_id: str, child_id: str, row_html: str) -> str:
|
||||
"""Wrap a header row in an OOB swap div with child placeholder."""
|
||||
return render("oob-header",
|
||||
parent_id=parent_id, child_id=child_id, row_html=row_html,
|
||||
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_html(inner_html: str, *, id: str = "root-header-child") -> str:
|
||||
"""Wrap inner HTML in a header-child div."""
|
||||
return render("header-child", id=id, inner_html=inner_html)
|
||||
|
||||
|
||||
def error_content_html(errnum: str, message: str, image: str | None = None) -> str:
|
||||
"""Render the error content block."""
|
||||
return render("error-content", errnum=errnum, message=message, image=image)
|
||||
|
||||
|
||||
def full_page(ctx: dict, *, header_rows_html: str,
|
||||
filter_html: str = "", aside_html: str = "",
|
||||
content_html: str = "", menu_html: str = "",
|
||||
body_end_html: str = "", meta_html: str = "") -> str:
|
||||
"""Render a full app page with the standard layout."""
|
||||
return render(
|
||||
"app-layout",
|
||||
title=ctx.get("base_title", "Rose Ash"),
|
||||
asset_url=get_asset_url(ctx),
|
||||
meta_html=meta_html,
|
||||
header_rows_html=header_rows_html,
|
||||
menu_html=menu_html,
|
||||
filter_html=filter_html,
|
||||
aside_html=aside_html,
|
||||
content_html=content_html,
|
||||
body_end_html=body_end_html,
|
||||
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 sexp_response(sexp_source: str, status: int = 200,
|
||||
headers: dict | None = None):
|
||||
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.
|
||||
|
||||
The client-side sexp.js will intercept responses with Content-Type
|
||||
text/sexp and render them before HTMX swaps the result in.
|
||||
Can be called with a raw sexp string::
|
||||
|
||||
Usage in a route handler::
|
||||
return sexp_response('(~test-row :nodeid "foo")')
|
||||
|
||||
return sexp_response('(~test-row :nodeid "test_foo" :outcome "passed")')
|
||||
Or with a component name + kwargs (builds the sexp call)::
|
||||
|
||||
return sexp_response("test-row", nodeid="foo", outcome="passed")
|
||||
"""
|
||||
from quart import Response
|
||||
resp = Response(sexp_source, status=status, content_type="text/sexp")
|
||||
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
|
||||
@@ -256,3 +297,105 @@ def oob_page(ctx: dict, *, oobs_html: str = "",
|
||||
menu_html=menu_html,
|
||||
content_html=content_html,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
Reference in New Issue
Block a user