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('"', """))
|
||||
|
||||
@@ -191,7 +191,7 @@ def _get_request_context():
|
||||
try:
|
||||
from quart import g, request
|
||||
user = getattr(g, "user", None)
|
||||
is_htmx = bool(request.headers.get("HX-Request"))
|
||||
is_htmx = bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
|
||||
return RequestContext(user=user, is_htmx=is_htmx)
|
||||
except Exception:
|
||||
return RequestContext()
|
||||
|
||||
@@ -88,7 +88,7 @@ async def render_sexp_response(source: str, **kwargs: Any) -> str:
|
||||
"""Render an s-expression with the full app template context.
|
||||
|
||||
Calls the app's registered context processors (which provide
|
||||
cart_mini_html, auth_menu_html, nav_tree_html, asset_url, etc.)
|
||||
cart_mini, auth_menu, nav_tree, asset_url, etc.)
|
||||
and merges them with the caller's kwargs before rendering.
|
||||
|
||||
Returns the rendered HTML string (caller wraps in Response as needed).
|
||||
|
||||
@@ -21,6 +21,37 @@ from typing import Any
|
||||
from .types import Keyword, Symbol, NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SexpExpr — pre-built sexp source marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SexpExpr:
|
||||
"""Pre-built sexp source that serialize() outputs unquoted.
|
||||
|
||||
Use this to nest sexp call strings inside other sexp_call() invocations
|
||||
without them being quoted as strings::
|
||||
|
||||
sexp_call("parent", child=SexpExpr(sexp_call("child", x=1)))
|
||||
# => (~parent :child (~child :x 1))
|
||||
"""
|
||||
__slots__ = ("source",)
|
||||
|
||||
def __init__(self, source: str):
|
||||
self.source = source
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SexpExpr({self.source!r})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.source
|
||||
|
||||
def __add__(self, other: object) -> "SexpExpr":
|
||||
return SexpExpr(self.source + str(other))
|
||||
|
||||
def __radd__(self, other: object) -> "SexpExpr":
|
||||
return SexpExpr(str(other) + self.source)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -230,6 +261,9 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
|
||||
|
||||
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
"""Serialize a value back to s-expression text."""
|
||||
if isinstance(expr, SexpExpr):
|
||||
return expr.source
|
||||
|
||||
if isinstance(expr, list):
|
||||
if not expr:
|
||||
return "()"
|
||||
@@ -269,6 +303,13 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
items.append(serialize(v, indent, pretty))
|
||||
return "{" + " ".join(items) + "}"
|
||||
|
||||
# Catch callables (Python functions leaked into sexp data)
|
||||
if callable(expr):
|
||||
import logging
|
||||
logging.getLogger("sexp").error(
|
||||
"serialize: callable leaked into sexp data: %r", expr)
|
||||
return "nil"
|
||||
|
||||
# Fallback for Lambda/Component — show repr
|
||||
return repr(expr)
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
(defcomp ~post-card (&key title slug href feature-image excerpt
|
||||
status published-at updated-at publish-requested
|
||||
hx-select like-html widgets-html at-bar-html)
|
||||
hx-select like widgets at-bar)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(when like-html (raw! like-html))
|
||||
(when like like)
|
||||
(a :href href
|
||||
:hx-get href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select hx-select
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:sx-get href
|
||||
:sx-target "#main-panel"
|
||||
:sx-select hx-select
|
||||
:sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
@@ -28,8 +28,8 @@
|
||||
(img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt
|
||||
(p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
(when widgets-html (raw! widgets-html))
|
||||
(when at-bar-html (raw! at-bar-html))))
|
||||
(when widgets widgets)
|
||||
(when at-bar at-bar)))
|
||||
|
||||
(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
(input :id "search-mobile"
|
||||
:type "text" :name "search" :aria-label "search"
|
||||
:class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
:hx-preserve true
|
||||
:sx-preserve true
|
||||
:value (or search "")
|
||||
:placeholder "search"
|
||||
:hx-trigger "input changed delay:300ms"
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:hx-get current-local-href
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:hx-headers search-headers-mobile
|
||||
:hx-sync "this:replace"
|
||||
:sx-trigger "input changed delay:300ms"
|
||||
:sx-target "#main-panel"
|
||||
:sx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:sx-get current-local-href
|
||||
:sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:sx-headers search-headers-mobile
|
||||
:sx-sync "this:replace"
|
||||
:autocomplete "off")
|
||||
(div :id "search-count-mobile" :aria-label "search count"
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (raw! (str search-count))))))
|
||||
(when search (str search-count)))))
|
||||
|
||||
(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
|
||||
(div :id "search-desktop-wrapper"
|
||||
@@ -26,23 +26,23 @@
|
||||
(input :id "search-desktop"
|
||||
:type "text" :name "search" :aria-label "search"
|
||||
:class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
:hx-preserve true
|
||||
:sx-preserve true
|
||||
:value (or search "")
|
||||
:placeholder "search"
|
||||
:hx-trigger "input changed delay:300ms"
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:hx-get current-local-href
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:hx-headers search-headers-desktop
|
||||
:hx-sync "this:replace"
|
||||
:sx-trigger "input changed delay:300ms"
|
||||
:sx-target "#main-panel"
|
||||
:sx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||
:sx-get current-local-href
|
||||
:sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:sx-headers search-headers-desktop
|
||||
:sx-sync "this:replace"
|
||||
:autocomplete "off")
|
||||
(div :id "search-count-desktop" :aria-label "search count"
|
||||
:class (if (not search-count) "text-xl text-red-500" "")
|
||||
(when search (raw! (str search-count))))))
|
||||
(when search (str search-count)))))
|
||||
|
||||
(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html)
|
||||
(defcomp ~mobile-filter (&key filter-summary action-buttons filter-details)
|
||||
(details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
|
||||
(summary :class "bg-white/90"
|
||||
(div :class "flex flex-row items-start"
|
||||
@@ -57,58 +57,30 @@
|
||||
(div :id "filter-summary-mobile"
|
||||
:class "flex-1 md:hidden grid grid-cols-12 items-center gap-3"
|
||||
(div :class "flex flex-col items-start gap-2"
|
||||
(raw! filter-summary-html)))))
|
||||
(raw! (or action-buttons-html ""))
|
||||
(when filter-summary filter-summary)))))
|
||||
(when action-buttons action-buttons)
|
||||
(div :id "filter-details-mobile" :style "display:contents"
|
||||
(raw! (or filter-details-html "")))))
|
||||
(when filter-details filter-details))))
|
||||
|
||||
(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
|
||||
(if (< page total-pages)
|
||||
(raw! (str
|
||||
"<tr id=\"" id-prefix "-sentinel-" page "\""
|
||||
" hx-get=\"" url "\""
|
||||
" hx-trigger=\"intersect once delay:250ms, sentinel:retry\""
|
||||
" hx-swap=\"outerHTML\""
|
||||
" _=\""
|
||||
"init "
|
||||
"if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end "
|
||||
"on sentinel:retry "
|
||||
"remove .hidden from .js-loading in me "
|
||||
"add .hidden to .js-neterr in me "
|
||||
"set me.style.pointerEvents to 'none' "
|
||||
"set me.style.opacity to '0' "
|
||||
"trigger htmx:consume on me "
|
||||
"call htmx.trigger(me, 'intersect') "
|
||||
"end "
|
||||
"def backoff() "
|
||||
"add .hidden to .js-loading in me "
|
||||
"remove .hidden from .js-neterr in me "
|
||||
"set myMs to Number(me.dataset.retryMs) "
|
||||
"if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end "
|
||||
"js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs) "
|
||||
"end "
|
||||
"on htmx:beforeRequest "
|
||||
"set me.style.pointerEvents to 'none' "
|
||||
"set me.style.opacity to '0' "
|
||||
"end "
|
||||
"on htmx:afterSwap set me.dataset.retryMs to 1000 end "
|
||||
"on htmx:sendError call backoff() "
|
||||
"on htmx:responseError call backoff() "
|
||||
"on htmx:timeout call backoff()"
|
||||
"\""
|
||||
" role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
|
||||
"<td colspan=\"" colspan "\" class=\"px-3 py-4\">"
|
||||
"<div class=\"block md:hidden h-[60vh] js-mobile-sentinel\">"
|
||||
"<div class=\"js-loading text-center text-xs text-stone-400\">loading\u2026 " page " / " total-pages "</div>"
|
||||
"<div class=\"js-neterr hidden flex h-full items-center justify-center\"></div>"
|
||||
"</div>"
|
||||
"<div class=\"hidden md:block h-[30vh] js-desktop-sentinel\">"
|
||||
"<div class=\"js-loading text-center text-xs text-stone-400\">loading\u2026 " page " / " total-pages "</div>"
|
||||
"<div class=\"js-neterr hidden inset-0 grid place-items-center p-4\"></div>"
|
||||
"</div>"
|
||||
"</td></tr>"))
|
||||
(raw! (str
|
||||
"<tr><td colspan=\"" colspan "\" class=\"px-3 py-4 text-center text-xs text-stone-400\">End of results</td></tr>"))))
|
||||
(tr :id (str id-prefix "-sentinel-" page)
|
||||
:sx-get url
|
||||
:sx-trigger "intersect once delay:250ms"
|
||||
:sx-swap "outerHTML"
|
||||
:sx-retry "exponential:1000:30000"
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(td :colspan colspan :class "px-3 py-4"
|
||||
(div :class "block md:hidden h-[60vh] js-mobile-sentinel"
|
||||
(div :class "sx-indicator js-loading text-center text-xs text-stone-400"
|
||||
(str "loading\u2026 " page " / " total-pages))
|
||||
(div :class "js-neterr hidden flex h-full items-center justify-center"))
|
||||
(div :class "hidden md:block h-[30vh] js-desktop-sentinel"
|
||||
(div :class "sx-indicator js-loading text-center text-xs text-stone-400"
|
||||
(str "loading\u2026 " page " / " total-pages))
|
||||
(div :class "js-neterr hidden inset-0 grid place-items-center p-4"))))
|
||||
(tr (td :colspan colspan :class "px-3 py-4 text-center text-xs text-stone-400"
|
||||
"End of results"))))
|
||||
|
||||
(defcomp ~status-pill (&key status size)
|
||||
(let* ((s (or status "pending"))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
|
||||
:data-fragment "link-card"
|
||||
:data-app data-app
|
||||
:data-hx-disable true
|
||||
:sx-disable true
|
||||
(div :class "flex flex-row items-start gap-3 p-3"
|
||||
(if image
|
||||
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
|
||||
(div :id "cart-mini"
|
||||
:hx-swap-oob oob
|
||||
:sx-swap-oob oob
|
||||
(if (= cart-count 0)
|
||||
(div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
|
||||
(a :href blog-url
|
||||
@@ -58,5 +58,5 @@
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
:data-hx-disable true
|
||||
:sx-disable true
|
||||
label)))
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
|
||||
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
|
||||
(script :src "https://unpkg.com/htmx.org@2.0.8")
|
||||
(meta :name "htmx-config" :content "{\"selfRequestsOnly\":false}")
|
||||
(script :src "https://unpkg.com/hyperscript.org@0.9.12")
|
||||
(meta :name "csrf-token" :content "")
|
||||
(script :src "https://cdn.tailwindcss.com")
|
||||
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
|
||||
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css"))
|
||||
@@ -33,7 +31,8 @@
|
||||
".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}"
|
||||
".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}"
|
||||
".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}")))
|
||||
|
||||
(defcomp ~app-layout (&key title asset-url meta-html menu-colour
|
||||
@@ -55,7 +54,7 @@
|
||||
:class (str "flex items-start gap-2 p-1 bg-" colour "-500")
|
||||
(div :class "flex flex-col w-full items-center"
|
||||
(when header-rows-html (raw! header-rows-html))))))
|
||||
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(when menu-html (raw! menu-html)))))
|
||||
(div :id "filter"
|
||||
(when filter-html (raw! filter-html)))
|
||||
@@ -73,20 +72,60 @@
|
||||
(script :src (str asset-url "/scripts/sexp.js"))
|
||||
(script :src (str asset-url "/scripts/body.js")))))))
|
||||
|
||||
(defcomp ~app-body (&key header-rows filter aside menu content)
|
||||
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
|
||||
(div :class "w-full"
|
||||
(details :class "group/root p-2" :data-toggle-group "mobile-panels"
|
||||
(summary
|
||||
(header :class "z-50"
|
||||
(div :id "root-header-summary"
|
||||
:class "flex items-start gap-2 p-1 bg-sky-500"
|
||||
(div :class "flex flex-col w-full items-center"
|
||||
(when header-rows header-rows)))))
|
||||
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(when menu menu))))
|
||||
(div :id "filter"
|
||||
(when filter filter))
|
||||
(main :id "root-panel" :class "max-w-full"
|
||||
(div :class "md:min-h-0"
|
||||
(div :class "flex flex-row md:h-full md:min-h-0"
|
||||
(aside :id "aside"
|
||||
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
(when aside aside))
|
||||
(section :id "main-panel"
|
||||
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
(when content content)
|
||||
(div :class "pb-8")))))))
|
||||
|
||||
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
|
||||
(<>
|
||||
(when oobs-html (raw! oobs-html))
|
||||
(div :id "filter" :hx-swap-oob "outerHTML"
|
||||
(div :id "filter" :sx-swap-oob "outerHTML"
|
||||
(when filter-html (raw! filter-html)))
|
||||
(aside :id "aside" :hx-swap-oob "outerHTML"
|
||||
(aside :id "aside" :sx-swap-oob "outerHTML"
|
||||
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
(when aside-html (raw! aside-html)))
|
||||
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(when menu-html (raw! menu-html)))
|
||||
(section :id "main-panel"
|
||||
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
(when content-html (raw! content-html)))))
|
||||
|
||||
;; Sexp-native OOB response — accepts nested sexp expressions, no raw!
|
||||
(defcomp ~oob-sexp (&key oobs filter aside menu content)
|
||||
(<>
|
||||
(when oobs oobs)
|
||||
(div :id "filter" :sx-swap-oob "outerHTML"
|
||||
(when filter filter))
|
||||
(aside :id "aside" :sx-swap-oob "outerHTML"
|
||||
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
(when aside aside))
|
||||
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
|
||||
(when menu menu))
|
||||
(section :id "main-panel"
|
||||
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
(when content content))))
|
||||
|
||||
(defcomp ~hamburger ()
|
||||
(div :class "md:hidden bg-stone-200 rounded"
|
||||
(svg :class "h-12 w-12 transition-transform group-open/root:hidden block self-start"
|
||||
@@ -102,7 +141,7 @@
|
||||
settings-url is-admin oob)
|
||||
(<>
|
||||
(div :id "root-row"
|
||||
:hx-swap-oob (if oob "outerHTML" nil)
|
||||
:sx-swap-oob (if oob "outerHTML" nil)
|
||||
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
|
||||
(div :class "w-full flex flex-row items-top"
|
||||
(when cart-mini-html (raw! cart-mini-html))
|
||||
@@ -127,15 +166,15 @@
|
||||
(shade (str (- 500 (* lv 100)))))
|
||||
(<>
|
||||
(div :id id
|
||||
:hx-swap-oob (if oob "outerHTML" nil)
|
||||
:sx-swap-oob (if oob "outerHTML" nil)
|
||||
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
|
||||
(div :class "relative nav-group"
|
||||
(a :href link-href
|
||||
:hx-get (if external nil link-href)
|
||||
:hx-target (if external nil "#main-panel")
|
||||
:hx-select (if external nil (or hx-select "#main-panel"))
|
||||
:hx-swap (if external nil "outerHTML")
|
||||
:hx-push-url (if external nil "true")
|
||||
:sx-get (if external nil link-href)
|
||||
:sx-target (if external nil "#main-panel")
|
||||
:sx-select (if external nil (or hx-select "#main-panel"))
|
||||
:sx-swap (if external nil "outerHTML")
|
||||
:sx-push-url (if external nil "true")
|
||||
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(if link-label-html (raw! link-label-html)
|
||||
@@ -158,13 +197,75 @@
|
||||
(span count)))
|
||||
|
||||
(defcomp ~oob-header (&key parent-id child-id row-html)
|
||||
(div :id parent-id :hx-swap-oob "outerHTML" :class "w-full"
|
||||
(div :id parent-id :sx-swap-oob "outerHTML" :class "w-full"
|
||||
(div :class "w-full" (raw! row-html)
|
||||
(div :id child-id))))
|
||||
|
||||
(defcomp ~header-child (&key id inner-html)
|
||||
(div :id (or id "root-header-child") :class "w-full" (raw! inner-html)))
|
||||
|
||||
;; Sexp-native header-row — accepts nested sexp expressions, no raw!
|
||||
(defcomp ~header-row-sx (&key cart-mini blog-url site-title app-label
|
||||
nav-tree auth-menu nav-panel
|
||||
settings-url is-admin oob)
|
||||
(<>
|
||||
(div :id "root-row"
|
||||
:sx-swap-oob (if oob "outerHTML" nil)
|
||||
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
|
||||
(div :class "w-full flex flex-row items-top"
|
||||
(when cart-mini cart-mini)
|
||||
(div :class "font-bold text-5xl flex-1"
|
||||
(a :href (or blog-url "/") :class "flex justify-center md:justify-start items-baseline gap-2"
|
||||
(h1 (or site-title ""))))
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(when nav-tree nav-tree)
|
||||
(when auth-menu auth-menu)
|
||||
(when nav-panel nav-panel)
|
||||
(when (and is-admin settings-url)
|
||||
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||
(~hamburger)))
|
||||
(div :class "block md:hidden text-md font-bold"
|
||||
(when auth-menu auth-menu))))
|
||||
|
||||
;; Sexp-native menu-row — accepts nested sexp expressions, no raw!
|
||||
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
|
||||
hx-select nav child-id child oob external)
|
||||
(let* ((c (or colour "sky"))
|
||||
(lv (or level 1))
|
||||
(shade (str (- 500 (* lv 100)))))
|
||||
(<>
|
||||
(div :id id
|
||||
:sx-swap-oob (if oob "outerHTML" nil)
|
||||
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
|
||||
(div :class "relative nav-group"
|
||||
(a :href link-href
|
||||
:sx-get (if external nil link-href)
|
||||
:sx-target (if external nil "#main-panel")
|
||||
:sx-select (if external nil (or hx-select "#main-panel"))
|
||||
:sx-swap (if external nil "outerHTML")
|
||||
:sx-push-url (if external nil "true")
|
||||
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(if link-label-content link-label-content
|
||||
(when link-label (div link-label)))))
|
||||
(when nav
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
nav)))
|
||||
(when (and child-id (not oob))
|
||||
(div :id child-id :class "flex flex-col w-full items-center"
|
||||
(when child child))))))
|
||||
|
||||
;; Sexp-native oob-header — accepts nested sexp expression, no raw!
|
||||
(defcomp ~oob-header-sx (&key parent-id child-id row)
|
||||
(div :id parent-id :sx-swap-oob "outerHTML" :class "w-full"
|
||||
(div :class "w-full" row
|
||||
(div :id child-id))))
|
||||
|
||||
;; Sexp-native header-child — accepts nested sexp expression, no raw!
|
||||
(defcomp ~header-child-sx (&key id inner)
|
||||
(div :id (or id "root-header-child") :class "w-full" inner))
|
||||
|
||||
(defcomp ~error-content (&key errnum message image)
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" errnum)
|
||||
@@ -176,11 +277,11 @@
|
||||
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:hx-get href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (or hx-select "#main-panel")
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:sx-get href
|
||||
:sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel")
|
||||
:sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:aria-selected (when is-selected "true")
|
||||
:class (or aclass
|
||||
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
;; Miscellaneous shared components for Phase 3 conversion
|
||||
|
||||
;; The single place where raw! lives — for CMS content (Ghost post body,
|
||||
;; product descriptions, etc.) that arrives as pre-rendered HTML.
|
||||
(defcomp ~rich-text (&key html)
|
||||
(raw! html))
|
||||
|
||||
(defcomp ~error-inline (&key message)
|
||||
(div :class "text-red-600 text-sm" (raw! message)))
|
||||
(div :class "text-red-600 text-sm" message))
|
||||
|
||||
(defcomp ~notification-badge (&key count)
|
||||
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" (raw! count)))
|
||||
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" count))
|
||||
|
||||
(defcomp ~cache-cleared (&key time-str)
|
||||
(span :class "text-green-600 font-bold" "Cache cleared at " (raw! time-str)))
|
||||
(span :class "text-green-600 font-bold" "Cache cleared at " time-str))
|
||||
|
||||
(defcomp ~error-list (&key items-html)
|
||||
(defcomp ~error-list (&key items)
|
||||
(ul :class "list-disc pl-5 space-y-1 text-sm text-red-600"
|
||||
(raw! items-html)))
|
||||
(when items items)))
|
||||
|
||||
(defcomp ~error-list-item (&key message)
|
||||
(li (raw! message)))
|
||||
(li message))
|
||||
|
||||
(defcomp ~fragment-error (&key service)
|
||||
(p :class "text-sm text-red-600" "Service " (b (raw! service)) " is unavailable."))
|
||||
(p :class "text-sm text-red-600" "Service " (b service) " is unavailable."))
|
||||
|
||||
(defcomp ~htmx-sentinel (&key id hx-get hx-trigger hx-swap class extra-attrs)
|
||||
(div :id id :hx-get hx-get :hx-trigger hx-trigger :hx-swap hx-swap :class class))
|
||||
(div :id id :sx-get hx-get :sx-trigger hx-trigger :sx-swap hx-swap :class class))
|
||||
|
||||
(defcomp ~nav-group-link (&key href hx-select nav-class label)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML"
|
||||
:hx-push-url "true" :class nav-class
|
||||
(raw! label))))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML"
|
||||
:sx-push-url "true" :class nav-class
|
||||
label)))
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
(defcomp ~relation-attach (&key create-url label icon)
|
||||
(a :href create-url
|
||||
:hx-get create-url
|
||||
:hx-target "#main-panel"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:sx-get create-url
|
||||
:sx-target "#main-panel"
|
||||
:sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
|
||||
(when icon (i :class icon))
|
||||
(span (or label "Add"))))
|
||||
|
||||
(defcomp ~relation-detach (&key detach-url name)
|
||||
(button :hx-delete detach-url
|
||||
:hx-confirm (str "Remove " (or name "this item") "?")
|
||||
(button :sx-delete detach-url
|
||||
:sx-confirm (str "Remove " (or name "this item") "?")
|
||||
:class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
|
||||
(i :class "fa fa-times" :aria-hidden "true")))
|
||||
|
||||
@@ -203,3 +203,31 @@ class TestPythonParity:
|
||||
py_html = py_render(parse(call_text), env)
|
||||
js_html = _js_render(call_text, comp_text)
|
||||
assert js_html == py_html
|
||||
|
||||
MAP_CASES = [
|
||||
# map with lambda returning HTML element
|
||||
(
|
||||
"",
|
||||
'(ul (map (lambda (x) (li x)) ("a" "b" "c")))',
|
||||
),
|
||||
# map with lambda returning component
|
||||
(
|
||||
'(defcomp ~item (&key name) (span :class "item" name))',
|
||||
'(div (map (lambda (t) (~item :name (get t "name"))) ({"name" "Alice"} {"name" "Bob"})))',
|
||||
),
|
||||
# map-indexed with lambda
|
||||
(
|
||||
"",
|
||||
'(ul (map-indexed (lambda (i x) (li (str i ". " x))) ("foo" "bar")))',
|
||||
),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("comp_text,call_text", MAP_CASES)
|
||||
def test_map_lambda_render(self, comp_text, call_text):
|
||||
env = {}
|
||||
if comp_text:
|
||||
for expr in parse_all(comp_text):
|
||||
evaluate(expr, env)
|
||||
py_html = py_render(parse(call_text), env)
|
||||
js_html = _js_render(call_text, comp_text)
|
||||
assert js_html == py_html, f"Mismatch:\n PY: {py_html!r}\n JS: {js_html!r}"
|
||||
|
||||
Reference in New Issue
Block a user