diff --git a/account/bp/fragments/routes.py b/account/bp/fragments/routes.py index b21a601..6574e8f 100644 --- a/account/bp/fragments/routes.py +++ b/account/bp/fragments/routes.py @@ -9,7 +9,7 @@ Fragments: 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 @@ -22,10 +22,13 @@ def register(): # --------------------------------------------------------------- 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", "") - return await render_template( - "fragments/auth_menu.html", - user_email=user_email, + return render_sexp( + '(~auth-menu :user-email user-email :account-url account-url)', + **{"user-email": user_email or None, "account-url": account_url("")}, ) _handlers = { diff --git a/cart/bp/fragments/routes.py b/cart/bp/fragments/routes.py index c60cc52..bdeab0b 100644 --- a/cart/bp/fragments/routes.py +++ b/cart/bp/fragments/routes.py @@ -10,7 +10,7 @@ Fragments: 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 @@ -24,6 +24,8 @@ def register(): async def _cart_mini(): 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) session_id = request.args.get("session_id") @@ -33,17 +35,18 @@ def register(): ) count = summary.count + summary.calendar_count + summary.ticket_count 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(): from shared.infrastructure.urls import cart_url + from shared.sexp.jinja_bridge import sexp as render_sexp - href = cart_url("/orders/") - return ( - '' + return render_sexp( + '(~account-nav-item :href href :label "orders")', + href=cart_url("/orders/"), ) _handlers = { diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py index 9f67b05..3f8e7a3 100644 --- a/events/bp/fragments/routes.py +++ b/events/bp/fragments/routes.py @@ -35,6 +35,10 @@ def register(): # --- container-nav fragment: calendar entries + calendar links ----------- 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_id = int(request.args.get("container_id", 0)) post_slug = request.args.get("post_slug", "") @@ -43,6 +47,8 @@ def register(): exclude = request.args.get("exclude", "") 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 = [] # Calendar entries nav @@ -50,23 +56,41 @@ def register(): entries, has_more = await services.calendar.associated_entries( g.s, container_type, container_id, page, ) - if entries: - html_parts.append(await render_template( - "fragments/container_nav_entries.html", - entries=entries, has_more=has_more, - page=page, post_slug=post_slug, - paginate_url_base=paginate_url_base, + for entry in entries: + entry_path = ( + f"/{post_slug}/calendars/{entry.calendar_slug}/" + f"{entry.start_at.year}/{entry.start_at.month}/" + f"{entry.start_at.day}/entries/{entry.id}/" + ) + 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'
' + ) # Calendar links nav if not any(e.startswith("calendar") for e in excludes): calendars = await services.calendar.calendars_for_container( g.s, container_type, container_id, ) - if calendars: - html_parts.append(await render_template( - "fragments/container_nav_calendars.html", - calendars=calendars, post_slug=post_slug, + for cal in calendars: + href = events_url(f"/{post_slug}/calendars/{cal.slug}/") + html_parts.append(render_sexp( + '(~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) @@ -99,7 +123,28 @@ def register(): # --- account-nav-item fragment: tickets + bookings links for account nav - 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'' + ) + return "\n".join(parts) _handlers["account-nav-item"] = _account_nav_item_handler diff --git a/market/bp/fragments/routes.py b/market/bp/fragments/routes.py index 91d5668..358253a 100644 --- a/market/bp/fragments/routes.py +++ b/market/bp/fragments/routes.py @@ -33,6 +33,10 @@ def register(): # --- container-nav fragment: market links -------------------------------- 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_id = int(request.args.get("container_id", 0)) post_slug = request.args.get("post_slug", "") @@ -42,10 +46,16 @@ def register(): ) if not markets: return "" - return await render_template( - "fragments/container_nav_markets.html", - markets=markets, post_slug=post_slug, - ) + styles = current_app.jinja_env.globals.get("styles", {}) + nav_class = styles.get("nav_button_less_pad", "") + 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 diff --git a/orders/bp/fragments/routes.py b/orders/bp/fragments/routes.py index b4cbbc4..15bbbd5 100644 --- a/orders/bp/fragments/routes.py +++ b/orders/bp/fragments/routes.py @@ -14,12 +14,11 @@ def register(): async def _account_nav_item(): from shared.infrastructure.urls import orders_url - href = orders_url("/") - return ( - '' + from shared.sexp.jinja_bridge import sexp as render_sexp + + return render_sexp( + '(~account-nav-item :href href :label "orders")', + href=orders_url("/"), ) _handlers = { diff --git a/shared/browser/app/errors.py b/shared/browser/app/errors.py index ce638fc..9d3b0c6 100644 --- a/shared/browser/app/errors.py +++ b/shared/browser/app/errors.py @@ -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 _info(e): return { @@ -82,10 +94,17 @@ def errors(app): errnum='404' ) else: - html = await render_template( - "_types/root/exceptions/_.html", - errnum='404', - ) + # Render via s-expressions (Phase 5 proof-of-concept) + try: + 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) @@ -98,10 +117,16 @@ def errors(app): errnum='403' ) else: - html = await render_template( - "_types/root/exceptions/_.html", - errnum='403', - ) + try: + html = _sexp_error_page( + "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) diff --git a/shared/sexp/components.py b/shared/sexp/components.py index d4acfec..7513f49 100644 --- a/shared/sexp/components.py +++ b/shared/sexp/components.py @@ -14,6 +14,15 @@ 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) # --------------------------------------------------------------------------- @@ -49,3 +58,243 @@ _LINK_CARD = ''' (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 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! "") + (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")))))) +''' diff --git a/shared/sexp/page.py b/shared/sexp/page.py new file mode 100644 index 0000000..29b038c --- /dev/null +++ b/shared/sexp/page.py @@ -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) diff --git a/shared/sexp/tests/test_components.py b/shared/sexp/tests/test_components.py new file mode 100644 index 0000000..72c5f10 --- /dev/null +++ b/shared/sexp/tests/test_components.py @@ -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 "W"' + ' :at-bar-html "
B
")', + **{ + "hx-select": "#mp", + "widgets-html": '
W
', + "at-bar-html": '
B
', + }, + ) + 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 "" in html + assert "Test" in html + assert "

Hello

" 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 "" 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 "" in html + assert "MSG" in html