Files
rose-ash/shared/sexp/components.py
giles 8013317b41
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Phase 5: Page layouts as s-expressions — components, fragments, error pages
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>
2026-02-27 15:25:11 +00:00

301 lines
12 KiB
Python

"""
Shared s-expression component definitions.
Loaded at app startup via ``load_shared_components()``. Each component
replaces a per-service Jinja fragment template with a single reusable
s-expression definition.
"""
from __future__ import annotations
from .jinja_bridge import register_components
def load_shared_components() -> None:
"""Register all shared s-expression components."""
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)
# ---------------------------------------------------------------------------
# ~link-card
# ---------------------------------------------------------------------------
# Replaces: blog/templates/fragments/link_card.html
# market/templates/fragments/link_card.html
# events/templates/fragments/link_card.html
# federation/templates/fragments/link_card.html
# artdag/l1/app/templates/fragments/link_card.html
#
# Usage:
# sexp('(~link-card :link "/post/apple/" :title "Apple" :image "/img/a.jpg")')
# sexp('(~link-card :link url :title title :icon "fas fa-file-alt")', **ctx)
# ---------------------------------------------------------------------------
_LINK_CARD = '''
(defcomp ~link-card (&key link title image icon subtitle detail data-app)
(a :href link
: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
(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")
(div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
(i :class icon)))
(div :class "flex-1 min-w-0"
(div :class "font-medium text-stone-900 text-sm clamp-2" title)
(when subtitle
(div :class "text-xs text-stone-500 mt-0.5" subtitle))
(when 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"))))))
'''