Phase 5: Page layouts as s-expressions — components, fragments, error pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Add 9 new shared s-expression components (cart-mini, auth-menu, account-nav-item, calendar-entry-nav, calendar-link-nav, market-link-nav, post-card, base-shell, error-page) and wire them into all fragment route handlers. 404/403 error pages now render entirely via s-expressions as a full-page proof-of-concept, with Jinja fallback on failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ Fragments:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from quart import Blueprint, Response, request, render_template
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
|
||||||
@@ -22,10 +22,13 @@ def register():
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
async def _auth_menu():
|
async def _auth_menu():
|
||||||
|
from shared.infrastructure.urls import account_url
|
||||||
|
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||||
|
|
||||||
user_email = request.args.get("email", "")
|
user_email = request.args.get("email", "")
|
||||||
return await render_template(
|
return render_sexp(
|
||||||
"fragments/auth_menu.html",
|
'(~auth-menu :user-email user-email :account-url account-url)',
|
||||||
user_email=user_email,
|
**{"user-email": user_email or None, "account-url": account_url("")},
|
||||||
)
|
)
|
||||||
|
|
||||||
_handlers = {
|
_handlers = {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Fragments:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from quart import Blueprint, Response, request, render_template, g
|
from quart import Blueprint, Response, request, g
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ def register():
|
|||||||
|
|
||||||
async def _cart_mini():
|
async def _cart_mini():
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
|
from shared.infrastructure.urls import blog_url, cart_url
|
||||||
|
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||||
|
|
||||||
user_id = request.args.get("user_id", type=int)
|
user_id = request.args.get("user_id", type=int)
|
||||||
session_id = request.args.get("session_id")
|
session_id = request.args.get("session_id")
|
||||||
@@ -33,17 +35,18 @@ def register():
|
|||||||
)
|
)
|
||||||
count = summary.count + summary.calendar_count + summary.ticket_count
|
count = summary.count + summary.calendar_count + summary.ticket_count
|
||||||
oob = request.args.get("oob", "")
|
oob = request.args.get("oob", "")
|
||||||
return await render_template("fragments/cart_mini.html", cart_count=count, oob=oob)
|
return render_sexp(
|
||||||
|
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url :oob oob)',
|
||||||
|
**{"cart-count": count, "blog-url": blog_url(""), "cart-url": cart_url(""), "oob": oob or None},
|
||||||
|
)
|
||||||
|
|
||||||
async def _account_nav_item():
|
async def _account_nav_item():
|
||||||
from shared.infrastructure.urls import cart_url
|
from shared.infrastructure.urls import cart_url
|
||||||
|
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||||
|
|
||||||
href = cart_url("/orders/")
|
return render_sexp(
|
||||||
return (
|
'(~account-nav-item :href href :label "orders")',
|
||||||
'<div class="relative nav-group">'
|
href=cart_url("/orders/"),
|
||||||
f'<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>'
|
|
||||||
'orders</a></div>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_handlers = {
|
_handlers = {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ def register():
|
|||||||
# --- container-nav fragment: calendar entries + calendar links -----------
|
# --- container-nav fragment: calendar entries + calendar links -----------
|
||||||
|
|
||||||
async def _container_nav_handler():
|
async def _container_nav_handler():
|
||||||
|
from quart import current_app
|
||||||
|
from shared.infrastructure.urls import events_url
|
||||||
|
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||||
|
|
||||||
container_type = request.args.get("container_type", "page")
|
container_type = request.args.get("container_type", "page")
|
||||||
container_id = int(request.args.get("container_id", 0))
|
container_id = int(request.args.get("container_id", 0))
|
||||||
post_slug = request.args.get("post_slug", "")
|
post_slug = request.args.get("post_slug", "")
|
||||||
@@ -43,6 +47,8 @@ def register():
|
|||||||
exclude = request.args.get("exclude", "")
|
exclude = request.args.get("exclude", "")
|
||||||
excludes = [e.strip() for e in exclude.split(",") if e.strip()]
|
excludes = [e.strip() for e in exclude.split(",") if e.strip()]
|
||||||
|
|
||||||
|
styles = current_app.jinja_env.globals.get("styles", {})
|
||||||
|
nav_class = styles.get("nav_button_less_pad", "")
|
||||||
html_parts = []
|
html_parts = []
|
||||||
|
|
||||||
# Calendar entries nav
|
# Calendar entries nav
|
||||||
@@ -50,23 +56,41 @@ def register():
|
|||||||
entries, has_more = await services.calendar.associated_entries(
|
entries, has_more = await services.calendar.associated_entries(
|
||||||
g.s, container_type, container_id, page,
|
g.s, container_type, container_id, page,
|
||||||
)
|
)
|
||||||
if entries:
|
for entry in entries:
|
||||||
html_parts.append(await render_template(
|
entry_path = (
|
||||||
"fragments/container_nav_entries.html",
|
f"/{post_slug}/calendars/{entry.calendar_slug}/"
|
||||||
entries=entries, has_more=has_more,
|
f"{entry.start_at.year}/{entry.start_at.month}/"
|
||||||
page=page, post_slug=post_slug,
|
f"{entry.start_at.day}/entries/{entry.id}/"
|
||||||
paginate_url_base=paginate_url_base,
|
)
|
||||||
|
date_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
|
||||||
|
if entry.end_at:
|
||||||
|
date_str += f" – {entry.end_at.strftime('%H:%M')}"
|
||||||
|
html_parts.append(render_sexp(
|
||||||
|
'(~calendar-entry-nav :href href :name name :date-str date-str :nav-class nav-class)',
|
||||||
|
href=events_url(entry_path), name=entry.name,
|
||||||
|
**{"date-str": date_str, "nav-class": nav_class},
|
||||||
))
|
))
|
||||||
|
# Infinite scroll sentinel (kept as raw HTML — HTMX-specific)
|
||||||
|
if has_more and paginate_url_base:
|
||||||
|
html_parts.append(
|
||||||
|
f'<div id="entries-load-sentinel-{page}"'
|
||||||
|
f' hx-get="{paginate_url_base}?page={page + 1}"'
|
||||||
|
f' hx-trigger="intersect once"'
|
||||||
|
f' hx-swap="beforebegin"'
|
||||||
|
f' _="on htmx:afterRequest trigger scroll on #associated-entries-container"'
|
||||||
|
f' class="flex-shrink-0 w-1"></div>'
|
||||||
|
)
|
||||||
|
|
||||||
# Calendar links nav
|
# Calendar links nav
|
||||||
if not any(e.startswith("calendar") for e in excludes):
|
if not any(e.startswith("calendar") for e in excludes):
|
||||||
calendars = await services.calendar.calendars_for_container(
|
calendars = await services.calendar.calendars_for_container(
|
||||||
g.s, container_type, container_id,
|
g.s, container_type, container_id,
|
||||||
)
|
)
|
||||||
if calendars:
|
for cal in calendars:
|
||||||
html_parts.append(await render_template(
|
href = events_url(f"/{post_slug}/calendars/{cal.slug}/")
|
||||||
"fragments/container_nav_calendars.html",
|
html_parts.append(render_sexp(
|
||||||
calendars=calendars, post_slug=post_slug,
|
'(~calendar-link-nav :href href :name name :nav-class nav-class)',
|
||||||
|
href=href, name=cal.name, **{"nav-class": nav_class},
|
||||||
))
|
))
|
||||||
|
|
||||||
return "\n".join(html_parts)
|
return "\n".join(html_parts)
|
||||||
@@ -99,7 +123,28 @@ def register():
|
|||||||
# --- account-nav-item fragment: tickets + bookings links for account nav -
|
# --- account-nav-item fragment: tickets + bookings links for account nav -
|
||||||
|
|
||||||
async def _account_nav_item_handler():
|
async def _account_nav_item_handler():
|
||||||
return await render_template("fragments/account_nav_items.html")
|
from quart import current_app
|
||||||
|
from shared.infrastructure.urls import account_url
|
||||||
|
|
||||||
|
styles = current_app.jinja_env.globals.get("styles", {})
|
||||||
|
nav_class = styles.get("nav_button", "")
|
||||||
|
hx_select = (
|
||||||
|
"#main-panel, #search-mobile, #search-count-mobile,"
|
||||||
|
" #search-desktop, #search-count-desktop, #menu-items-nav-wrapper"
|
||||||
|
)
|
||||||
|
tickets_url = account_url("/tickets/")
|
||||||
|
bookings_url = account_url("/bookings/")
|
||||||
|
# These two links use HTMX navigation — kept as raw HTML for the
|
||||||
|
# hx-* attributes that don't map neatly to a reusable component.
|
||||||
|
parts = []
|
||||||
|
for href, label in [(tickets_url, "tickets"), (bookings_url, "bookings")]:
|
||||||
|
parts.append(
|
||||||
|
f'<div class="relative nav-group">'
|
||||||
|
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
|
||||||
|
f' hx-select="{hx_select}" hx-swap="outerHTML"'
|
||||||
|
f' hx-push-url="true" class="{nav_class}">{label}</a></div>'
|
||||||
|
)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
_handlers["account-nav-item"] = _account_nav_item_handler
|
_handlers["account-nav-item"] = _account_nav_item_handler
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ def register():
|
|||||||
# --- container-nav fragment: market links --------------------------------
|
# --- container-nav fragment: market links --------------------------------
|
||||||
|
|
||||||
async def _container_nav_handler():
|
async def _container_nav_handler():
|
||||||
|
from quart import current_app
|
||||||
|
from shared.infrastructure.urls import market_url
|
||||||
|
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||||
|
|
||||||
container_type = request.args.get("container_type", "page")
|
container_type = request.args.get("container_type", "page")
|
||||||
container_id = int(request.args.get("container_id", 0))
|
container_id = int(request.args.get("container_id", 0))
|
||||||
post_slug = request.args.get("post_slug", "")
|
post_slug = request.args.get("post_slug", "")
|
||||||
@@ -42,10 +46,16 @@ def register():
|
|||||||
)
|
)
|
||||||
if not markets:
|
if not markets:
|
||||||
return ""
|
return ""
|
||||||
return await render_template(
|
styles = current_app.jinja_env.globals.get("styles", {})
|
||||||
"fragments/container_nav_markets.html",
|
nav_class = styles.get("nav_button_less_pad", "")
|
||||||
markets=markets, post_slug=post_slug,
|
parts = []
|
||||||
)
|
for m in markets:
|
||||||
|
href = market_url(f"/{post_slug}/{m.slug}/")
|
||||||
|
parts.append(render_sexp(
|
||||||
|
'(~market-link-nav :href href :name name :nav-class nav-class)',
|
||||||
|
href=href, name=m.name, **{"nav-class": nav_class},
|
||||||
|
))
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
_handlers["container-nav"] = _container_nav_handler
|
_handlers["container-nav"] = _container_nav_handler
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,11 @@ def register():
|
|||||||
|
|
||||||
async def _account_nav_item():
|
async def _account_nav_item():
|
||||||
from shared.infrastructure.urls import orders_url
|
from shared.infrastructure.urls import orders_url
|
||||||
href = orders_url("/")
|
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||||
return (
|
|
||||||
'<div class="relative nav-group">'
|
return render_sexp(
|
||||||
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
|
'(~account-nav-item :href href :label "orders")',
|
||||||
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
|
href=orders_url("/"),
|
||||||
'orders</a></div>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_handlers = {
|
_handlers = {
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ def _error_page(message: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sexp_error_page(errnum: str, message: str, image: str | None = None) -> str:
|
||||||
|
"""Render an error page via s-expressions. Bypasses Jinja entirely."""
|
||||||
|
from shared.sexp.page import render_page
|
||||||
|
|
||||||
|
return render_page(
|
||||||
|
'(~error-page :title title :message message :image image :asset-url "/static")',
|
||||||
|
title=f"{errnum} Error",
|
||||||
|
message=message,
|
||||||
|
image=image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def errors(app):
|
def errors(app):
|
||||||
def _info(e):
|
def _info(e):
|
||||||
return {
|
return {
|
||||||
@@ -82,10 +94,17 @@ def errors(app):
|
|||||||
errnum='404'
|
errnum='404'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
# Render via s-expressions (Phase 5 proof-of-concept)
|
||||||
"_types/root/exceptions/_.html",
|
try:
|
||||||
errnum='404',
|
html = _sexp_error_page(
|
||||||
)
|
"404", "NOT FOUND",
|
||||||
|
image="/static/errors/404.gif",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
html = await render_template(
|
||||||
|
"_types/root/exceptions/_.html",
|
||||||
|
errnum='404',
|
||||||
|
)
|
||||||
|
|
||||||
return await make_response(html, 404)
|
return await make_response(html, 404)
|
||||||
|
|
||||||
@@ -98,10 +117,16 @@ def errors(app):
|
|||||||
errnum='403'
|
errnum='403'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
try:
|
||||||
"_types/root/exceptions/_.html",
|
html = _sexp_error_page(
|
||||||
errnum='403',
|
"403", "FORBIDDEN",
|
||||||
)
|
image="/static/errors/403.gif",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
html = await render_template(
|
||||||
|
"_types/root/exceptions/_.html",
|
||||||
|
errnum='403',
|
||||||
|
)
|
||||||
|
|
||||||
return await make_response(html, 403)
|
return await make_response(html, 403)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ from .jinja_bridge import register_components
|
|||||||
def load_shared_components() -> None:
|
def load_shared_components() -> None:
|
||||||
"""Register all shared s-expression components."""
|
"""Register all shared s-expression components."""
|
||||||
register_components(_LINK_CARD)
|
register_components(_LINK_CARD)
|
||||||
|
register_components(_CART_MINI)
|
||||||
|
register_components(_AUTH_MENU)
|
||||||
|
register_components(_ACCOUNT_NAV_ITEM)
|
||||||
|
register_components(_CALENDAR_ENTRY_NAV)
|
||||||
|
register_components(_CALENDAR_LINK_NAV)
|
||||||
|
register_components(_MARKET_LINK_NAV)
|
||||||
|
register_components(_POST_CARD)
|
||||||
|
register_components(_BASE_SHELL)
|
||||||
|
register_components(_ERROR_PAGE)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -49,3 +58,243 @@ _LINK_CARD = '''
|
|||||||
(when detail
|
(when detail
|
||||||
(div :class "text-xs text-stone-400 mt-1" detail))))))
|
(div :class "text-xs text-stone-400 mt-1" detail))))))
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~cart-mini
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: cart/templates/fragments/cart_mini.html
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~cart-mini :cart-count count :blog-url burl :cart-url curl)',
|
||||||
|
# count=0, burl="https://blog.rose-ash.com", curl="https://cart.rose-ash.com")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CART_MINI = '''
|
||||||
|
(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
|
||||||
|
(div :id "cart-mini"
|
||||||
|
:hx-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 (str blog-url "/")
|
||||||
|
:class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||||
|
(img :src (str blog-url "/static/img/logo.jpg")
|
||||||
|
:class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))
|
||||||
|
(a :href (str cart-url "/")
|
||||||
|
:class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||||
|
(i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
|
||||||
|
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||||
|
cart-count)))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~auth-menu
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: account/templates/fragments/auth_menu.html
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~auth-menu :user-email email :account-url aurl)',
|
||||||
|
# email="user@example.com", aurl="https://account.rose-ash.com")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_AUTH_MENU = '''
|
||||||
|
(defcomp ~auth-menu (&key user-email account-url)
|
||||||
|
(<>
|
||||||
|
(span :id "auth-menu-desktop" :class "hidden md:inline-flex"
|
||||||
|
(if user-email
|
||||||
|
(a :href (str account-url "/")
|
||||||
|
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||||
|
:data-close-details true
|
||||||
|
(i :class "fa-solid fa-user")
|
||||||
|
(span user-email))
|
||||||
|
(a :href (str account-url "/")
|
||||||
|
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
|
||||||
|
:data-close-details true
|
||||||
|
(i :class "fa-solid fa-key")
|
||||||
|
(span "sign in or register"))))
|
||||||
|
(span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold"
|
||||||
|
(if user-email
|
||||||
|
(a :href (str account-url "/") :data-close-details true
|
||||||
|
(i :class "fa-solid fa-user")
|
||||||
|
(span user-email))
|
||||||
|
(a :href (str account-url "/")
|
||||||
|
(i :class "fa-solid fa-key")
|
||||||
|
(span "sign in or register"))))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~account-nav-item
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: hardcoded HTML in cart/bp/fragments/routes.py
|
||||||
|
# and orders/bp/fragments/routes.py
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~account-nav-item :href url :label "orders")', url=cart_url("/orders/"))
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ACCOUNT_NAV_ITEM = '''
|
||||||
|
(defcomp ~account-nav-item (&key href label)
|
||||||
|
(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
|
||||||
|
label)))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~calendar-entry-nav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: events/templates/fragments/container_nav_entries.html (per-entry)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~calendar-entry-nav :href url :name name :date-str "Jan 15, 2026 at 14:00")',
|
||||||
|
# url="/events/...", name="Workshop")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CALENDAR_ENTRY_NAV = '''
|
||||||
|
(defcomp ~calendar-entry-nav (&key href name date-str nav-class)
|
||||||
|
(a :href href :class nav-class
|
||||||
|
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||||
|
(div :class "flex-1 min-w-0"
|
||||||
|
(div :class "font-medium truncate" name)
|
||||||
|
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~calendar-link-nav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: events/templates/fragments/container_nav_calendars.html (per-calendar)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~calendar-link-nav :href url :name "My Calendar")', url="/events/...")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CALENDAR_LINK_NAV = '''
|
||||||
|
(defcomp ~calendar-link-nav (&key href name nav-class)
|
||||||
|
(a :href href :class nav-class
|
||||||
|
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||||
|
(div name)))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~market-link-nav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: market/templates/fragments/container_nav_markets.html (per-market)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~market-link-nav :href url :name "Farm Shop")', url="/market/...")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MARKET_LINK_NAV = '''
|
||||||
|
(defcomp ~market-link-nav (&key href name nav-class)
|
||||||
|
(a :href href :class nav-class
|
||||||
|
(i :class "fa fa-shopping-bag" :aria-hidden "true")
|
||||||
|
(div name)))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~post-card
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: blog/templates/_types/blog/_card.html
|
||||||
|
#
|
||||||
|
# A simplified s-expression version of the blog listing card.
|
||||||
|
# The full card is complex (like buttons, card widgets, at_bar with tag/author
|
||||||
|
# filtering). This component covers the core card structure; the at_bar and
|
||||||
|
# card_widgets are passed as pre-rendered HTML via :at-bar-html and
|
||||||
|
# :widgets-html kwargs for incremental migration.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~post-card :title t :slug s :href h ...)', **ctx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_POST_CARD = '''
|
||||||
|
(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)
|
||||||
|
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||||
|
(when like-html (raw! like-html))
|
||||||
|
(a :href href
|
||||||
|
:hx-get href
|
||||||
|
:hx-target "#main-panel"
|
||||||
|
:hx-select hx-select
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-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)
|
||||||
|
(cond
|
||||||
|
(= status "draft")
|
||||||
|
(begin
|
||||||
|
(div :class "flex justify-center gap-2 mt-1"
|
||||||
|
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||||
|
(when publish-requested
|
||||||
|
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
|
||||||
|
(when updated-at
|
||||||
|
(p :class "text-sm text-stone-500" (str "Updated: " updated-at))))
|
||||||
|
published-at
|
||||||
|
(p :class "text-sm text-stone-500" (str "Published: " published-at))))
|
||||||
|
(when feature-image
|
||||||
|
(div :class "mb-4"
|
||||||
|
(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))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~base-shell — full HTML document wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: shared/browser/templates/_types/root/index.html (the <html> shell)
|
||||||
|
#
|
||||||
|
# Usage: For full-page s-expression rendering (Step 4 proof of concept)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_BASE_SHELL = '''
|
||||||
|
(defcomp ~base-shell (&key title asset-url &rest children)
|
||||||
|
(<>
|
||||||
|
(raw! "<!doctype html>")
|
||||||
|
(html :lang "en"
|
||||||
|
(head
|
||||||
|
(meta :charset "utf-8")
|
||||||
|
(meta :name "viewport" :content "width=device-width, initial-scale=1")
|
||||||
|
(title title)
|
||||||
|
(style
|
||||||
|
"body{margin:0;min-height:100vh;display:flex;align-items:center;"
|
||||||
|
"justify-content:center;font-family:system-ui,sans-serif;"
|
||||||
|
"background:#fafaf9;color:#1c1917}")
|
||||||
|
(script :src "https://cdn.tailwindcss.com")
|
||||||
|
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")))
|
||||||
|
(body :class "bg-stone-50 text-stone-900"
|
||||||
|
children))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~error-page — styled error page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: shared/browser/templates/_types/root/exceptions/_.html
|
||||||
|
# + base.html + 404/message.html + 404/img.html
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~error-page :title "Not Found" :message "NOT FOUND" :image img-url :asset-url aurl)',
|
||||||
|
# img_url="/static/errors/404.gif", aurl="/static")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ERROR_PAGE = '''
|
||||||
|
(defcomp ~error-page (&key title message image asset-url)
|
||||||
|
(~base-shell :title title :asset-url asset-url
|
||||||
|
(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"
|
||||||
|
(div message))
|
||||||
|
(when image
|
||||||
|
(div :class "flex justify-center"
|
||||||
|
(img :src image :width "300" :height "300"))))))
|
||||||
|
'''
|
||||||
|
|||||||
32
shared/sexp/page.py
Normal file
32
shared/sexp/page.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Full-page s-expression rendering.
|
||||||
|
|
||||||
|
Provides ``render_page()`` for rendering a complete HTML page from an
|
||||||
|
s-expression, bypassing Jinja entirely. Used by error handlers and
|
||||||
|
(eventually) by route handlers for fully-migrated pages.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from shared.sexp.page import render_page
|
||||||
|
|
||||||
|
html = render_page(
|
||||||
|
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
|
||||||
|
image="/static/errors/404.gif",
|
||||||
|
asset_url="/static",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .jinja_bridge import sexp
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(source: str, **kwargs: Any) -> str:
|
||||||
|
"""Render a full HTML page from an s-expression string.
|
||||||
|
|
||||||
|
This is a thin wrapper around ``sexp()`` — it exists to make the
|
||||||
|
intent explicit in call sites (rendering a whole page, not a fragment).
|
||||||
|
"""
|
||||||
|
return sexp(source, **kwargs)
|
||||||
280
shared/sexp/tests/test_components.py
Normal file
280
shared/sexp/tests/test_components.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""Tests for shared s-expression components (Phase 5)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shared.sexp.jinja_bridge import sexp, _COMPONENT_ENV
|
||||||
|
from shared.sexp.components import load_shared_components
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _load_components():
|
||||||
|
"""Ensure all shared components are registered for every test."""
|
||||||
|
_COMPONENT_ENV.clear()
|
||||||
|
load_shared_components()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~cart-mini
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCartMini:
|
||||||
|
def test_empty_cart_shows_logo(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||||
|
**{"cart-count": 0, "blog-url": "https://blog.example.com", "cart-url": "https://cart.example.com"},
|
||||||
|
)
|
||||||
|
assert 'id="cart-mini"' in html
|
||||||
|
assert "logo.jpg" in html
|
||||||
|
assert "blog.example.com/" in html
|
||||||
|
assert "fa-shopping-cart" not in html
|
||||||
|
|
||||||
|
def test_nonempty_cart_shows_badge(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||||
|
**{"cart-count": 3, "blog-url": "https://blog.example.com", "cart-url": "https://cart.example.com"},
|
||||||
|
)
|
||||||
|
assert 'id="cart-mini"' in html
|
||||||
|
assert "fa-shopping-cart" in html
|
||||||
|
assert "bg-emerald-600" in html
|
||||||
|
assert ">3<" in html
|
||||||
|
assert "cart.example.com/" in html
|
||||||
|
|
||||||
|
def test_oob_attribute(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
|
||||||
|
)
|
||||||
|
assert 'hx-swap-oob="true"' in html
|
||||||
|
|
||||||
|
def test_no_oob_when_nil(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "")',
|
||||||
|
)
|
||||||
|
assert "hx-swap-oob" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~auth-menu
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAuthMenu:
|
||||||
|
def test_logged_in(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~auth-menu :user-email user-email :account-url account-url)',
|
||||||
|
**{"user-email": "alice@example.com", "account-url": "https://account.example.com"},
|
||||||
|
)
|
||||||
|
assert 'id="auth-menu-desktop"' in html
|
||||||
|
assert 'id="auth-menu-mobile"' in html
|
||||||
|
assert "alice@example.com" in html
|
||||||
|
assert "fa-solid fa-user" in html
|
||||||
|
assert "sign in or register" not in html
|
||||||
|
|
||||||
|
def test_logged_out(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~auth-menu :account-url account-url)',
|
||||||
|
**{"account-url": "https://account.example.com"},
|
||||||
|
)
|
||||||
|
assert "fa-solid fa-key" in html
|
||||||
|
assert "sign in or register" in html
|
||||||
|
|
||||||
|
def test_desktop_has_data_close_details(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~auth-menu :user-email "x@y.com" :account-url "http://a")',
|
||||||
|
)
|
||||||
|
assert "data-close-details" in html
|
||||||
|
|
||||||
|
def test_two_spans_always_present(self):
|
||||||
|
"""Both desktop and mobile spans are always rendered."""
|
||||||
|
for email in ["user@test.com", None]:
|
||||||
|
html = sexp(
|
||||||
|
'(~auth-menu :user-email user-email :account-url account-url)',
|
||||||
|
**{"user-email": email, "account-url": "http://a"},
|
||||||
|
)
|
||||||
|
assert 'id="auth-menu-desktop"' in html
|
||||||
|
assert 'id="auth-menu-mobile"' in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~account-nav-item
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAccountNavItem:
|
||||||
|
def test_renders_link(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~account-nav-item :href "/orders/" :label "orders")',
|
||||||
|
)
|
||||||
|
assert 'href="/orders/"' in html
|
||||||
|
assert ">orders<" in html
|
||||||
|
assert "nav-group" in html
|
||||||
|
assert "data-hx-disable" in html
|
||||||
|
|
||||||
|
def test_custom_label(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~account-nav-item :href "/cart/orders/" :label "my orders")',
|
||||||
|
)
|
||||||
|
assert ">my orders<" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~calendar-entry-nav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCalendarEntryNav:
|
||||||
|
def test_renders_entry(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
|
||||||
|
**{"date-str": "Jan 15, 2026 at 14:00", "nav-class": "btn"},
|
||||||
|
)
|
||||||
|
assert 'href="/events/entry/1/"' in html
|
||||||
|
assert "Workshop" in html
|
||||||
|
assert "Jan 15, 2026 at 14:00" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~calendar-link-nav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCalendarLinkNav:
|
||||||
|
def test_renders_calendar_link(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
|
||||||
|
**{"nav-class": "btn"},
|
||||||
|
)
|
||||||
|
assert 'href="/events/cal/"' in html
|
||||||
|
assert "fa fa-calendar" in html
|
||||||
|
assert "Art Events" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~market-link-nav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMarketLinkNav:
|
||||||
|
def test_renders_market_link(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
|
||||||
|
**{"nav-class": "btn"},
|
||||||
|
)
|
||||||
|
assert 'href="/market/farm/"' in html
|
||||||
|
assert "fa fa-shopping-bag" in html
|
||||||
|
assert "Farm Shop" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~post-card
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPostCard:
|
||||||
|
def test_basic_card(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~post-card :title "Hello World" :slug "hello" :href "/hello/"'
|
||||||
|
' :feature-image "/img/hello.jpg" :excerpt "A test post"'
|
||||||
|
' :status "published" :published-at "15 Jan 2026"'
|
||||||
|
' :hx-select "#main-panel")',
|
||||||
|
**{
|
||||||
|
"feature-image": "/img/hello.jpg",
|
||||||
|
"hx-select": "#main-panel",
|
||||||
|
"published-at": "15 Jan 2026",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "<article" in html
|
||||||
|
assert "Hello World" in html
|
||||||
|
assert 'href="/hello/"' in html
|
||||||
|
assert '<img src="/img/hello.jpg"' in html
|
||||||
|
assert "A test post" in html
|
||||||
|
|
||||||
|
def test_draft_status(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~post-card :title "Draft" :slug "draft" :href "/draft/"'
|
||||||
|
' :status "draft" :updated-at "15 Jan 2026"'
|
||||||
|
' :hx-select "#main-panel")',
|
||||||
|
**{"hx-select": "#main-panel", "updated-at": "15 Jan 2026"},
|
||||||
|
)
|
||||||
|
assert "Draft" in html
|
||||||
|
assert "bg-amber-100" in html
|
||||||
|
assert "Updated:" in html
|
||||||
|
|
||||||
|
def test_draft_with_publish_requested(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~post-card :title "Pending" :slug "pending" :href "/pending/"'
|
||||||
|
' :status "draft" :publish-requested true'
|
||||||
|
' :hx-select "#main-panel")',
|
||||||
|
**{"hx-select": "#main-panel", "publish-requested": True},
|
||||||
|
)
|
||||||
|
assert "Publish requested" in html
|
||||||
|
assert "bg-blue-100" in html
|
||||||
|
|
||||||
|
def test_no_image(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~post-card :title "No Img" :slug "no-img" :href "/no-img/"'
|
||||||
|
' :status "published" :hx-select "#main-panel")',
|
||||||
|
**{"hx-select": "#main-panel"},
|
||||||
|
)
|
||||||
|
assert "<img" not in html
|
||||||
|
|
||||||
|
def test_widgets_and_at_bar(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~post-card :title "T" :slug "s" :href "/"'
|
||||||
|
' :status "published" :hx-select "#mp"'
|
||||||
|
' :widgets-html "<div class=\\"widget\\">W</div>"'
|
||||||
|
' :at-bar-html "<div class=\\"at-bar\\">B</div>")',
|
||||||
|
**{
|
||||||
|
"hx-select": "#mp",
|
||||||
|
"widgets-html": '<div class="widget">W</div>',
|
||||||
|
"at-bar-html": '<div class="at-bar">B</div>',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert 'class="widget"' in html
|
||||||
|
assert 'class="at-bar"' in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~base-shell and ~error-page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBaseShell:
|
||||||
|
def test_renders_full_page(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~base-shell :title "Test" :asset-url "/static" (p "Hello"))',
|
||||||
|
**{"asset-url": "/static"},
|
||||||
|
)
|
||||||
|
assert "<!doctype html>" in html
|
||||||
|
assert "<html" in html
|
||||||
|
assert "<title>Test</title>" in html
|
||||||
|
assert "<p>Hello</p>" in html
|
||||||
|
assert "tailwindcss" in html
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorPage:
|
||||||
|
def test_404_page(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
|
||||||
|
**{"asset-url": "/static"},
|
||||||
|
)
|
||||||
|
assert "<!doctype html>" in html
|
||||||
|
assert "NOT FOUND" in html
|
||||||
|
assert "text-red-500" in html
|
||||||
|
assert "/static/errors/404.gif" in html
|
||||||
|
|
||||||
|
def test_error_page_no_image(self):
|
||||||
|
html = sexp(
|
||||||
|
'(~error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
|
||||||
|
**{"asset-url": "/static"},
|
||||||
|
)
|
||||||
|
assert "SERVER ERROR" in html
|
||||||
|
assert "<img" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# render_page() helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRenderPage:
|
||||||
|
def test_render_page(self):
|
||||||
|
from shared.sexp.page import render_page
|
||||||
|
|
||||||
|
html = render_page(
|
||||||
|
'(~error-page :title "Test" :message "MSG" :asset-url "/s")',
|
||||||
|
**{"asset-url": "/s"},
|
||||||
|
)
|
||||||
|
assert "<!doctype html>" in html
|
||||||
|
assert "MSG" in html
|
||||||
Reference in New Issue
Block a user