Phase 6: Replace render_template() with s-expression rendering in all GET routes
Migrate ~52 GET route handlers across all 7 services from Jinja render_template() to s-expression component rendering. Each service gets a sexp_components.py with page/oob/cards render functions. - Add per-service sexp_components.py (account, blog, cart, events, federation, market, orders) with full page, OOB, and pagination card rendering - Add shared/sexp/helpers.py with call_url, root_header_html, full_page, oob_page utilities - Update all GET routes to use get_template_context() + render fns - Fix get_template_context() to inject Jinja globals (URL helpers) - Add qs_filter to base_context for sexp filter URL building - Mount sexp_components.py in docker-compose.dev.yml for all services - Import sexp_components in app.py for Hypercorn --reload watching - Fix route_prefix import (shared.utils not shared.infrastructure.urls) - Fix federation choose-username missing actor in context - Fix market page_markets missing post in context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,51 @@ from shared.utils import host_url
|
||||
from shared.browser.app.utils import current_route_relative_path
|
||||
|
||||
|
||||
def _qs_filter_fn():
|
||||
"""Build a qs_filter(dict) wrapper for sexp components, or None.
|
||||
|
||||
Sexp components call ``qs_fn({"page": 2})``, ``qs_fn({"sort": "az"})``,
|
||||
``qs_fn({"labels": ["organic", "local"]})``, etc.
|
||||
|
||||
Simple keys (page, sort, search, liked, clear_filters) are forwarded
|
||||
to ``makeqs(**kwargs)``. List-valued keys (labels, stickers, brands)
|
||||
represent *replacement* sets, so we rebuild the querystring from the
|
||||
current base with those overridden.
|
||||
"""
|
||||
factory = getattr(g, "makeqs_factory", None)
|
||||
if not factory:
|
||||
return None
|
||||
makeqs = factory()
|
||||
|
||||
def _qs(d: dict) -> str:
|
||||
from shared.browser.app.filters.qs_base import build_qs
|
||||
|
||||
# Collect list-valued overrides
|
||||
list_overrides = {}
|
||||
for plural, singular in (("labels", "label"), ("stickers", "sticker"), ("brands", "brand")):
|
||||
if plural in d:
|
||||
list_overrides[singular] = list(d[plural] or [])
|
||||
|
||||
simple = {k: v for k, v in d.items()
|
||||
if k in ("page", "sort", "search", "liked", "clear_filters")}
|
||||
|
||||
if not list_overrides:
|
||||
return makeqs(**simple)
|
||||
|
||||
# For list overrides: get the base qs, parse out the overridden keys,
|
||||
# then rebuild with the new values.
|
||||
base_qs = makeqs(**simple)
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
params = [(k, v) for k, v in parse_qsl(base_qs.lstrip("?"))
|
||||
if k not in list_overrides]
|
||||
for singular, vals in list_overrides.items():
|
||||
for v in vals:
|
||||
params.append((singular, v))
|
||||
return ("?" + urlencode(params)) if params else ""
|
||||
|
||||
return _qs
|
||||
|
||||
|
||||
async def base_context() -> dict:
|
||||
"""
|
||||
Common template variables available in every app.
|
||||
@@ -50,6 +95,7 @@ async def base_context() -> dict:
|
||||
("price-desc", "\u00a3 high\u2192low", "order/h-l.svg"),
|
||||
],
|
||||
"zap_filter": zap_filter,
|
||||
"qs_filter": _qs_filter_fn(),
|
||||
"print": print,
|
||||
"base_url": base_url,
|
||||
"base_title": config()["title"],
|
||||
|
||||
@@ -23,6 +23,18 @@ def load_shared_components() -> None:
|
||||
register_components(_POST_CARD)
|
||||
register_components(_BASE_SHELL)
|
||||
register_components(_ERROR_PAGE)
|
||||
# Phase 6: layout infrastructure
|
||||
register_components(_APP_SHELL)
|
||||
register_components(_APP_LAYOUT)
|
||||
register_components(_OOB_RESPONSE)
|
||||
register_components(_HEADER_ROW)
|
||||
register_components(_MENU_ROW)
|
||||
register_components(_NAV_LINK)
|
||||
register_components(_INFINITE_SCROLL)
|
||||
register_components(_STATUS_PILL)
|
||||
register_components(_SEARCH_MOBILE)
|
||||
register_components(_SEARCH_DESKTOP)
|
||||
register_components(_ORDER_SUMMARY_CARD)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -298,3 +310,425 @@ _ERROR_PAGE = '''
|
||||
(div :class "flex justify-center"
|
||||
(img :src image :width "300" :height "300"))))))
|
||||
'''
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Phase 6: Layout infrastructure components
|
||||
# ===================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~app-shell — full HTML document with all required CSS/JS assets
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: _types/root/index.html <html><head>...<body> shell
|
||||
#
|
||||
# This includes htmx, hyperscript, tailwind, fontawesome, prism, and
|
||||
# all shared CSS/JS. ``~base-shell`` remains the lightweight error-page
|
||||
# shell; ``~app-shell`` is for real app pages.
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_APP_SHELL = r'''
|
||||
(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
|
||||
(<>
|
||||
(raw! "<!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)
|
||||
(when meta-html (raw! meta-html))
|
||||
(style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }")
|
||||
(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")
|
||||
(script :src "https://unpkg.com/hyperscript.org@0.9.12")
|
||||
(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"))
|
||||
(link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet")
|
||||
(script :src "https://unpkg.com/prismjs/prism.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
|
||||
(script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
|
||||
(script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
|
||||
(script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}")
|
||||
(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')})")
|
||||
(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}"
|
||||
".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}"
|
||||
".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))
|
||||
(body :class "bg-stone-50 text-stone-900"
|
||||
(raw! body-html)
|
||||
(when body-end-html (raw! body-end-html))
|
||||
(script :src (str asset-url "/scripts/body.js"))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~app-layout — page body layout (header + filter + aside + main-panel)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: _types/root/index.html body structure
|
||||
#
|
||||
# The header uses a <details>/<summary> pattern for mobile menu toggle.
|
||||
# All content sections are passed as pre-rendered HTML strings.
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~app-layout :title t :asset-url a :header-rows-html h
|
||||
# :menu-html m :filter-html f :aside-html a :content-html c)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_APP_LAYOUT = r'''
|
||||
(defcomp ~app-layout (&key title asset-url meta-html menu-colour
|
||||
header-rows-html menu-html
|
||||
filter-html aside-html content-html
|
||||
body-end-html)
|
||||
(let* ((colour (or menu-colour "sky")))
|
||||
(~app-shell :title (or title "Rose Ash") :asset-url asset-url
|
||||
:meta-html meta-html :body-end-html body-end-html
|
||||
:body-html (str
|
||||
"<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-" colour "-500\">"
|
||||
"<div class=\"flex flex-col w-full items-center\">"
|
||||
header-rows-html
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</header>"
|
||||
"</summary>"
|
||||
"<div id=\"root-menu\" hx-swap-oob=\"outerHTML\" class=\"md:hidden\">"
|
||||
(or menu-html "")
|
||||
"</div>"
|
||||
"</details>"
|
||||
"</div>"
|
||||
"<div id=\"filter\">"
|
||||
(or filter-html "")
|
||||
"</div>"
|
||||
"<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\">"
|
||||
(or aside-html "")
|
||||
"</aside>"
|
||||
"<section id=\"main-panel\" class=\"flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport\">"
|
||||
(or content-html "")
|
||||
"<div class=\"pb-8\"></div>"
|
||||
"</section>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</main>"
|
||||
"</div>"))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~oob-response — HTMX OOB multi-target swap wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: oob_elements.html base template
|
||||
#
|
||||
# Each named region gets hx-swap-oob="outerHTML" on its wrapper div.
|
||||
# The oobs-html param contains any extra OOB elements (header row swaps).
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~oob-response :oobs-html oh :filter-html fh :aside-html ah
|
||||
# :menu-html mh :content-html ch)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OOB_RESPONSE = '''
|
||||
(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"
|
||||
(when filter-html (raw! filter-html)))
|
||||
(aside :id "aside" :hx-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"
|
||||
(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)))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~header-row — root header bar (cart-mini, title, nav-tree, auth-menu)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: _types/root/header/_header.html header_row macro
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~header-row :cart-mini-html cm :blog-url bu :site-title st
|
||||
# :nav-tree-html nh :auth-menu-html ah :nav-panel-html np
|
||||
# :settings-url su :is-admin ia)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HEADER_ROW = '''
|
||||
(defcomp ~header-row (&key cart-mini-html blog-url site-title
|
||||
nav-tree-html auth-menu-html nav-panel-html
|
||||
settings-url is-admin oob hamburger-html)
|
||||
(<>
|
||||
(div :id "root-row"
|
||||
:hx-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))
|
||||
(div :class "font-bold text-5xl flex-1"
|
||||
(a :href (str (or blog-url "") "/") :class "flex justify-center md:justify-start"
|
||||
(h1 (or site-title ""))))
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(when nav-tree-html (raw! nav-tree-html))
|
||||
(when auth-menu-html (raw! auth-menu-html))
|
||||
(when nav-panel-html (raw! nav-panel-html))
|
||||
(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"))))
|
||||
(when hamburger-html (raw! hamburger-html))))
|
||||
(div :class "block md:hidden text-md font-bold"
|
||||
(when auth-menu-html (raw! auth-menu-html)))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~menu-row — section header row (wraps in colored bar)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: macros/links.html menu_row macro
|
||||
#
|
||||
# Each nested header row gets a progressively lighter background.
|
||||
# The route handler passes the level (0-based depth after root).
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~menu-row :id "auth-row" :level 1 :colour "sky"
|
||||
# :link-href url :link-label "account" :icon "fa-solid fa-user"
|
||||
# :nav-html nh :child-id "auth-header-child" :child-html ch)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MENU_ROW = '''
|
||||
(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon
|
||||
hx-select nav-html child-id child-html oob)
|
||||
(let* ((c (or colour "sky"))
|
||||
(lv (or level 1))
|
||||
(shade (str (- 500 (* lv 100)))))
|
||||
(<>
|
||||
(div :id id
|
||||
:hx-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 link-href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select (or hx-select "#main-panel")
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "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)
|
||||
(when link-label (div link-label)))))
|
||||
(when nav-html
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(raw! nav-html))))
|
||||
(when child-id
|
||||
(div :id child-id :class "flex flex-col w-full items-center"
|
||||
(when child-html (raw! child-html)))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~nav-link — HTMX navigation link (replaces macros/links.html link macro)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NAV_LINK = '''
|
||||
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
|
||||
(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"
|
||||
:class (or aclass
|
||||
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
|
||||
(or select-colours "")))
|
||||
(when icon (i :class icon :aria-hidden "true"))
|
||||
(when label (span label)))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~infinite-scroll — pagination sentinel for table-based lists
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: sentinel pattern in _rows.html templates
|
||||
#
|
||||
# For table rows (orders, etc.): renders <tr> with intersection observer.
|
||||
# Uses hyperscript for retry with exponential backoff.
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~infinite-scroll :url next-url :page p :total-pages tp
|
||||
# :id-prefix "orders" :colspan 5)', **ctx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INFINITE_SCROLL = r'''
|
||||
(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… " 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… " 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>"))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~status-pill — colored status indicator
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replaces: inline Jinja status pill patterns across templates
|
||||
#
|
||||
# Usage:
|
||||
# sexp('(~status-pill :status s :size "sm")', status="paid")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATUS_PILL = '''
|
||||
(defcomp ~status-pill (&key status size)
|
||||
(let* ((s (or status "pending"))
|
||||
(lower (lower s))
|
||||
(sz (or size "xs"))
|
||||
(colours (cond
|
||||
(= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
|
||||
(or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
|
||||
(= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
|
||||
(= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
|
||||
true "border-stone-300 bg-stone-50 text-stone-700")))
|
||||
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
|
||||
s)))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~search-mobile — mobile search input with htmx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEARCH_MOBILE = '''
|
||||
(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
|
||||
(div :id "search-mobile-wrapper"
|
||||
:class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||
(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
|
||||
: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"
|
||||
: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))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~search-desktop — desktop search input with htmx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEARCH_DESKTOP = '''
|
||||
(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
|
||||
(div :id "search-desktop-wrapper"
|
||||
:class "flex flex-row gap-2 items-center"
|
||||
(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
|
||||
: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"
|
||||
: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))))))
|
||||
'''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ~order-summary-card — reusable order summary card
|
||||
# ---------------------------------------------------------------------------
|
||||
_ORDER_SUMMARY_CARD = r'''
|
||||
(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"
|
||||
(p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
|
||||
(p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
|
||||
(p (span :class "font-medium" "Description:") " " (or description "\u2013"))
|
||||
(p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
|
||||
(p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
|
||||
(p (span :class "font-medium" "Total:") " "
|
||||
(if total-amount
|
||||
(str (or currency "GBP") " " total-amount)
|
||||
"\u2013"))))
|
||||
'''
|
||||
|
||||
108
shared/sexp/helpers.py
Normal file
108
shared/sexp/helpers.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Shared helper functions for s-expression page rendering.
|
||||
|
||||
These are used by per-service sexp_components.py files to build common
|
||||
page elements (headers, search, etc.) from template context.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .jinja_bridge import sexp
|
||||
from .page import HAMBURGER_HTML, SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||
|
||||
|
||||
def call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||
"""Call a URL helper from context (e.g., blog_url, account_url)."""
|
||||
fn = ctx.get(key)
|
||||
if callable(fn):
|
||||
return fn(path)
|
||||
return str(fn or "") + path
|
||||
|
||||
|
||||
def get_asset_url(ctx: dict) -> str:
|
||||
"""Extract the asset URL base from context."""
|
||||
au = ctx.get("asset_url")
|
||||
if callable(au):
|
||||
result = au("")
|
||||
return result.rsplit("/", 1)[0] if "/" in result else result
|
||||
return au or ""
|
||||
|
||||
|
||||
def root_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the root header row HTML."""
|
||||
return sexp(
|
||||
'(~header-row :cart-mini-html cmi :blog-url bu :site-title st'
|
||||
' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph'
|
||||
' :hamburger-html hh :oob oob)',
|
||||
cmi=ctx.get("cart_mini_html", ""),
|
||||
bu=call_url(ctx, "blog_url", ""),
|
||||
st=ctx.get("base_title", ""),
|
||||
nth=ctx.get("nav_tree_html", ""),
|
||||
amh=ctx.get("auth_menu_html", ""),
|
||||
nph=ctx.get("nav_panel_html", ""),
|
||||
hh=HAMBURGER_HTML,
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def search_mobile_html(ctx: dict) -> str:
|
||||
"""Build mobile search input HTML."""
|
||||
return sexp(
|
||||
'(~search-mobile :current-local-href clh :search s :search-count sc'
|
||||
' :hx-select hs :search-headers-mobile shm)',
|
||||
clh=ctx.get("current_local_href", "/"),
|
||||
s=ctx.get("search", ""),
|
||||
sc=ctx.get("search_count", ""),
|
||||
hs=ctx.get("hx_select", "#main-panel"),
|
||||
shm=SEARCH_HEADERS_MOBILE,
|
||||
)
|
||||
|
||||
|
||||
def search_desktop_html(ctx: dict) -> str:
|
||||
"""Build desktop search input HTML."""
|
||||
return sexp(
|
||||
'(~search-desktop :current-local-href clh :search s :search-count sc'
|
||||
' :hx-select hs :search-headers-desktop shd)',
|
||||
clh=ctx.get("current_local_href", "/"),
|
||||
s=ctx.get("search", ""),
|
||||
sc=ctx.get("search_count", ""),
|
||||
hs=ctx.get("hx_select", "#main-panel"),
|
||||
shd=SEARCH_HEADERS_DESKTOP,
|
||||
)
|
||||
|
||||
|
||||
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 sexp(
|
||||
'(~app-layout :title t :asset-url au :meta-html mh'
|
||||
' :header-rows-html hrh :menu-html muh :filter-html fh'
|
||||
' :aside-html ash :content-html ch :body-end-html beh)',
|
||||
t=ctx.get("base_title", "Rose Ash"),
|
||||
au=get_asset_url(ctx),
|
||||
mh=meta_html,
|
||||
hrh=header_rows_html,
|
||||
muh=menu_html,
|
||||
fh=filter_html,
|
||||
ash=aside_html,
|
||||
ch=content_html,
|
||||
beh=body_end_html,
|
||||
)
|
||||
|
||||
|
||||
def oob_page(ctx: dict, *, oobs_html: str = "",
|
||||
filter_html: str = "", aside_html: str = "",
|
||||
content_html: str = "", menu_html: str = "") -> str:
|
||||
"""Render an OOB response with standard swap targets."""
|
||||
return sexp(
|
||||
'(~oob-response :oobs-html oh :filter-html fh :aside-html ash'
|
||||
' :menu-html mh :content-html ch)',
|
||||
oh=oobs_html,
|
||||
fh=filter_html,
|
||||
ash=aside_html,
|
||||
mh=menu_html,
|
||||
ch=content_html,
|
||||
)
|
||||
@@ -5,15 +5,23 @@ 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.
|
||||
|
||||
``render_sexp_response()`` is the main entry point for GET route handlers:
|
||||
it calls the app's context processor, merges in route-specific kwargs,
|
||||
renders the s-expression to HTML, and returns a Quart ``Response``.
|
||||
|
||||
Usage::
|
||||
|
||||
from shared.sexp.page import render_page
|
||||
from shared.sexp.page import render_page, render_sexp_response
|
||||
|
||||
# Error pages (no context needed)
|
||||
html = render_page(
|
||||
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
|
||||
image="/static/errors/404.gif",
|
||||
asset_url="/static",
|
||||
)
|
||||
|
||||
# GET route handlers (auto-injects app context)
|
||||
resp = await render_sexp_response('(~orders-page :orders orders)', orders=orders)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +30,22 @@ from typing import Any
|
||||
|
||||
from .jinja_bridge import sexp
|
||||
|
||||
# HTML constants used by layout components — kept here to avoid
|
||||
# s-expression parser issues with embedded quotes in SVG.
|
||||
HAMBURGER_HTML = (
|
||||
'<div class="md:hidden bg-stone-200 rounded">'
|
||||
'<svg class="h-12 w-12 transition-transform group-open/root:hidden block self-start"'
|
||||
' viewBox="0 0 24 24" fill="none" stroke="currentColor">'
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"'
|
||||
' d="M4 6h16M4 12h16M4 18h16"/></svg>'
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24"'
|
||||
' class="w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start">'
|
||||
'<path d="M6 9l6 6 6-6" fill="currentColor"/></svg></div>'
|
||||
)
|
||||
|
||||
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
|
||||
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
|
||||
|
||||
|
||||
def render_page(source: str, **kwargs: Any) -> str:
|
||||
"""Render a full HTML page from an s-expression string.
|
||||
@@ -30,3 +54,52 @@ def render_page(source: str, **kwargs: Any) -> str:
|
||||
intent explicit in call sites (rendering a whole page, not a fragment).
|
||||
"""
|
||||
return sexp(source, **kwargs)
|
||||
|
||||
|
||||
async def get_template_context(**kwargs: Any) -> dict[str, Any]:
|
||||
"""Gather the full template context from all registered context processors.
|
||||
|
||||
Returns a dict with all context variables that would normally be
|
||||
available in a Jinja template, merged with any extra kwargs.
|
||||
"""
|
||||
import asyncio
|
||||
from quart import current_app, request
|
||||
|
||||
ctx: dict[str, Any] = {}
|
||||
|
||||
# App-level context processors
|
||||
for proc in current_app.template_context_processors.get(None, []):
|
||||
rv = proc()
|
||||
if asyncio.iscoroutine(rv):
|
||||
rv = await rv
|
||||
ctx.update(rv)
|
||||
|
||||
# Blueprint-scoped context processors
|
||||
for bp_name in (request.blueprints or []):
|
||||
for proc in current_app.template_context_processors.get(bp_name, []):
|
||||
rv = proc()
|
||||
if asyncio.iscoroutine(rv):
|
||||
rv = await rv
|
||||
ctx.update(rv)
|
||||
|
||||
# Inject Jinja globals that s-expression components need (URL helpers,
|
||||
# asset_url, site, etc.) — these aren't provided by context processors.
|
||||
for key, val in current_app.jinja_env.globals.items():
|
||||
if key not in ctx and callable(val):
|
||||
ctx[key] = val
|
||||
|
||||
ctx.update(kwargs)
|
||||
return ctx
|
||||
|
||||
|
||||
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.)
|
||||
and merges them with the caller's kwargs before rendering.
|
||||
|
||||
Returns the rendered HTML string (caller wraps in Response as needed).
|
||||
"""
|
||||
ctx = await get_template_context(**kwargs)
|
||||
return sexp(source, **ctx)
|
||||
|
||||
Reference in New Issue
Block a user