Add macros, declarative handlers (defhandler), and convert all fragment routes to sx
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Phase 1 — Macros: defmacro + quasiquote syntax (`, ,, ,@) in parser,
evaluator, HTML renderer, and JS mirror. Macro type, expansion, and
round-trip serialization.

Phase 2 — Expanded primitives: app-url, url-for, asset-url, config,
format-date, parse-int (pure); service, request-arg, request-path,
nav-tree, get-children (I/O); jinja-global, relations-from (pure).
Updated _io_service to accept (service "registry-name" "method" :kwargs)
with auto kebab→snake conversion. DTO-to-dict now expands datetime fields
into year/month/day convenience keys. Tuple returns converted to lists.

Phase 3 — Declarative handlers: HandlerDef type, defhandler special form,
handler registry (service → name → HandlerDef), async evaluator+renderer
(async_eval.py) that awaits I/O primitives inline within control flow.
Handler loading from .sx files, execute_handler, blueprint factory.

Phase 4 — Convert all fragment routes: 13 Python fragment handlers across
8 services replaced with declarative .sx handler files. All routes.py
simplified to uniform sx dispatch pattern. Two Jinja HTML handlers
(events/container-cards, events/account-page) kept as Python.

New files: shared/sx/async_eval.py, shared/sx/handlers.py,
shared/sx/tests/test_handlers.py, plus 13 handler .sx files under
{service}/sx/handlers/. MarketService.product_by_slug() added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 00:22:18 +00:00
parent 13bcf755f6
commit ab75e505a8
48 changed files with 2538 additions and 638 deletions

View File

@@ -2,6 +2,12 @@
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Most handlers are defined declaratively in .sx files under
``events/sx/handlers/`` and dispatched via the sx handler registry.
Jinja HTML handlers (container-cards, account-page) remain as Python
because they return ``text/html`` templates, not sx source.
"""
from __future__ import annotations
@@ -9,9 +15,8 @@ from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
from shared.services.registry import services
from shared.sx.handlers import get_handler, execute_handler
def register():
@@ -19,7 +24,7 @@ def register():
_handlers: dict[str, object] = {}
# Fragment types that still return HTML (Jinja templates)
# Fragment types that return HTML (Jinja templates)
_html_types = {"container-cards", "account-page"}
@bp.before_request
@@ -29,78 +34,24 @@ def register():
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
# 1. Check Python handlers first (Jinja HTML types)
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/sx")
result = await handler()
ct = "text/html" if fragment_type in _html_types else "text/sx"
return Response(result, status=200, content_type=ct)
if handler is not None:
result = await handler()
ct = "text/html" if fragment_type in _html_types else "text/sx"
return Response(result, status=200, content_type=ct)
# --- 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.sx.helpers import sx_call
container_type = request.args.get("container_type", "page")
container_id = int(request.args.get("container_id", 0))
post_slug = request.args.get("post_slug", "")
paginate_url_base = request.args.get("paginate_url", "")
page = int(request.args.get("page", 1))
exclude = request.args.get("exclude", "")
excludes = [e.strip() for e in exclude.split(",") if e.strip()]
current_calendar = request.args.get("current_calendar", "")
styles = current_app.jinja_env.globals.get("styles", {})
nav_class = styles.get("nav_button", "")
select_colours = current_app.jinja_env.globals.get("select_colours", "")
parts = []
# Calendar entries nav
if not any(e.startswith("calendar") for e in excludes):
entries, has_more = await services.calendar.associated_entries(
g.s, container_type, container_id, page,
# 2. Check sx handler registry
handler_def = get_handler("events", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "events", args=dict(request.args),
)
for entry in entries:
entry_path = (
f"/{post_slug}/{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')}"
parts.append(sx_call("calendar-entry-nav",
href=events_url(entry_path), name=entry.name,
date_str=date_str, nav_class=nav_class))
if has_more and paginate_url_base:
parts.append(sx_call("htmx-sentinel",
id=f"entries-load-sentinel-{page}",
hx_get=f"{paginate_url_base}?page={page + 1}",
hx_trigger="intersect once",
hx_swap="beforebegin",
**{"class": "flex-shrink-0 w-1"}))
return Response(result, status=200, content_type="text/sx")
# 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,
)
for cal in calendars:
href = events_url(f"/{post_slug}/{cal.slug}/")
is_selected = (cal.slug == current_calendar) if current_calendar else False
parts.append(sx_call("calendar-link-nav",
href=href, name=cal.name, nav_class=nav_class,
is_selected=is_selected, select_colours=select_colours))
return Response("", status=200, content_type="text/sx")
if not parts:
return ""
return "(<> " + " ".join(parts) + ")"
_handlers["container-nav"] = _container_nav_handler
# --- container-cards fragment: entries for blog listing cards (still Jinja) --
# --- container-cards fragment: entries for blog listing cards (Jinja HTML) --
async def _container_cards_handler():
post_ids_raw = request.args.get("post_ids", "")
@@ -122,30 +73,7 @@ def register():
_handlers["container-cards"] = _container_cards_handler
# --- account-nav-item fragment: tickets + bookings links -----------------
async def _account_nav_item_handler():
from quart import current_app
from shared.infrastructure.urls import account_url
from shared.sx.helpers import sx_call
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/")
parts = []
for href, label in [(tickets_url, "tickets"), (bookings_url, "bookings")]:
parts.append(sx_call("nav-group-link",
href=href, hx_select=hx_select, nav_class=nav_class, label=label))
return "(<> " + " ".join(parts) + ")"
_handlers["account-nav-item"] = _account_nav_item_handler
# --- account-page fragment: tickets or bookings panel (still Jinja) ------
# --- account-page fragment: tickets or bookings panel (Jinja HTML) ------
async def _account_page_handler():
slug = request.args.get("slug", "")
@@ -169,52 +97,6 @@ def register():
_handlers["account-page"] = _account_page_handler
# --- link-card fragment: event page preview card -------------------------
async def _link_card_handler():
from shared.infrastructure.urls import events_url
from shared.sx.helpers import sx_call
slug = request.args.get("slug", "")
keys_raw = request.args.get("keys", "")
def _event_link_card_sx(post, cal_names: str) -> str:
return sx_call("link-card",
title=post.title, image=post.feature_image,
subtitle=cal_names,
link=events_url(f"/{post.slug}"))
# Batch mode
if keys_raw:
slugs = [k.strip() for k in keys_raw.split(",") if k.strip()]
parts = []
for s in slugs:
parts.append(f"<!-- fragment:{s} -->")
raw = await fetch_data("blog", "post-by-slug", params={"slug": s}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if post:
calendars = await services.calendar.calendars_for_container(
g.s, "page", post.id,
)
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
parts.append(_event_link_card_sx(post, cal_names))
return "\n".join(parts)
# Single mode
if not slug:
return ""
raw = await fetch_data("blog", "post-by-slug", params={"slug": slug}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if not post:
return ""
calendars = await services.calendar.calendars_for_container(
g.s, "page", post.id,
)
cal_names = ", ".join(c.name for c in calendars) if calendars else ""
return _event_link_card_sx(post, cal_names)
_handlers["link-card"] = _link_card_handler
bp._fragment_handlers = _handlers
return bp

View File

@@ -0,0 +1,19 @@
;; Events account-nav-item fragment handler
;;
;; Renders tickets + bookings links for the account dashboard nav.
(defhandler account-nav-item (&key)
(let ((styles (or (jinja-global "styles") (dict)))
(nav-class (or (get styles "nav_button") ""))
(hx-select "#main-panel, #search-mobile, #search-count-mobile, #search-desktop, #search-count-desktop, #menu-items-nav-wrapper"))
(<>
(~nav-group-link
:href (app-url "account" "/tickets/")
:hx-select hx-select
:nav-class nav-class
:label "tickets")
(~nav-group-link
:href (app-url "account" "/bookings/")
:hx-select hx-select
:nav-class nav-class
:label "bookings"))))

View File

@@ -0,0 +1,81 @@
;; Events container-nav fragment handler
;;
;; Renders calendar entry nav items + calendar link nav items
;; for the scrollable navigation panel on blog post pages.
;;
;; Params (from request.args):
;; container_type — "page" (default)
;; container_id — int
;; post_slug — string
;; paginate_url — base URL for infinite scroll
;; page — current page (default 1)
;; exclude — comma-separated exclusion prefixes
;; current_calendar — currently selected calendar slug
(defhandler container-nav
(&key container_type container_id post_slug paginate_url page exclude current_calendar)
(let ((ct (or container_type "page"))
(cid (parse-int (or container_id "0")))
(slug (or post_slug ""))
(purl (or paginate_url ""))
(pg (parse-int (or page "1")))
(excl-raw (or exclude ""))
(cur-cal (or current_calendar ""))
(excludes (filter (fn (e) (not (empty? e)))
(map trim (split excl-raw ","))))
(has-cal-excl (not (empty? (filter (fn (e) (starts-with? e "calendar"))
excludes))))
(styles (or (jinja-global "styles") (dict)))
(nav-class (or (get styles "nav_button") ""))
(sel-colours (or (jinja-global "select_colours") "")))
;; Only render if no calendar-* exclusion
(when (not has-cal-excl)
(let ((result (service "calendar" "associated-entries"
:content-type ct :content-id cid :page pg))
(entries (first result))
(has-more (nth result 1))
(calendars (service "calendar" "calendars-for-container"
:container-type ct :container-id cid)))
(<>
;; Calendar entry nav items
(map (fn (entry)
(let ((entry-path (str "/" slug "/"
(get entry "calendar_slug") "/"
(get entry "start_at_year") "/"
(get entry "start_at_month") "/"
(get entry "start_at_day")
"/entries/" (get entry "id") "/"))
(date-str (str (format-date (get entry "start_at") "%b %d, %Y at %H:%M")
(if (get entry "end_at")
(str " " (format-date (get entry "end_at") "%H:%M"))
""))))
(~calendar-entry-nav
:href (app-url "events" entry-path)
:name (get entry "name")
:date-str date-str
:nav-class nav-class))) entries)
;; Infinite scroll sentinel
(when (and has-more (not (empty? purl)))
(~htmx-sentinel
:id (str "entries-load-sentinel-" pg)
:hx-get (str purl "?page=" (+ pg 1))
:hx-trigger "intersect once"
:hx-swap "beforebegin"
:class "flex-shrink-0 w-1"))
;; Calendar link nav items
(map (fn (cal)
(let ((href (app-url "events" (str "/" slug "/" (get cal "slug") "/")))
(is-selected (if (not (empty? cur-cal))
(= (get cal "slug") cur-cal)
false)))
(~calendar-link-nav
:href href
:name (get cal "name")
:nav-class nav-class
:is-selected is-selected
:select-colours sel-colours))) calendars))))))

View File

@@ -0,0 +1,34 @@
;; Events link-card fragment handler
;;
;; Renders event page preview card(s) by slug.
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).
(defhandler link-card (&key slug keys)
(if keys
(let ((slugs (filter (fn (s) (not (empty? s)))
(map trim (split keys ",")))))
(<> (map (fn (s)
(let ((post (query "blog" "post-by-slug" :slug s)))
(<> (str "<!-- fragment:" s " -->")
(when post
(let ((calendars (service "calendar" "calendars-for-container"
:container-type "page"
:container-id (get post "id")))
(cal-names (join ", " (map (fn (c) (get c "name")) calendars))))
(~link-card
:title (get post "title")
:image (get post "feature_image")
:subtitle cal-names
:link (app-url "events" (str "/" (get post "slug"))))))))) slugs)))
(when slug
(let ((post (query "blog" "post-by-slug" :slug slug)))
(when post
(let ((calendars (service "calendar" "calendars-for-container"
:container-type "page"
:container-id (get post "id")))
(cal-names (join ", " (map (fn (c) (get c "name")) calendars))))
(~link-card
:title (get post "title")
:image (get post "feature_image")
:subtitle cal-names
:link (app-url "events" (str "/" (get post "slug"))))))))))

View File

@@ -21,8 +21,9 @@ from shared.sx.helpers import (
)
from shared.sx.parser import SxExpr
# Load events-specific .sx components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# Load events-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="events")
# ---------------------------------------------------------------------------