From ab75e505a849a8d111000338cc04b2c0fafbe7b5 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 00:22:18 +0000 Subject: [PATCH] Add macros, declarative handlers (defhandler), and convert all fragment routes to sx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- account/bp/fragments/routes.py | 38 +- account/sx/handlers/auth-menu.sx | 8 + account/sx/sx_components.py | 5 +- blog/bp/fragments/routes.py | 149 +---- blog/sx/handlers/link-card.sx | 31 + blog/sx/handlers/nav-tree.sx | 80 +++ blog/sx/sx_components.py | 4 +- cart/bp/fragments/routes.py | 60 +- cart/sx/handlers/account-nav-item.sx | 8 + cart/sx/handlers/cart-mini.sx | 16 + cart/sx/sx_components.py | 5 +- events/bp/fragments/routes.py | 162 +---- events/sx/handlers/account-nav-item.sx | 19 + events/sx/handlers/container-nav.sx | 81 +++ events/sx/handlers/link-card.sx | 34 + events/sx/sx_components.py | 5 +- federation/bp/fragments/routes.py | 68 +- federation/sx/handlers/link-card.sx | 40 ++ federation/sx/sx_components.py | 5 +- market/bp/fragments/routes.py | 101 +-- market/sx/handlers/container-nav.sx | 21 + market/sx/handlers/link-card.sx | 42 ++ market/sx/sx_components.py | 5 +- orders/bp/fragments/routes.py | 34 +- orders/sx/handlers/account-nav-item.sx | 8 + orders/sx/sx_components.py | 5 +- relations/app.py | 1 + relations/bp/fragments/routes.py | 82 +-- relations/sx/__init__.py | 0 relations/sx/handlers/container-nav.sx | 47 ++ relations/sx/sx_components.py | 14 + shared/contracts/protocols.py | 2 + shared/services/market_impl.py | 6 + shared/static/scripts/sx.js | 103 ++- shared/sx/__init__.py | 4 + shared/sx/async_eval.py | 837 +++++++++++++++++++++++++ shared/sx/evaluator.py | 141 ++++- shared/sx/handlers.py | 205 ++++++ shared/sx/helpers.py | 38 +- shared/sx/html.py | 13 +- shared/sx/jinja_bridge.py | 65 +- shared/sx/parser.py | 23 + shared/sx/primitives.py | 78 +++ shared/sx/primitives_io.py | 151 +++++ shared/sx/tests/test_evaluator.py | 81 ++- shared/sx/tests/test_handlers.py | 159 +++++ shared/sx/tests/test_parser.py | 46 ++ shared/sx/types.py | 46 +- 48 files changed, 2538 insertions(+), 638 deletions(-) create mode 100644 account/sx/handlers/auth-menu.sx create mode 100644 blog/sx/handlers/link-card.sx create mode 100644 blog/sx/handlers/nav-tree.sx create mode 100644 cart/sx/handlers/account-nav-item.sx create mode 100644 cart/sx/handlers/cart-mini.sx create mode 100644 events/sx/handlers/account-nav-item.sx create mode 100644 events/sx/handlers/container-nav.sx create mode 100644 events/sx/handlers/link-card.sx create mode 100644 federation/sx/handlers/link-card.sx create mode 100644 market/sx/handlers/container-nav.sx create mode 100644 market/sx/handlers/link-card.sx create mode 100644 orders/sx/handlers/account-nav-item.sx create mode 100644 relations/sx/__init__.py create mode 100644 relations/sx/handlers/container-nav.sx create mode 100644 relations/sx/sx_components.py create mode 100644 shared/sx/async_eval.py create mode 100644 shared/sx/handlers.py create mode 100644 shared/sx/tests/test_handlers.py diff --git a/account/bp/fragments/routes.py b/account/bp/fragments/routes.py index 0512696..28a4362 100644 --- a/account/bp/fragments/routes.py +++ b/account/bp/fragments/routes.py @@ -3,8 +3,8 @@ Exposes sx fragments at ``/internal/fragments/`` for consumption by other coop apps via the fragment client. -Fragments: - auth-menu Desktop + mobile auth menu (sign-in or user link) +All handlers are defined declaratively in .sx files under +``account/sx/handlers/`` and dispatched via the sx handler registry. """ from __future__ import annotations @@ -12,32 +12,12 @@ from __future__ import annotations from quart import Blueprint, Response, request from shared.infrastructure.fragments import FRAGMENT_HEADER +from shared.sx.handlers import get_handler, execute_handler def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - # --------------------------------------------------------------- - # Fragment handlers — return sx source text - # --------------------------------------------------------------- - - async def _auth_menu(): - from shared.infrastructure.urls import account_url - from shared.sx.helpers import sx_call - - user_email = request.args.get("email", "") - return sx_call("auth-menu", - user_email=user_email or None, - account_url=account_url("")) - - _handlers = { - "auth-menu": _auth_menu, - } - - # --------------------------------------------------------------- - # Routing - # --------------------------------------------------------------- - @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): @@ -45,10 +25,12 @@ def register(): @bp.get("/") async def get_fragment(fragment_type: str): - handler = _handlers.get(fragment_type) - if handler is None: - return Response("", status=200, content_type="text/sx") - src = await handler() - return Response(src, status=200, content_type="text/sx") + handler_def = get_handler("account", fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, "account", args=dict(request.args), + ) + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") return bp diff --git a/account/sx/handlers/auth-menu.sx b/account/sx/handlers/auth-menu.sx new file mode 100644 index 0000000..28d4c02 --- /dev/null +++ b/account/sx/handlers/auth-menu.sx @@ -0,0 +1,8 @@ +;; Account auth-menu fragment handler +;; +;; Renders the desktop + mobile auth menu (sign-in or user link). + +(defhandler auth-menu (&key email) + (~auth-menu + :user-email (when email email) + :account-url (app-url "account" ""))) diff --git a/account/sx/sx_components.py b/account/sx/sx_components.py index 45fa386..04119e7 100644 --- a/account/sx/sx_components.py +++ b/account/sx/sx_components.py @@ -15,8 +15,9 @@ from shared.sx.helpers import ( root_header_sx, full_page_sx, header_child_sx, oob_page_sx, ) -# Load account-specific .sx components at import time -load_service_components(os.path.dirname(os.path.dirname(__file__))) +# Load account-specific .sx components + handlers at import time +load_service_components(os.path.dirname(os.path.dirname(__file__)), + service_name="account") # --------------------------------------------------------------------------- diff --git a/blog/bp/fragments/routes.py b/blog/bp/fragments/routes.py index 9c4010f..9b0818f 100644 --- a/blog/bp/fragments/routes.py +++ b/blog/bp/fragments/routes.py @@ -2,21 +2,22 @@ Exposes sx fragments at ``/internal/fragments/`` for consumption by other coop apps via the fragment client. + +All handlers are defined declaratively in .sx files under +``blog/sx/handlers/`` and dispatched via the sx handler registry. """ from __future__ import annotations -from quart import Blueprint, Response, g, render_template, request +from quart import Blueprint, Response, request from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.services.navigation import get_navigation_tree +from shared.sx.handlers import get_handler, execute_handler def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - _handlers: dict[str, object] = {} - @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): @@ -24,138 +25,12 @@ def register(): @bp.get("/") async def get_fragment(fragment_type: str): - handler = _handlers.get(fragment_type) - if handler is None: - return Response("", status=200, content_type="text/sx") - result = await handler() - return Response(result, status=200, content_type="text/sx") - - # --- nav-tree fragment — returns sx source --- - async def _nav_tree_handler(): - from shared.sx.helpers import sx_call, SxExpr - from shared.infrastructure.urls import ( - blog_url, cart_url, market_url, events_url, - federation_url, account_url, artdag_url, - ) - - app_name = request.args.get("app_name", "") - path = request.args.get("path", "/") - first_seg = path.strip("/").split("/")[0] - menu_items = list(await get_navigation_tree(g.s)) - - app_slugs = { - "cart": cart_url("/"), - "market": market_url("/"), - "events": events_url("/"), - "federation": federation_url("/"), - "account": account_url("/"), - "artdag": artdag_url("/"), - } - - nav_cls = "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm" - - item_sxs = [] - for item in menu_items: - href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/")) - selected = "true" if (item.slug == first_seg - or item.slug == app_name) else "false" - img = sx_call("img-or-placeholder", - src=getattr(item, "feature_image", None), - alt=getattr(item, "label", item.slug), - size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0") - item_sxs.append(sx_call( - "blog-nav-item-link", - href=href, hx_get=href, selected=selected, nav_cls=nav_cls, - img=SxExpr(img), label=getattr(item, "label", item.slug), - )) - - # artdag link - href = artdag_url("/") - selected = "true" if ("artdag" == first_seg - or "artdag" == app_name) else "false" - img = sx_call("img-or-placeholder", src=None, alt="art-dag", - size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0") - item_sxs.append(sx_call( - "blog-nav-item-link", - href=href, hx_get=href, selected=selected, nav_cls=nav_cls, - img=SxExpr(img), label="art-dag", - )) - - if not item_sxs: - return sx_call("blog-nav-empty", - wrapper_id="menu-items-nav-wrapper") - - items_frag = "(<> " + " ".join(item_sxs) + ")" - - arrow_cls = "scrolling-menu-arrow-menu-items-container" - container_id = "menu-items-container" - left_hs = ("on click set #" + container_id - + ".scrollLeft to #" + container_id + ".scrollLeft - 200") - scroll_hs = ("on scroll " - "set cls to '" + arrow_cls + "' " - "set arrows to document.getElementsByClassName(cls) " - "set show to (window.innerWidth >= 640 and " - "my.scrollWidth > my.clientWidth) " - "repeat for arrow in arrows " - "if show remove .hidden from arrow add .flex to arrow " - "else add .hidden to arrow remove .flex from arrow end " - "end") - right_hs = ("on click set #" + container_id - + ".scrollLeft to #" + container_id + ".scrollLeft + 200") - - return sx_call("scroll-nav-wrapper", - wrapper_id="menu-items-nav-wrapper", - container_id=container_id, - arrow_cls=arrow_cls, - left_hs=left_hs, - scroll_hs=scroll_hs, - right_hs=right_hs, - items=SxExpr(items_frag), - oob=True) - - _handlers["nav-tree"] = _nav_tree_handler - - # --- link-card fragment — returns sx source --- - def _blog_link_card_sx(post, link: str) -> str: - from shared.sx.helpers import sx_call - published = post.published_at.strftime("%d %b %Y") if post.published_at else None - return sx_call("link-card", - link=link, - title=post.title, - image=post.feature_image, - icon="fas fa-file-alt", - subtitle=post.custom_excerpt or post.excerpt, - detail=published, - data_app="blog") - - async def _link_card_handler(): - from services import blog_service - from shared.infrastructure.urls import blog_url - - slug = request.args.get("slug", "") - keys_raw = request.args.get("keys", "") - - # 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"") - post = await blog_service.get_post_by_slug(g.s, s) - if post: - parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}"))) - return "\n".join(parts) - - # Single mode - if not slug: - return "" - post = await blog_service.get_post_by_slug(g.s, slug) - if not post: - return "" - return _blog_link_card_sx(post, blog_url(f"/{post.slug}")) - - _handlers["link-card"] = _link_card_handler - - bp._fragment_handlers = _handlers + handler_def = get_handler("blog", fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, "blog", args=dict(request.args), + ) + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") return bp diff --git a/blog/sx/handlers/link-card.sx b/blog/sx/handlers/link-card.sx new file mode 100644 index 0000000..8598a6b --- /dev/null +++ b/blog/sx/handlers/link-card.sx @@ -0,0 +1,31 @@ +;; Blog link-card fragment handler +;; +;; Renders link-card(s) for blog posts by slug. +;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z). + +(defhandler link-card (&key slug keys) + (if keys + (let ((slugs (split keys ","))) + (<> (map (fn (s) + (let ((post (query "blog" "post-by-slug" :slug (trim s)))) + (when post + (<> (str "") + (~link-card + :link (app-url "blog" (str "/" (get post "slug") "/")) + :title (get post "title") + :image (get post "feature_image") + :icon "fas fa-file-alt" + :subtitle (or (get post "custom_excerpt") (get post "excerpt")) + :detail (get post "published_at_display") + :data-app "blog"))))) slugs))) + (when slug + (let ((post (query "blog" "post-by-slug" :slug slug))) + (when post + (~link-card + :link (app-url "blog" (str "/" (get post "slug") "/")) + :title (get post "title") + :image (get post "feature_image") + :icon "fas fa-file-alt" + :subtitle (or (get post "custom_excerpt") (get post "excerpt")) + :detail (get post "published_at_display") + :data-app "blog")))))) diff --git a/blog/sx/handlers/nav-tree.sx b/blog/sx/handlers/nav-tree.sx new file mode 100644 index 0000000..3d10625 --- /dev/null +++ b/blog/sx/handlers/nav-tree.sx @@ -0,0 +1,80 @@ +;; Blog nav-tree fragment handler +;; +;; Renders the full scrollable navigation menu bar with app icons. +;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB. + +(defhandler nav-tree (&key app_name path) + (let ((app (or app_name "")) + (cur-path (or path "/")) + (first-seg (first (filter (fn (s) (not (empty? s))) + (split (trim cur-path) "/")))) + (items (nav-tree)) + (nav-cls "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm") + + ;; App slug → URL mapping + (app-slugs (dict + :cart (app-url "cart" "/") + :market (app-url "market" "/") + :events (app-url "events" "/") + :federation (app-url "federation" "/") + :account (app-url "account" "/") + :artdag (app-url "artdag" "/")))) + + (let ((item-sxs + (<> + ;; Nav items from DB + (map (fn (item) + (let ((item-slug (or (get item "slug") "")) + (href (or (get app-slugs item-slug) + (app-url "blog" (str "/" item-slug "/")))) + (selected (or (= item-slug (or first-seg "")) + (= item-slug app)))) + (~blog-nav-item-link + :href href + :hx-get href + :selected (if selected "true" "false") + :nav-cls nav-cls + :img (~img-or-placeholder + :src (get item "feature_image") + :alt (or (get item "label") item-slug) + :size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0") + :label (or (get item "label") item-slug)))) items) + + ;; Hardcoded artdag link + (~blog-nav-item-link + :href (app-url "artdag" "/") + :hx-get (app-url "artdag" "/") + :selected (if (or (= "artdag" (or first-seg "")) + (= "artdag" app)) "true" "false") + :nav-cls nav-cls + :img (~img-or-placeholder + :src nil :alt "art-dag" + :size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0") + :label "art-dag"))) + + ;; Scroll wrapper IDs + hyperscript + (arrow-cls "scrolling-menu-arrow-menu-items-container") + (cid "menu-items-container") + (left-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft - 200")) + (scroll-hs (str "on scroll " + "set cls to '" arrow-cls "' " + "set arrows to document.getElementsByClassName(cls) " + "set show to (window.innerWidth >= 640 and " + "my.scrollWidth > my.clientWidth) " + "repeat for arrow in arrows " + "if show remove .hidden from arrow add .flex to arrow " + "else add .hidden to arrow remove .flex from arrow end " + "end")) + (right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200"))) + + (if (empty? items) + (~blog-nav-empty :wrapper-id "menu-items-nav-wrapper") + (~scroll-nav-wrapper + :wrapper-id "menu-items-nav-wrapper" + :container-id cid + :arrow-cls arrow-cls + :left-hs left-hs + :scroll-hs scroll-hs + :right-hs right-hs + :items item-sxs + :oob true))))) diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py index e635480..6915684 100644 --- a/blog/sx/sx_components.py +++ b/blog/sx/sx_components.py @@ -28,8 +28,8 @@ from shared.sx.helpers import ( full_page_sx, ) -# Load blog service .sx component definitions -load_service_components(os.path.dirname(os.path.dirname(__file__))) +# Load blog service .sx component definitions + handler definitions +load_service_components(os.path.dirname(os.path.dirname(__file__)), service_name="blog") def _ctx_csrf(ctx: dict) -> str: diff --git a/cart/bp/fragments/routes.py b/cart/bp/fragments/routes.py index b84a85c..6c84d22 100644 --- a/cart/bp/fragments/routes.py +++ b/cart/bp/fragments/routes.py @@ -3,61 +3,21 @@ Exposes sx fragments at ``/internal/fragments/`` for consumption by other coop apps via the fragment client. -Fragments: - cart-mini Cart icon with badge (or logo when empty) - account-nav-item "orders" link for account dashboard +All handlers are defined declaratively in .sx files under +``cart/sx/handlers/`` and dispatched via the sx handler registry. """ from __future__ import annotations -from quart import Blueprint, Response, request, g +from quart import Blueprint, Response, request from shared.infrastructure.fragments import FRAGMENT_HEADER +from shared.sx.handlers import get_handler, execute_handler def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - # --------------------------------------------------------------- - # Fragment handlers — return sx source text - # --------------------------------------------------------------- - - async def _cart_mini(): - from shared.services.registry import services - from shared.infrastructure.urls import blog_url, cart_url - from shared.sx.helpers import sx_call - - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - - summary = await services.cart.cart_summary( - g.s, user_id=user_id, session_id=session_id, - ) - count = summary.count + summary.calendar_count + summary.ticket_count - oob = request.args.get("oob", "") - return sx_call("cart-mini", - 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.sx.helpers import sx_call - - return sx_call("account-nav-item", - href=cart_url("/orders/"), - label="orders") - - _handlers = { - "cart-mini": _cart_mini, - "account-nav-item": _account_nav_item, - } - - # --------------------------------------------------------------- - # Routing - # --------------------------------------------------------------- - @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): @@ -65,10 +25,12 @@ def register(): @bp.get("/") async def get_fragment(fragment_type: str): - handler = _handlers.get(fragment_type) - if handler is None: - return Response("", status=200, content_type="text/sx") - src = await handler() - return Response(src, status=200, content_type="text/sx") + handler_def = get_handler("cart", fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, "cart", args=dict(request.args), + ) + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") return bp diff --git a/cart/sx/handlers/account-nav-item.sx b/cart/sx/handlers/account-nav-item.sx new file mode 100644 index 0000000..5c31f44 --- /dev/null +++ b/cart/sx/handlers/account-nav-item.sx @@ -0,0 +1,8 @@ +;; Cart account-nav-item fragment handler +;; +;; Renders the "orders" link for the account dashboard nav. + +(defhandler account-nav-item (&key) + (~account-nav-item + :href (app-url "cart" "/orders/") + :label "orders")) diff --git a/cart/sx/handlers/cart-mini.sx b/cart/sx/handlers/cart-mini.sx new file mode 100644 index 0000000..2dbb44a --- /dev/null +++ b/cart/sx/handlers/cart-mini.sx @@ -0,0 +1,16 @@ +;; Cart cart-mini fragment handler +;; +;; Renders the cart icon with badge (or logo when empty). + +(defhandler cart-mini (&key user_id session_id oob) + (let ((summary (service "cart" "cart-summary" + :user-id (when user_id (parse-int user_id)) + :session-id session_id)) + (count (+ (or (get summary "count") 0) + (or (get summary "calendar_count") 0) + (or (get summary "ticket_count") 0)))) + (~cart-mini + :cart-count count + :blog-url (app-url "blog" "") + :cart-url (app-url "cart" "") + :oob (when oob oob)))) diff --git a/cart/sx/sx_components.py b/cart/sx/sx_components.py index e31112b..78befe2 100644 --- a/cart/sx/sx_components.py +++ b/cart/sx/sx_components.py @@ -20,8 +20,9 @@ from shared.sx.helpers import ( ) from shared.infrastructure.urls import market_product_url, cart_url -# Load cart-specific .sx components at import time -load_service_components(os.path.dirname(os.path.dirname(__file__))) +# Load cart-specific .sx components + handlers at import time +load_service_components(os.path.dirname(os.path.dirname(__file__)), + service_name="cart") # --------------------------------------------------------------------------- diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py index 6daf56c..796f8a8 100644 --- a/events/bp/fragments/routes.py +++ b/events/bp/fragments/routes.py @@ -2,6 +2,12 @@ Exposes sx fragments at ``/internal/fragments/`` 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("/") 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"") - 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 diff --git a/events/sx/handlers/account-nav-item.sx b/events/sx/handlers/account-nav-item.sx new file mode 100644 index 0000000..73f248c --- /dev/null +++ b/events/sx/handlers/account-nav-item.sx @@ -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")))) diff --git a/events/sx/handlers/container-nav.sx b/events/sx/handlers/container-nav.sx new file mode 100644 index 0000000..d58f9fb --- /dev/null +++ b/events/sx/handlers/container-nav.sx @@ -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)))))) diff --git a/events/sx/handlers/link-card.sx b/events/sx/handlers/link-card.sx new file mode 100644 index 0000000..1c58552 --- /dev/null +++ b/events/sx/handlers/link-card.sx @@ -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 "") + (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")))))))))) diff --git a/events/sx/sx_components.py b/events/sx/sx_components.py index ce1e7d8..82af676 100644 --- a/events/sx/sx_components.py +++ b/events/sx/sx_components.py @@ -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") # --------------------------------------------------------------------------- diff --git a/federation/bp/fragments/routes.py b/federation/bp/fragments/routes.py index 9114f60..95da7d9 100644 --- a/federation/bp/fragments/routes.py +++ b/federation/bp/fragments/routes.py @@ -2,6 +2,9 @@ Exposes sx fragments at ``/internal/fragments/`` for consumption by other coop apps via the fragment client. + +All handlers are defined declaratively in .sx files under +``federation/sx/handlers/`` and dispatched via the sx handler registry. """ from __future__ import annotations @@ -9,13 +12,12 @@ from __future__ import annotations from quart import Blueprint, Response, request from shared.infrastructure.fragments import FRAGMENT_HEADER +from shared.sx.handlers import get_handler, execute_handler def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - _handlers: dict[str, object] = {} - @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): @@ -23,60 +25,12 @@ def register(): @bp.get("/") async def get_fragment(fragment_type: str): - handler = _handlers.get(fragment_type) - if handler is None: - return Response("", status=200, content_type="text/sx") - src = await handler() - return Response(src, status=200, content_type="text/sx") - - # --- link-card fragment: actor profile preview card -------------------------- - - def _federation_link_card_sx(actor, link: str) -> str: - from shared.sx.helpers import sx_call - return sx_call("link-card", - link=link, - title=actor.display_name or actor.preferred_username, - image=None, - icon="fas fa-user", - subtitle=f"@{actor.preferred_username}" if actor.preferred_username else None, - detail=actor.summary, - data_app="federation") - - async def _link_card_handler(): - from quart import g - from shared.services.registry import services - from shared.infrastructure.urls import federation_url - - username = request.args.get("username", "") - slug = request.args.get("slug", "") - keys_raw = request.args.get("keys", "") - - # Batch mode - if keys_raw: - usernames = [k.strip() for k in keys_raw.split(",") if k.strip()] - parts = [] - for u in usernames: - parts.append(f"") - actor = await services.federation.get_actor_by_username(g.s, u) - if actor: - parts.append(_federation_link_card_sx( - actor, federation_url(f"/users/{actor.preferred_username}"), - )) - return "\n".join(parts) - - # Single mode - lookup = username or slug - if not lookup: - return "" - actor = await services.federation.get_actor_by_username(g.s, lookup) - if not actor: - return "" - return _federation_link_card_sx( - actor, federation_url(f"/users/{actor.preferred_username}"), - ) - - _handlers["link-card"] = _link_card_handler - - bp._fragment_handlers = _handlers + handler_def = get_handler("federation", fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, "federation", args=dict(request.args), + ) + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") return bp diff --git a/federation/sx/handlers/link-card.sx b/federation/sx/handlers/link-card.sx new file mode 100644 index 0000000..bed0324 --- /dev/null +++ b/federation/sx/handlers/link-card.sx @@ -0,0 +1,40 @@ +;; Federation link-card fragment handler +;; +;; Renders actor profile preview card(s) by username. +;; Supports single mode (?slug=x or ?username=x) and batch mode (?keys=x,y,z). + +(defhandler link-card (&key username slug keys) + (if keys + (let ((usernames (filter (fn (u) (not (empty? u))) + (map trim (split keys ","))))) + (<> (map (fn (u) + (let ((actor (service "federation" "get-actor-by-username" :username u))) + (<> (str "") + (when (not (nil? actor)) + (~link-card + :link (app-url "federation" + (str "/users/" (get actor "preferred_username"))) + :title (or (get actor "display_name") + (get actor "preferred_username")) + :image nil + :icon "fas fa-user" + :subtitle (when (get actor "preferred_username") + (str "@" (get actor "preferred_username"))) + :detail (get actor "summary") + :data-app "federation"))))) usernames))) + (let ((lookup (or username slug))) + (when (not (empty? (or lookup ""))) + (let ((actor (service "federation" "get-actor-by-username" + :username lookup))) + (when (not (nil? actor)) + (~link-card + :link (app-url "federation" + (str "/users/" (get actor "preferred_username"))) + :title (or (get actor "display_name") + (get actor "preferred_username")) + :image nil + :icon "fas fa-user" + :subtitle (when (get actor "preferred_username") + (str "@" (get actor "preferred_username"))) + :detail (get actor "summary") + :data-app "federation"))))))) diff --git a/federation/sx/sx_components.py b/federation/sx/sx_components.py index 7010500..6d1d376 100644 --- a/federation/sx/sx_components.py +++ b/federation/sx/sx_components.py @@ -17,8 +17,9 @@ from shared.sx.helpers import ( root_header_sx, full_page_sx, header_child_sx, ) -# Load federation-specific .sx components at import time -load_service_components(os.path.dirname(os.path.dirname(__file__))) +# Load federation-specific .sx components + handlers at import time +load_service_components(os.path.dirname(os.path.dirname(__file__)), + service_name="federation") # --------------------------------------------------------------------------- diff --git a/market/bp/fragments/routes.py b/market/bp/fragments/routes.py index 3831e24..323c1e5 100644 --- a/market/bp/fragments/routes.py +++ b/market/bp/fragments/routes.py @@ -2,21 +2,22 @@ Exposes sx fragments at ``/internal/fragments/`` for consumption by other coop apps via the fragment client. + +All handlers are defined declaratively in .sx files under +``market/sx/handlers/`` and dispatched via the sx handler registry. """ from __future__ import annotations -from quart import Blueprint, Response, g, request +from quart import Blueprint, Response, request from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.services.registry import services +from shared.sx.handlers import get_handler, execute_handler def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - _handlers: dict[str, object] = {} - @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): @@ -24,90 +25,12 @@ def register(): @bp.get("/") async def get_fragment(fragment_type: str): - handler = _handlers.get(fragment_type) - if handler is None: - return Response("", status=200, content_type="text/sx") - src = await handler() - return Response(src, status=200, content_type="text/sx") - - # --- container-nav fragment: market links -------------------------------- - - async def _container_nav_handler(): - from quart import current_app - from shared.infrastructure.urls import market_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", "") - - markets = await services.market.marketplaces_for_container( - g.s, container_type, container_id, - ) - if not markets: - return "" - 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 = [] - for m in markets: - href = market_url(f"/{post_slug}/{m.slug}/") - parts.append(sx_call("market-link-nav", - href=href, name=m.name, nav_class=nav_class, - select_colours=select_colours)) - return "(<> " + " ".join(parts) + ")" - - _handlers["container-nav"] = _container_nav_handler - - # --- link-card fragment: product preview card -------------------------------- - - def _product_link_card_sx(product, link: str) -> str: - from shared.sx.helpers import sx_call - subtitle = product.brand or "" - detail = "" - if product.special_price: - detail = f"{product.regular_price} → {product.special_price}" - elif product.regular_price: - detail = str(product.regular_price) - return sx_call("link-card", - title=product.title, image=product.image, - subtitle=subtitle, detail=detail, - link=link) - - async def _link_card_handler(): - from sqlalchemy import select - from shared.models.market import Product - from shared.infrastructure.urls import market_url - - slug = request.args.get("slug", "") - keys_raw = request.args.get("keys", "") - - # 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"") - product = ( - await g.s.execute(select(Product).where(Product.slug == s)) - ).scalar_one_or_none() - if product: - parts.append(_product_link_card_sx( - product, market_url(f"/product/{product.slug}/"))) - return "\n".join(parts) - - # Single mode - if not slug: - return "" - product = ( - await g.s.execute(select(Product).where(Product.slug == slug)) - ).scalar_one_or_none() - if not product: - return "" - return _product_link_card_sx(product, market_url(f"/product/{product.slug}/")) - - _handlers["link-card"] = _link_card_handler - - bp._fragment_handlers = _handlers + handler_def = get_handler("market", fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, "market", args=dict(request.args), + ) + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") return bp diff --git a/market/sx/handlers/container-nav.sx b/market/sx/handlers/container-nav.sx new file mode 100644 index 0000000..8fdc813 --- /dev/null +++ b/market/sx/handlers/container-nav.sx @@ -0,0 +1,21 @@ +;; Market container-nav fragment handler +;; +;; Renders marketplace link nav items for blog post pages. + +(defhandler container-nav (&key container_type container_id post_slug) + (let ((ct (or container_type "page")) + (cid (parse-int (or container_id "0"))) + (slug (or post_slug "")) + (markets (service "market" "marketplaces-for-container" + :container-type ct :container-id cid))) + (when (not (empty? markets)) + (let ((styles (or (jinja-global "styles") (dict))) + (nav-class (or (get styles "nav_button") "")) + (sel-colours (or (jinja-global "select_colours") ""))) + (<> (map (fn (m) + (let ((href (app-url "market" (str "/" slug "/" (get m "slug") "/")))) + (~market-link-nav + :href href + :name (get m "name") + :nav-class nav-class + :select-colours sel-colours))) markets)))))) diff --git a/market/sx/handlers/link-card.sx b/market/sx/handlers/link-card.sx new file mode 100644 index 0000000..a329555 --- /dev/null +++ b/market/sx/handlers/link-card.sx @@ -0,0 +1,42 @@ +;; Market link-card fragment handler +;; +;; Renders product 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 ((product (service "market" "product-by-slug" :slug s))) + (<> (str "") + (when (not (nil? product)) + (let ((link (app-url "market" (str "/product/" (get product "slug") "/"))) + (subtitle (or (get product "brand") "")) + (detail (if (get product "special_price") + (str (get product "regular_price") " → " (get product "special_price")) + (if (get product "regular_price") + (str (get product "regular_price")) + "")))) + (~link-card + :title (get product "title") + :image (get product "image") + :subtitle subtitle + :detail detail + :link link)))))) slugs))) + (when slug + (let ((product (service "market" "product-by-slug" :slug slug))) + (when (not (nil? product)) + (let ((link (app-url "market" (str "/product/" (get product "slug") "/"))) + (subtitle (or (get product "brand") "")) + (detail (if (get product "special_price") + (str (get product "regular_price") " → " (get product "special_price")) + (if (get product "regular_price") + (str (get product "regular_price")) + "")))) + (~link-card + :title (get product "title") + :image (get product "image") + :subtitle subtitle + :detail detail + :link link))))))) diff --git a/market/sx/sx_components.py b/market/sx/sx_components.py index ea4a197..912a36d 100644 --- a/market/sx/sx_components.py +++ b/market/sx/sx_components.py @@ -22,8 +22,9 @@ from shared.sx.helpers import ( full_page_sx, oob_page_sx, ) -# Load market-specific .sx components at import time -load_service_components(os.path.dirname(os.path.dirname(__file__))) +# Load market-specific .sx components + handlers at import time +load_service_components(os.path.dirname(os.path.dirname(__file__)), + service_name="market") # --------------------------------------------------------------------------- diff --git a/orders/bp/fragments/routes.py b/orders/bp/fragments/routes.py index 8d3cf48..e18f5c4 100644 --- a/orders/bp/fragments/routes.py +++ b/orders/bp/fragments/routes.py @@ -1,29 +1,23 @@ """Orders app fragment endpoints. -Fragments: - account-nav-item "orders" link for account dashboard +Exposes sx fragments at ``/internal/fragments/`` for consumption +by other coop apps via the fragment client. + +All handlers are defined declaratively in .sx files under +``orders/sx/handlers/`` and dispatched via the sx handler registry. """ + from __future__ import annotations from quart import Blueprint, Response, request + from shared.infrastructure.fragments import FRAGMENT_HEADER +from shared.sx.handlers import get_handler, execute_handler def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - async def _account_nav_item(): - from shared.infrastructure.urls import orders_url - from shared.sx.helpers import sx_call - - return sx_call("account-nav-item", - href=orders_url("/"), - label="orders") - - _handlers = { - "account-nav-item": _account_nav_item, - } - @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): @@ -31,10 +25,12 @@ def register(): @bp.get("/") async def get_fragment(fragment_type: str): - handler = _handlers.get(fragment_type) - if handler is None: - return Response("", status=200, content_type="text/sx") - src = await handler() - return Response(src, status=200, content_type="text/sx") + handler_def = get_handler("orders", fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, "orders", args=dict(request.args), + ) + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") return bp diff --git a/orders/sx/handlers/account-nav-item.sx b/orders/sx/handlers/account-nav-item.sx new file mode 100644 index 0000000..f679d81 --- /dev/null +++ b/orders/sx/handlers/account-nav-item.sx @@ -0,0 +1,8 @@ +;; Orders account-nav-item fragment handler +;; +;; Renders the "orders" link for the account dashboard nav. + +(defhandler account-nav-item (&key) + (~account-nav-item + :href (app-url "orders" "/") + :label "orders")) diff --git a/orders/sx/sx_components.py b/orders/sx/sx_components.py index 4f0368c..d91f16b 100644 --- a/orders/sx/sx_components.py +++ b/orders/sx/sx_components.py @@ -19,8 +19,9 @@ from shared.sx.helpers import ( ) from shared.infrastructure.urls import market_product_url, cart_url -# Load orders-specific .sx components at import time -load_service_components(os.path.dirname(os.path.dirname(__file__))) +# Load orders-specific .sx components + handlers at import time +load_service_components(os.path.dirname(os.path.dirname(__file__)), + service_name="orders") # --------------------------------------------------------------------------- diff --git a/relations/app.py b/relations/app.py index 1b74608..3515745 100644 --- a/relations/app.py +++ b/relations/app.py @@ -1,5 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 +import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file from shared.infrastructure.factory import create_base_app diff --git a/relations/bp/fragments/routes.py b/relations/bp/fragments/routes.py index 803c0ae..cc90927 100644 --- a/relations/bp/fragments/routes.py +++ b/relations/bp/fragments/routes.py @@ -1,21 +1,23 @@ """Relations app fragment endpoints. -Generic container-nav fragment that renders navigation items for all -related entities, driven by the relation registry. +Exposes sx fragments at ``/internal/fragments/`` for consumption +by other coop apps via the fragment client. + +All handlers are defined declaratively in .sx files under +``relations/sx/handlers/`` and dispatched via the sx handler registry. """ from __future__ import annotations -from quart import Blueprint, Response, g, request +from quart import Blueprint, Response, request from shared.infrastructure.fragments import FRAGMENT_HEADER +from shared.sx.handlers import get_handler, execute_handler def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - _handlers: dict[str, object] = {} - @bp.before_request async def _require_fragment_header(): if not request.headers.get(FRAGMENT_HEADER): @@ -23,70 +25,12 @@ def register(): @bp.get("/") async def get_fragment(fragment_type: str): - handler = _handlers.get(fragment_type) - if handler is None: - return Response("", status=200, content_type="text/sx") - src = await handler() - return Response(src, status=200, content_type="text/sx") - - # --- generic container-nav fragment ---------------------------------------- - - async def _container_nav_handler(): - from shared.sx.helpers import sx_call - from shared.sx.relations import relations_from - from shared.services.relationships import get_children - from shared.infrastructure.urls import events_url, market_url - - _SERVICE_URL = { - "calendar": events_url, - "market": market_url, - } - - container_type = request.args.get("container_type", "page") - container_id = int(request.args.get("container_id", 0)) - post_slug = request.args.get("post_slug", "") - nav_class = request.args.get("nav_class", "") - exclude_raw = request.args.get("exclude", "") - exclude = set(exclude_raw.split(",")) if exclude_raw else set() - - nav_defs = [ - d for d in relations_from(container_type) - if d.nav != "hidden" and d.name not in exclude - ] - - if not nav_defs: - return "" - - parts = [] - for defn in nav_defs: - children = await get_children( - g.s, - parent_type=container_type, - parent_id=container_id, - child_type=defn.to_type, - relation_type=defn.name, + handler_def = get_handler("relations", fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, "relations", args=dict(request.args), ) - for child in children: - slug = (child.metadata_ or {}).get("slug", "") - if not slug: - continue - if post_slug: - path = f"/{post_slug}/{slug}/" - else: - path = f"/{slug}/" - url_fn = _SERVICE_URL.get(defn.to_type) - href = url_fn(path) if url_fn else path - parts.append(sx_call("relation-nav", - href=href, - name=child.label or "", - icon=defn.nav_icon or "", - nav_class=nav_class, - relation_type=defn.name)) - - if not parts: - return "" - return "(<> " + " ".join(parts) + ")" - - _handlers["container-nav"] = _container_nav_handler + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") return bp diff --git a/relations/sx/__init__.py b/relations/sx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/relations/sx/handlers/container-nav.sx b/relations/sx/handlers/container-nav.sx new file mode 100644 index 0000000..c9cd863 --- /dev/null +++ b/relations/sx/handlers/container-nav.sx @@ -0,0 +1,47 @@ +;; Relations container-nav fragment handler +;; +;; Generic navigation fragment driven by the relation registry. +;; Renders nav items for all related entities of a container. + +(defhandler container-nav (&key container_type container_id post_slug nav_class exclude) + (let ((ct (or container_type "page")) + (cid (parse-int (or container_id "0"))) + (slug (or post_slug "")) + (ncls (or nav_class "")) + (excl-raw (or exclude "")) + (excl-set (filter (fn (e) (not (empty? e))) + (map trim (split excl-raw ",")))) + + ;; URL builders per child type + (url-builders (dict :calendar "events" :market "market")) + + ;; Filter relation defs: visible + not excluded + (nav-defs (filter (fn (d) + (and (!= (get d "nav") "hidden") + (not (contains? excl-set (get d "name"))))) + (relations-from ct)))) + + (when (not (empty? nav-defs)) + (let ((parts (map (fn (defn) + (let ((children (get-children + :parent-type ct + :parent-id cid + :child-type (get defn "to_type") + :relation-type (get defn "name")))) + (<> (map (fn (child) + (let ((child-slug (or (get (or (get child "metadata_") (dict)) "slug") ""))) + (when (not (empty? child-slug)) + (let ((path (if (not (empty? slug)) + (str "/" slug "/" child-slug "/") + (str "/" child-slug "/"))) + (svc-name (get url-builders (get defn "to_type"))) + (href (if svc-name + (app-url svc-name path) + path))) + (~relation-nav + :href href + :name (or (get child "label") "") + :icon (or (get defn "nav_icon") "") + :nav-class ncls + :relation-type (get defn "name")))))) children)))) nav-defs))) + (<> parts))))) diff --git a/relations/sx/sx_components.py b/relations/sx/sx_components.py new file mode 100644 index 0000000..491851e --- /dev/null +++ b/relations/sx/sx_components.py @@ -0,0 +1,14 @@ +""" +Relations service s-expression components. + +Loads relation-specific .sx components and handlers. +""" +from __future__ import annotations + +import os + +from shared.sx.jinja_bridge import load_service_components + +# Load relations-specific .sx components + handlers at import time +load_service_components(os.path.dirname(os.path.dirname(__file__)), + service_name="relations") diff --git a/shared/contracts/protocols.py b/shared/contracts/protocols.py index d8cbbd0..db02185 100644 --- a/shared/contracts/protocols.py +++ b/shared/contracts/protocols.py @@ -138,6 +138,8 @@ class MarketService(Protocol): async def product_by_id(self, session: AsyncSession, product_id: int) -> ProductDTO | None: ... + async def product_by_slug(self, session: AsyncSession, slug: str) -> ProductDTO | None: ... + async def create_marketplace( self, session: AsyncSession, container_type: str, container_id: int, name: str, slug: str, diff --git a/shared/services/market_impl.py b/shared/services/market_impl.py index f8e12ee..b95a02e 100644 --- a/shared/services/market_impl.py +++ b/shared/services/market_impl.py @@ -75,6 +75,12 @@ class SqlMarketService: ).scalar_one_or_none() return _product_to_dto(product) if product else None + async def product_by_slug(self, session: AsyncSession, slug: str) -> ProductDTO | None: + product = ( + await session.execute(select(Product).where(Product.slug == slug)) + ).scalar_one_or_none() + return _product_to_dto(product) if product else None + async def create_marketplace( self, session: AsyncSession, container_type: str, container_id: int, name: str, slug: str, diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 59623c3..de5cfd3 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -50,6 +50,15 @@ } Component.prototype._component = true; + function Macro(params, restParam, body, closure, name) { + this.params = params; + this.restParam = restParam; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Macro.prototype._macro = true; + /** Marker for pre-rendered HTML that bypasses escaping. */ function RawHTML(html) { this.html = html; } RawHTML.prototype._raw = true; @@ -58,6 +67,7 @@ function isKw(x) { return x && x._kw === true; } function isLambda(x) { return x && x._lambda === true; } function isComponent(x) { return x && x._component === true; } + function isMacro(x) { return x && x._macro === true; } function isRaw(x) { return x && x._raw === true; } // ========================================================================= @@ -181,6 +191,16 @@ if (raw === "(") { tok.next(); return parseList(tok, ")"); } if (raw === "[") { tok.next(); return parseList(tok, "]"); } if (raw === "{") { tok.next(); return parseMap(tok); } + // Quasiquote syntax + if (raw === "`") { tok._advance(1); return [new Symbol("quasiquote"), parseExpr(tok)]; } + if (raw === ",") { + tok._advance(1); + if (tok.pos < tok.text.length && tok.text[tok.pos] === "@") { + tok._advance(1); + return [new Symbol("splice-unquote"), parseExpr(tok)]; + } + return [new Symbol("unquote"), parseExpr(tok)]; + } return tok.next(); } @@ -372,6 +392,15 @@ if (sf) return sf(expr, env); var ho = HO_FORMS[head.name]; if (ho) return ho(expr, env); + + // Macro expansion + if (head.name in env) { + var macroVal = env[head.name]; + if (isMacro(macroVal)) { + var expanded = expandMacro(macroVal, expr.slice(1), env); + return sxEval(expanded, env); + } + } } // Function call @@ -576,6 +605,64 @@ return result; }; + SPECIAL_FORMS["defmacro"] = function (expr, env) { + var nameSym = expr[1]; + var paramsExpr = expr[2]; + var params = [], restParam = null; + for (var i = 0; i < paramsExpr.length; i++) { + var p = paramsExpr[i]; + if (isSym(p) && p.name === "&rest") { + if (i + 1 < paramsExpr.length) { + var rp = paramsExpr[i + 1]; + restParam = isSym(rp) ? rp.name : String(rp); + } + break; + } + if (isSym(p)) params.push(p.name); + else if (typeof p === "string") params.push(p); + } + var macro = new Macro(params, restParam, expr[3], merge({}, env), nameSym.name); + env[nameSym.name] = macro; + return macro; + }; + + SPECIAL_FORMS["quasiquote"] = function (expr, env) { + return qqExpand(expr[1], env); + }; + + function qqExpand(template, env) { + if (!Array.isArray(template)) return template; + if (!template.length) return []; + var head = template[0]; + if (isSym(head)) { + if (head.name === "unquote") return sxEval(template[1], env); + if (head.name === "splice-unquote") throw new Error("splice-unquote not inside a list"); + } + var result = []; + for (var i = 0; i < template.length; i++) { + var item = template[i]; + if (Array.isArray(item) && item.length === 2 && isSym(item[0]) && item[0].name === "splice-unquote") { + var spliced = sxEval(item[1], env); + if (Array.isArray(spliced)) { for (var j = 0; j < spliced.length; j++) result.push(spliced[j]); } + else if (!isNil(spliced)) result.push(spliced); + } else { + result.push(qqExpand(item, env)); + } + } + return result; + } + + function expandMacro(macro, rawArgs, env) { + var local = merge({}, macro.closure, env); + for (var i = 0; i < macro.params.length; i++) { + local[macro.params[i]] = i < rawArgs.length ? rawArgs[i] : NIL; + } + if (macro.restParam !== null) { + local[macro.restParam] = rawArgs.slice(macro.params.length); + } + return sxEval(macro.body, local); + } + // --- Higher-order forms -------------------------------------------------- var HO_FORMS = {}; @@ -772,6 +859,8 @@ RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); }; RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); }; + RENDER_FORMS["defmacro"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); }; + RENDER_FORMS["defhandler"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); }; RENDER_FORMS["map"] = function (expr, env) { var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env); @@ -913,6 +1002,12 @@ // Render-aware special forms if (RENDER_FORMS[name]) return RENDER_FORMS[name](expr, env); + // Macro expansion + if (name in env && isMacro(env[name])) { + var mExpanded = expandMacro(env[name], expr.slice(1), env); + return renderDOM(mExpanded, env); + } + // HTML tag if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env); @@ -1051,7 +1146,13 @@ for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env)); return bs.join(""); } - if (name === "define" || name === "defcomp") { sxEval(expr, env); return ""; } + if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; } + + // Macro expansion in string renderer + if (name in env && isMacro(env[name])) { + var smExp = expandMacro(env[name], expr.slice(1), env); + return renderStr(smExp, env); + } // Higher-order forms — render-aware (lambda bodies may contain HTML/components) if (name === "map") { diff --git a/shared/sx/__init__.py b/shared/sx/__init__.py index 5173bb8..ab02fe4 100644 --- a/shared/sx/__init__.py +++ b/shared/sx/__init__.py @@ -19,8 +19,10 @@ Quick start:: from .types import ( NIL, Component, + HandlerDef, Keyword, Lambda, + Macro, Symbol, ) from .parser import ( @@ -46,7 +48,9 @@ __all__ = [ "Symbol", "Keyword", "Lambda", + "Macro", "Component", + "HandlerDef", "NIL", # Parser "parse", diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py new file mode 100644 index 0000000..b5e46dc --- /dev/null +++ b/shared/sx/async_eval.py @@ -0,0 +1,837 @@ +""" +Async s-expression evaluator and HTML renderer. + +Mirrors the sync evaluator (evaluator.py) and HTML renderer (html.py) but +every step is ``async`` so I/O primitives can be ``await``ed inline. + +This is the execution engine for ``defhandler`` — handlers contain I/O +calls (``query``, ``service``, ``request-arg``, etc.) interleaved with +control flow (``if``, ``let``, ``map``, ``when``). The sync +collect-then-substitute resolver can't handle data dependencies between +I/O results and control flow, so handlers need inline async evaluation. + +Usage:: + + from shared.sx.async_eval import async_render + + html = await async_render(handler_def.body, env, ctx) +""" + +from __future__ import annotations + +from typing import Any + +from .types import Component, Keyword, Lambda, Macro, NIL, Symbol +from .evaluator import _expand_macro, EvalError +from .primitives import _PRIMITIVES +from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io +from .html import ( + HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, + escape_text, escape_attr, _RawHTML, css_class_collector, +) + + +# --------------------------------------------------------------------------- +# Async evaluate +# --------------------------------------------------------------------------- + +async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: + """Evaluate *expr* in *env*, awaiting I/O primitives inline.""" + # --- literals --- + if isinstance(expr, (int, float, str, bool)): + return expr + if expr is None or expr is NIL: + return NIL + + # --- symbol lookup --- + if isinstance(expr, Symbol): + name = expr.name + if name in env: + return env[name] + if name in _PRIMITIVES: + return _PRIMITIVES[name] + if name == "true": + return True + if name == "false": + return False + if name == "nil": + return NIL + raise EvalError(f"Undefined symbol: {name}") + + # --- keyword --- + if isinstance(expr, Keyword): + return expr.name + + # --- dict literal --- + if isinstance(expr, dict): + return {k: await async_eval(v, env, ctx) for k, v in expr.items()} + + # --- list --- + if not isinstance(expr, list): + return expr + if not expr: + return [] + + head = expr[0] + + if not isinstance(head, (Symbol, Lambda, list)): + return [await async_eval(x, env, ctx) for x in expr] + + if isinstance(head, Symbol): + name = head.name + + # I/O primitives — await inline + if name in IO_PRIMITIVES: + args, kwargs = await _parse_io_args(expr[1:], env, ctx) + return await execute_io(name, args, kwargs, ctx) + + # Special forms + sf = _ASYNC_SPECIAL_FORMS.get(name) + if sf is not None: + return await sf(expr, env, ctx) + + ho = _ASYNC_HO_FORMS.get(name) + if ho is not None: + return await ho(expr, env, ctx) + + # Macro expansion + if name in env: + val = env[name] + if isinstance(val, Macro): + expanded = _expand_macro(val, expr[1:], env) + return await async_eval(expanded, env, ctx) + + # Render forms in eval position — delegate to renderer and return + # the HTML string. Allows (let ((x (<> ...))) ...) etc. + if name in ("<>", "raw!") or name in HTML_TAGS: + return await _arender(expr, env, ctx) + + # --- function / lambda call --- + fn = await async_eval(head, env, ctx) + args = [await async_eval(a, env, ctx) for a in expr[1:]] + + if callable(fn) and not isinstance(fn, (Lambda, Component)): + return fn(*args) + if isinstance(fn, Lambda): + return await _async_call_lambda(fn, args, env, ctx) + if isinstance(fn, Component): + return await _async_call_component(fn, expr[1:], env, ctx) + raise EvalError(f"Not callable: {fn!r}") + + +async def _parse_io_args( + exprs: list[Any], env: dict[str, Any], ctx: RequestContext, +) -> tuple[list[Any], dict[str, Any]]: + """Parse and evaluate I/O node args.""" + args: list[Any] = [] + kwargs: dict[str, Any] = {} + i = 0 + while i < len(exprs): + item = exprs[i] + if isinstance(item, Keyword) and i + 1 < len(exprs): + kwargs[item.name] = await async_eval(exprs[i + 1], env, ctx) + i += 2 + else: + args.append(await async_eval(item, env, ctx)) + i += 1 + return args, kwargs + + +async def _async_call_lambda( + fn: Lambda, args: list[Any], caller_env: dict[str, Any], ctx: RequestContext, +) -> Any: + if len(args) != len(fn.params): + raise EvalError(f"{fn!r} expects {len(fn.params)} args, got {len(args)}") + local = dict(fn.closure) + local.update(caller_env) + for p, v in zip(fn.params, args): + local[p] = v + return await async_eval(fn.body, local, ctx) + + +async def _async_call_component( + comp: Component, raw_args: list[Any], env: dict[str, Any], ctx: RequestContext, +) -> Any: + kwargs: dict[str, Any] = {} + children: list[Any] = [] + i = 0 + while i < len(raw_args): + arg = raw_args[i] + if isinstance(arg, Keyword) and i + 1 < len(raw_args): + kwargs[arg.name] = await async_eval(raw_args[i + 1], env, ctx) + i += 2 + else: + children.append(await async_eval(arg, env, ctx)) + i += 1 + local = dict(comp.closure) + local.update(env) + for p in comp.params: + local[p] = kwargs.get(p, NIL) + if comp.has_children: + local["children"] = children + return await async_eval(comp.body, local, ctx) + + +# --------------------------------------------------------------------------- +# Async special forms +# --------------------------------------------------------------------------- + +async def _asf_if(expr, env, ctx): + cond = await async_eval(expr[1], env, ctx) + if cond and cond is not NIL: + return await async_eval(expr[2], env, ctx) + if len(expr) > 3: + return await async_eval(expr[3], env, ctx) + return NIL + + +async def _asf_when(expr, env, ctx): + cond = await async_eval(expr[1], env, ctx) + if cond and cond is not NIL: + result = NIL + for body_expr in expr[2:]: + result = await async_eval(body_expr, env, ctx) + return result + return NIL + + +async def _asf_and(expr, env, ctx): + result: Any = True + for arg in expr[1:]: + result = await async_eval(arg, env, ctx) + if not result: + return result + return result + + +async def _asf_or(expr, env, ctx): + result: Any = False + for arg in expr[1:]: + result = await async_eval(arg, env, ctx) + if result: + return result + return result + + +async def _asf_let(expr, env, ctx): + bindings = expr[1] + local = dict(env) + if isinstance(bindings, list): + if bindings and isinstance(bindings[0], list): + for binding in bindings: + var = binding[0] + vname = var.name if isinstance(var, Symbol) else var + local[vname] = await async_eval(binding[1], local, ctx) + elif len(bindings) % 2 == 0: + for i in range(0, len(bindings), 2): + var = bindings[i] + vname = var.name if isinstance(var, Symbol) else var + local[vname] = await async_eval(bindings[i + 1], local, ctx) + result: Any = NIL + for body_expr in expr[2:]: + result = await async_eval(body_expr, local, ctx) + return result + + +async def _asf_lambda(expr, env, ctx): + params_expr = expr[1] + param_names = [] + for p in params_expr: + if isinstance(p, Symbol): + param_names.append(p.name) + elif isinstance(p, str): + param_names.append(p) + return Lambda(param_names, expr[2], dict(env)) + + +async def _asf_define(expr, env, ctx): + name_sym = expr[1] + value = await async_eval(expr[2], env, ctx) + if isinstance(value, Lambda) and value.name is None: + value.name = name_sym.name + env[name_sym.name] = value + return value + + +async def _asf_defcomp(expr, env, ctx): + from .evaluator import _sf_defcomp + return _sf_defcomp(expr, env) + + +async def _asf_defmacro(expr, env, ctx): + from .evaluator import _sf_defmacro + return _sf_defmacro(expr, env) + + +async def _asf_defhandler(expr, env, ctx): + from .evaluator import _sf_defhandler + return _sf_defhandler(expr, env) + + +async def _asf_begin(expr, env, ctx): + result: Any = NIL + for sub in expr[1:]: + result = await async_eval(sub, env, ctx) + return result + + +async def _asf_quote(expr, env, ctx): + return expr[1] if len(expr) > 1 else NIL + + +async def _asf_quasiquote(expr, env, ctx): + return await _async_qq_expand(expr[1], env, ctx) + + +async def _async_qq_expand(template, env, ctx): + if not isinstance(template, list): + return template + if not template: + return [] + head = template[0] + if isinstance(head, Symbol): + if head.name == "unquote": + return await async_eval(template[1], env, ctx) + if head.name == "splice-unquote": + raise EvalError("splice-unquote not inside a list") + result: list[Any] = [] + for item in template: + if (isinstance(item, list) and len(item) == 2 + and isinstance(item[0], Symbol) and item[0].name == "splice-unquote"): + spliced = await async_eval(item[1], env, ctx) + if isinstance(spliced, list): + result.extend(spliced) + elif spliced is not None and spliced is not NIL: + result.append(spliced) + else: + result.append(await _async_qq_expand(item, env, ctx)) + return result + + +async def _asf_cond(expr, env, ctx): + clauses = expr[1:] + if not clauses: + return NIL + if (isinstance(clauses[0], list) and len(clauses[0]) == 2 + and not (isinstance(clauses[0][0], Symbol) and clauses[0][0].name in ( + "=", "<", ">", "<=", ">=", "!=", "and", "or"))): + for clause in clauses: + test = clause[0] + if isinstance(test, Symbol) and test.name in ("else", ":else"): + return await async_eval(clause[1], env, ctx) + if isinstance(test, Keyword) and test.name == "else": + return await async_eval(clause[1], env, ctx) + if await async_eval(test, env, ctx): + return await async_eval(clause[1], env, ctx) + else: + i = 0 + while i < len(clauses) - 1: + test = clauses[i] + result = clauses[i + 1] + if isinstance(test, Keyword) and test.name == "else": + return await async_eval(result, env, ctx) + if isinstance(test, Symbol) and test.name in (":else", "else"): + return await async_eval(result, env, ctx) + if await async_eval(test, env, ctx): + return await async_eval(result, env, ctx) + i += 2 + return NIL + + +async def _asf_case(expr, env, ctx): + match_val = await async_eval(expr[1], env, ctx) + clauses = expr[2:] + i = 0 + while i < len(clauses) - 1: + test = clauses[i] + result = clauses[i + 1] + if isinstance(test, Keyword) and test.name == "else": + return await async_eval(result, env, ctx) + if isinstance(test, Symbol) and test.name in (":else", "else"): + return await async_eval(result, env, ctx) + if match_val == await async_eval(test, env, ctx): + return await async_eval(result, env, ctx) + i += 2 + return NIL + + +async def _asf_thread_first(expr, env, ctx): + result = await async_eval(expr[1], env, ctx) + for form in expr[2:]: + if isinstance(form, list): + fn = await async_eval(form[0], env, ctx) + args = [result] + [await async_eval(a, env, ctx) for a in form[1:]] + else: + fn = await async_eval(form, env, ctx) + args = [result] + if callable(fn) and not isinstance(fn, (Lambda, Component)): + result = fn(*args) + elif isinstance(fn, Lambda): + result = await _async_call_lambda(fn, args, env, ctx) + else: + raise EvalError(f"-> form not callable: {fn!r}") + return result + + +async def _asf_set_bang(expr, env, ctx): + value = await async_eval(expr[2], env, ctx) + env[expr[1].name] = value + return value + + +_ASYNC_SPECIAL_FORMS: dict[str, Any] = { + "if": _asf_if, + "when": _asf_when, + "cond": _asf_cond, + "case": _asf_case, + "and": _asf_and, + "or": _asf_or, + "let": _asf_let, + "let*": _asf_let, + "lambda": _asf_lambda, + "fn": _asf_lambda, + "define": _asf_define, + "defcomp": _asf_defcomp, + "defmacro": _asf_defmacro, + "defhandler": _asf_defhandler, + "begin": _asf_begin, + "do": _asf_begin, + "quote": _asf_quote, + "quasiquote": _asf_quasiquote, + "->": _asf_thread_first, + "set!": _asf_set_bang, +} + + +# --------------------------------------------------------------------------- +# Async higher-order forms +# --------------------------------------------------------------------------- + +async def _aho_map(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + results = [] + for item in coll: + if isinstance(fn, Lambda): + results.append(await _async_call_lambda(fn, [item], env, ctx)) + elif callable(fn): + results.append(fn(item)) + else: + raise EvalError(f"map requires callable, got {type(fn).__name__}") + return results + + +async def _aho_map_indexed(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + results = [] + for i, item in enumerate(coll): + if isinstance(fn, Lambda): + results.append(await _async_call_lambda(fn, [i, item], env, ctx)) + elif callable(fn): + results.append(fn(i, item)) + else: + raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}") + return results + + +async def _aho_filter(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + results = [] + for item in coll: + if isinstance(fn, Lambda): + val = await _async_call_lambda(fn, [item], env, ctx) + elif callable(fn): + val = fn(item) + else: + raise EvalError(f"filter requires callable, got {type(fn).__name__}") + if val: + results.append(item) + return results + + +async def _aho_reduce(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + acc = await async_eval(expr[2], env, ctx) + coll = await async_eval(expr[3], env, ctx) + for item in coll: + acc = await _async_call_lambda(fn, [acc, item], env, ctx) if isinstance(fn, Lambda) else fn(acc, item) + return acc + + +async def _aho_some(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + for item in coll: + result = await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item) + if result: + return result + return NIL + + +async def _aho_every(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + for item in coll: + if not (await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)): + return False + return True + + +async def _aho_for_each(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + for item in coll: + if isinstance(fn, Lambda): + await _async_call_lambda(fn, [item], env, ctx) + elif callable(fn): + fn(item) + return NIL + + +_ASYNC_HO_FORMS: dict[str, Any] = { + "map": _aho_map, + "map-indexed": _aho_map_indexed, + "filter": _aho_filter, + "reduce": _aho_reduce, + "some": _aho_some, + "every?": _aho_every, + "for-each": _aho_for_each, +} + + +# --------------------------------------------------------------------------- +# Async HTML renderer +# --------------------------------------------------------------------------- + +async def async_render( + expr: Any, + env: dict[str, Any], + ctx: RequestContext | None = None, +) -> str: + """Render an s-expression to HTML, awaiting I/O primitives inline.""" + if ctx is None: + ctx = RequestContext() + return await _arender(expr, env, ctx) + + +async def _arender(expr: Any, env: dict[str, Any], ctx: RequestContext) -> str: + if expr is None or expr is NIL or expr is False or expr is True: + return "" + if isinstance(expr, _RawHTML): + return expr.html + if isinstance(expr, str): + return escape_text(expr) + if isinstance(expr, (int, float)): + return escape_text(str(expr)) + if isinstance(expr, Symbol): + val = await async_eval(expr, env, ctx) + return await _arender(val, env, ctx) + if isinstance(expr, Keyword): + return escape_text(expr.name) + if isinstance(expr, list): + if not expr: + return "" + return await _arender_list(expr, env, ctx) + if isinstance(expr, dict): + return "" + return escape_text(str(expr)) + + +async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) -> str: + head = expr[0] + + if isinstance(head, Symbol): + name = head.name + + # I/O primitive — await, then render result + if name in IO_PRIMITIVES: + result = await async_eval(expr, env, ctx) + return await _arender(result, env, ctx) + + # raw! + if name == "raw!": + parts = [] + for arg in expr[1:]: + val = await async_eval(arg, env, ctx) + if isinstance(val, _RawHTML): + parts.append(val.html) + elif isinstance(val, str): + parts.append(val) + elif val is not None and val is not NIL: + parts.append(str(val)) + return "".join(parts) + + # <> + if name == "<>": + parts = [] + for child in expr[1:]: + parts.append(await _arender(child, env, ctx)) + return "".join(parts) + + # Render-aware special forms + arsf = _ASYNC_RENDER_FORMS.get(name) + if arsf is not None: + return await arsf(expr, env, ctx) + + # Macro expansion + if name in env: + val = env[name] + if isinstance(val, Macro): + expanded = _expand_macro(val, expr[1:], env) + return await _arender(expanded, env, ctx) + + # HTML tag + if name in HTML_TAGS: + return await _arender_element(name, expr[1:], env, ctx) + + # Component + if name.startswith("~"): + val = env.get(name) + if isinstance(val, Component): + return await _arender_component(val, expr[1:], env, ctx) + + # Fallback — evaluate then render + result = await async_eval(expr, env, ctx) + return await _arender(result, env, ctx) + + if isinstance(head, (Lambda, list)): + result = await async_eval(expr, env, ctx) + return await _arender(result, env, ctx) + + # Data list + parts = [] + for item in expr: + parts.append(await _arender(item, env, ctx)) + return "".join(parts) + + +async def _arender_element( + tag: str, args: list, env: dict[str, Any], ctx: RequestContext, +) -> str: + attrs: dict[str, Any] = {} + children: list[Any] = [] + i = 0 + while i < len(args): + arg = args[i] + if isinstance(arg, Keyword) and i + 1 < len(args): + attr_val = await async_eval(args[i + 1], env, ctx) + attrs[arg.name] = attr_val + i += 2 + else: + children.append(arg) + i += 1 + + class_val = attrs.get("class") + if class_val is not None and class_val is not NIL and class_val is not False: + collector = css_class_collector.get(None) + if collector is not None: + collector.update(str(class_val).split()) + + parts = [f"<{tag}"] + for attr_name, attr_val in attrs.items(): + if attr_val is None or attr_val is NIL or attr_val is False: + continue + if attr_name in BOOLEAN_ATTRS: + if attr_val: + parts.append(f" {attr_name}") + elif attr_val is True: + parts.append(f" {attr_name}") + else: + parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"') + parts.append(">") + opening = "".join(parts) + + if tag in VOID_ELEMENTS: + return opening + + child_parts = [] + for child in children: + child_parts.append(await _arender(child, env, ctx)) + return f"{opening}{''.join(child_parts)}" + + +async def _arender_component( + comp: Component, args: list, env: dict[str, Any], ctx: RequestContext, +) -> str: + kwargs: dict[str, Any] = {} + children: list[Any] = [] + i = 0 + while i < len(args): + arg = args[i] + if isinstance(arg, Keyword) and i + 1 < len(args): + kwargs[arg.name] = await async_eval(args[i + 1], env, ctx) + i += 2 + else: + children.append(arg) + i += 1 + local = dict(comp.closure) + local.update(env) + for p in comp.params: + local[p] = kwargs.get(p, NIL) + if comp.has_children: + child_html = [] + for c in children: + child_html.append(await _arender(c, env, ctx)) + local["children"] = _RawHTML("".join(child_html)) + return await _arender(comp.body, local, ctx) + + +async def _arender_lambda( + fn: Lambda, args: tuple, env: dict[str, Any], ctx: RequestContext, +) -> str: + local = dict(fn.closure) + local.update(env) + for p, v in zip(fn.params, args): + local[p] = v + return await _arender(fn.body, local, ctx) + + +# --------------------------------------------------------------------------- +# Async render-aware special forms +# --------------------------------------------------------------------------- + +async def _arsf_if(expr, env, ctx): + cond = await async_eval(expr[1], env, ctx) + if cond and cond is not NIL: + return await _arender(expr[2], env, ctx) + if len(expr) > 3: + return await _arender(expr[3], env, ctx) + return "" + + +async def _arsf_when(expr, env, ctx): + cond = await async_eval(expr[1], env, ctx) + if cond and cond is not NIL: + parts = [] + for body_expr in expr[2:]: + parts.append(await _arender(body_expr, env, ctx)) + return "".join(parts) + return "" + + +async def _arsf_cond(expr, env, ctx): + clauses = expr[1:] + if not clauses: + return "" + if isinstance(clauses[0], list) and len(clauses[0]) == 2: + for clause in clauses: + test = clause[0] + if isinstance(test, Symbol) and test.name in ("else", ":else"): + return await _arender(clause[1], env, ctx) + if isinstance(test, Keyword) and test.name == "else": + return await _arender(clause[1], env, ctx) + if await async_eval(test, env, ctx): + return await _arender(clause[1], env, ctx) + else: + i = 0 + while i < len(clauses) - 1: + test = clauses[i] + result = clauses[i + 1] + if isinstance(test, Keyword) and test.name == "else": + return await _arender(result, env, ctx) + if isinstance(test, Symbol) and test.name in (":else", "else"): + return await _arender(result, env, ctx) + if await async_eval(test, env, ctx): + return await _arender(result, env, ctx) + i += 2 + return "" + + +async def _arsf_let(expr, env, ctx): + bindings = expr[1] + local = dict(env) + if isinstance(bindings, list): + if bindings and isinstance(bindings[0], list): + for binding in bindings: + var = binding[0] + vname = var.name if isinstance(var, Symbol) else var + local[vname] = await async_eval(binding[1], local, ctx) + elif len(bindings) % 2 == 0: + for i in range(0, len(bindings), 2): + var = bindings[i] + vname = var.name if isinstance(var, Symbol) else var + local[vname] = await async_eval(bindings[i + 1], local, ctx) + parts = [] + for body_expr in expr[2:]: + parts.append(await _arender(body_expr, local, ctx)) + return "".join(parts) + + +async def _arsf_begin(expr, env, ctx): + parts = [] + for sub in expr[1:]: + parts.append(await _arender(sub, env, ctx)) + return "".join(parts) + + +async def _arsf_define(expr, env, ctx): + await async_eval(expr, env, ctx) + return "" + + +async def _arsf_map(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + parts = [] + for item in coll: + if isinstance(fn, Lambda): + parts.append(await _arender_lambda(fn, (item,), env, ctx)) + elif callable(fn): + parts.append(await _arender(fn(item), env, ctx)) + else: + parts.append(await _arender(item, env, ctx)) + return "".join(parts) + + +async def _arsf_map_indexed(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + parts = [] + for i, item in enumerate(coll): + if isinstance(fn, Lambda): + parts.append(await _arender_lambda(fn, (i, item), env, ctx)) + elif callable(fn): + parts.append(await _arender(fn(i, item), env, ctx)) + else: + parts.append(await _arender(item, env, ctx)) + return "".join(parts) + + +async def _arsf_filter(expr, env, ctx): + result = await async_eval(expr, env, ctx) + return await _arender(result, env, ctx) + + +async def _arsf_for_each(expr, env, ctx): + fn = await async_eval(expr[1], env, ctx) + coll = await async_eval(expr[2], env, ctx) + parts = [] + for item in coll: + if isinstance(fn, Lambda): + parts.append(await _arender_lambda(fn, (item,), env, ctx)) + elif callable(fn): + parts.append(await _arender(fn(item), env, ctx)) + else: + parts.append(await _arender(item, env, ctx)) + return "".join(parts) + + +_ASYNC_RENDER_FORMS: dict[str, Any] = { + "if": _arsf_if, + "when": _arsf_when, + "cond": _arsf_cond, + "let": _arsf_let, + "let*": _arsf_let, + "begin": _arsf_begin, + "do": _arsf_begin, + "define": _arsf_define, + "defcomp": _arsf_define, + "defmacro": _arsf_define, + "defhandler": _arsf_define, + "map": _arsf_map, + "map-indexed": _arsf_map_indexed, + "filter": _arsf_filter, + "for-each": _arsf_for_each, +} diff --git a/shared/sx/evaluator.py b/shared/sx/evaluator.py index 8f07097..5ca2174 100644 --- a/shared/sx/evaluator.py +++ b/shared/sx/evaluator.py @@ -33,7 +33,7 @@ from __future__ import annotations from typing import Any -from .types import Component, Keyword, Lambda, NIL, RelationDef, Symbol +from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, RelationDef, Symbol from .primitives import _PRIMITIVES @@ -117,6 +117,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any: if ho is not None: return ho(expr, env) + # Macro expansion — if head resolves to a Macro, expand then eval + if name in env: + val = env[name] + if isinstance(val, Macro): + expanded = _expand_macro(val, expr[1:], env) + return _eval(expanded, env) + # --- function / lambda call ------------------------------------------- fn = _eval(head, env) args = [_eval(a, env) for a in expr[1:]] @@ -417,6 +424,135 @@ def _sf_thread_first(expr: list, env: dict) -> Any: return result +def _sf_defmacro(expr: list, env: dict) -> Macro: + """``(defmacro name (params... &rest rest) body)``""" + if len(expr) < 4: + raise EvalError("defmacro requires name, params, and body") + name_sym = expr[1] + if not isinstance(name_sym, Symbol): + raise EvalError(f"defmacro name must be symbol, got {type(name_sym).__name__}") + + params_expr = expr[2] + if not isinstance(params_expr, list): + raise EvalError("defmacro params must be a list") + + params: list[str] = [] + rest_param: str | None = None + i = 0 + while i < len(params_expr): + p = params_expr[i] + if isinstance(p, Symbol) and p.name == "&rest": + if i + 1 < len(params_expr): + rp = params_expr[i + 1] + rest_param = rp.name if isinstance(rp, Symbol) else str(rp) + break + if isinstance(p, Symbol): + params.append(p.name) + elif isinstance(p, str): + params.append(p) + i += 1 + + macro = Macro( + params=params, + rest_param=rest_param, + body=expr[3], + closure=dict(env), + name=name_sym.name, + ) + env[name_sym.name] = macro + return macro + + +def _sf_quasiquote(expr: list, env: dict) -> Any: + """``(quasiquote template)`` — process quasiquote template.""" + if len(expr) < 2: + raise EvalError("quasiquote requires a template") + return _qq_expand(expr[1], env) + + +def _qq_expand(template: Any, env: dict) -> Any: + """Walk a quasiquote template, replacing unquote/splice-unquote.""" + if not isinstance(template, list): + return template + if not template: + return [] + # Check for (unquote x) or (splice-unquote x) + head = template[0] + if isinstance(head, Symbol): + if head.name == "unquote": + if len(template) < 2: + raise EvalError("unquote requires an expression") + return _eval(template[1], env) + if head.name == "splice-unquote": + raise EvalError("splice-unquote not inside a list") + # Walk children, handling splice-unquote + result: list[Any] = [] + for item in template: + if isinstance(item, list) and len(item) == 2 and isinstance(item[0], Symbol) and item[0].name == "splice-unquote": + spliced = _eval(item[1], env) + if isinstance(spliced, list): + result.extend(spliced) + elif spliced is not None and spliced is not NIL: + result.append(spliced) + else: + result.append(_qq_expand(item, env)) + return result + + +def _expand_macro(macro: Macro, raw_args: list[Any], env: dict) -> Any: + """Expand a macro: bind unevaluated args, evaluate body to get new AST.""" + local = dict(macro.closure) + local.update(env) + + # Bind positional params + for i, param in enumerate(macro.params): + if i < len(raw_args): + local[param] = raw_args[i] + else: + local[param] = NIL + + # Bind &rest param + if macro.rest_param is not None: + rest_start = len(macro.params) + local[macro.rest_param] = list(raw_args[rest_start:]) + + return _eval(macro.body, local) + + +def _sf_defhandler(expr: list, env: dict) -> HandlerDef: + """``(defhandler name (&key param...) body)``""" + if len(expr) < 4: + raise EvalError("defhandler requires name, params, and body") + name_sym = expr[1] + if not isinstance(name_sym, Symbol): + raise EvalError(f"defhandler name must be symbol, got {type(name_sym).__name__}") + + params_expr = expr[2] + if not isinstance(params_expr, list): + raise EvalError("defhandler params must be a list") + + params: list[str] = [] + in_key = False + for p in params_expr: + if isinstance(p, Symbol): + if p.name == "&key": + in_key = True + continue + if in_key: + params.append(p.name) + elif isinstance(p, str): + params.append(p) + + handler = HandlerDef( + name=name_sym.name, + params=params, + body=expr[3], + closure=dict(env), + ) + env[f"handler:{name_sym.name}"] = handler + return handler + + def _sf_set_bang(expr: list, env: dict) -> Any: """``(set! name value)`` — mutate existing binding.""" if len(expr) != 3: @@ -518,6 +654,9 @@ _SPECIAL_FORMS: dict[str, Any] = { "quote": _sf_quote, "->": _sf_thread_first, "set!": _sf_set_bang, + "defmacro": _sf_defmacro, + "quasiquote": _sf_quasiquote, + "defhandler": _sf_defhandler, } diff --git a/shared/sx/handlers.py b/shared/sx/handlers.py new file mode 100644 index 0000000..9d845bd --- /dev/null +++ b/shared/sx/handlers.py @@ -0,0 +1,205 @@ +""" +Declarative handler registry and blueprint factory. + +Supports ``defhandler`` s-expressions that define fragment handlers +in .sx files instead of Python. Each handler is a self-contained +s-expression with a bounded primitive vocabulary, providing a clear +security boundary and AI legibility. + +Usage:: + + from shared.sx.handlers import create_handler_blueprint, load_handler_file + + # Load handler definitions from .sx files + load_handler_file("blog/sx/handlers/link-card.sx", "blog") + + # Create a blueprint that dispatches to both sx and Python handlers + bp = create_handler_blueprint("blog") + bp.add_python_handler("nav-tree", nav_tree_handler_fn) +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Callable, Awaitable + +from .types import HandlerDef + +logger = logging.getLogger("sx.handlers") + + +# --------------------------------------------------------------------------- +# Registry — service → handler-name → HandlerDef +# --------------------------------------------------------------------------- + +_HANDLER_REGISTRY: dict[str, dict[str, HandlerDef]] = {} + + +def register_handler(service: str, handler_def: HandlerDef) -> None: + """Register a handler definition for a service.""" + if service not in _HANDLER_REGISTRY: + _HANDLER_REGISTRY[service] = {} + _HANDLER_REGISTRY[service][handler_def.name] = handler_def + logger.debug("Registered handler %s:%s", service, handler_def.name) + + +def get_handler(service: str, name: str) -> HandlerDef | None: + """Look up a registered handler by service and name.""" + return _HANDLER_REGISTRY.get(service, {}).get(name) + + +def get_all_handlers(service: str) -> dict[str, HandlerDef]: + """Return all handlers for a service.""" + return dict(_HANDLER_REGISTRY.get(service, {})) + + +def clear_handlers(service: str | None = None) -> None: + """Clear handler registry. If service given, clear only that service.""" + if service is None: + _HANDLER_REGISTRY.clear() + else: + _HANDLER_REGISTRY.pop(service, None) + + +# --------------------------------------------------------------------------- +# Loading — parse .sx files and collect HandlerDef instances +# --------------------------------------------------------------------------- + +def load_handler_file(filepath: str, service_name: str) -> list[HandlerDef]: + """Parse an .sx file, evaluate it, and register any HandlerDef values.""" + from .parser import parse_all + from .evaluator import _eval + from .jinja_bridge import get_component_env + + with open(filepath, encoding="utf-8") as f: + source = f.read() + + # Seed env with component definitions so handlers can reference components + env = dict(get_component_env()) + exprs = parse_all(source) + handlers: list[HandlerDef] = [] + + for expr in exprs: + _eval(expr, env) + + # Collect all HandlerDef values from the env + for key, val in env.items(): + if isinstance(val, HandlerDef): + register_handler(service_name, val) + handlers.append(val) + + return handlers + + +def load_handler_dir(directory: str, service_name: str) -> list[HandlerDef]: + """Load all .sx files from a directory and register handlers.""" + import glob as glob_mod + handlers: list[HandlerDef] = [] + for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))): + handlers.extend(load_handler_file(filepath, service_name)) + return handlers + + +# --------------------------------------------------------------------------- +# Handler execution +# --------------------------------------------------------------------------- + +async def execute_handler( + handler_def: HandlerDef, + service_name: str, + args: dict[str, str] | None = None, +) -> str: + """Execute a declarative handler and return rendered sx/HTML string. + + Uses the async evaluator+renderer so I/O primitives (``query``, + ``service``, ``request-arg``, etc.) are awaited inline within + control flow — no collect-then-substitute limitations. + + 1. Build env from component env + handler closure + 2. Bind handler params from args (typically request.args) + 3. Evaluate + render via async_render (handles I/O inline) + 4. Return rendered string + """ + from .jinja_bridge import get_component_env, _get_request_context + from .async_eval import async_render + from .types import NIL + + if args is None: + args = {} + + # Build environment + env = dict(get_component_env()) + env.update(handler_def.closure) + + # Bind handler params from request args + for param in handler_def.params: + env[param] = args.get(param, args.get(param.replace("-", "_"), NIL)) + + # Get request context for I/O primitives + ctx = _get_request_context() + + # Async eval+render — I/O primitives are awaited inline + return await async_render(handler_def.body, env, ctx) + + +# --------------------------------------------------------------------------- +# Blueprint factory +# --------------------------------------------------------------------------- + +def create_handler_blueprint(service_name: str) -> Any: + """Create a Quart Blueprint that dispatches fragment requests to + both sx-defined handlers and Python handler functions. + + Usage:: + + bp = create_handler_blueprint("blog") + bp.add_python_handler("nav-tree", my_python_handler) + app.register_blueprint(bp) + """ + from quart import Blueprint, Response, request + from shared.infrastructure.fragments import FRAGMENT_HEADER + + bp = Blueprint( + f"sx_handlers_{service_name}", + __name__, + url_prefix="/internal/fragments", + ) + + # Python-side handler overrides + _python_handlers: dict[str, Callable[[], Awaitable[str]]] = {} + + @bp.before_request + async def _require_fragment_header(): + if not request.headers.get(FRAGMENT_HEADER): + return Response("", status=403) + + @bp.get("/") + async def get_fragment(fragment_type: str): + # 1. Check Python handlers first (manual overrides) + py_handler = _python_handlers.get(fragment_type) + if py_handler is not None: + result = await py_handler() + return Response(result, status=200, content_type="text/sx") + + # 2. Check sx handler registry + handler_def = get_handler(service_name, fragment_type) + if handler_def is not None: + result = await execute_handler( + handler_def, + service_name, + args=dict(request.args), + ) + return Response(result, status=200, content_type="text/sx") + + # 3. No handler found — return empty + return Response("", status=200, content_type="text/sx") + + def add_python_handler(name: str, fn: Callable[[], Awaitable[str]]) -> None: + """Register a Python async function as a fragment handler.""" + _python_handlers[name] = fn + + bp.add_python_handler = add_python_handler # type: ignore[attr-defined] + bp._python_handlers = _python_handlers # type: ignore[attr-defined] + + return bp diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index a317781..9cdc46e 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -314,15 +314,15 @@ def sx_call(component_name: str, **kwargs: Any) -> str: def components_for_request() -> str: - """Return defcomp source for components the client doesn't have yet. + """Return defcomp/defmacro source for definitions the client doesn't have yet. Reads the ``SX-Components`` header (comma-separated component names like ``~card,~nav-item``) and returns only the definitions the client - is missing. If the header is absent, returns all component defs. + is missing. If the header is absent, returns all defs. """ from quart import request from .jinja_bridge import client_components_tag, _COMPONENT_ENV - from .types import Component + from .types import Component, Macro from .parser import serialize loaded_raw = request.headers.get("SX-Components", "") @@ -338,18 +338,26 @@ def components_for_request() -> str: loaded = set(loaded_raw.split(",")) parts = [] for key, val in _COMPONENT_ENV.items(): - if not isinstance(val, Component): - continue - # Skip components the client already has - if f"~{val.name}" in loaded or val.name in loaded: - continue - # Reconstruct defcomp source - param_strs = ["&key"] + list(val.params) - if val.has_children: - param_strs.extend(["&rest", "children"]) - params_sx = "(" + " ".join(param_strs) + ")" - body_sx = serialize(val.body, pretty=True) - parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})") + if isinstance(val, Component): + # Skip components the client already has + if f"~{val.name}" in loaded or val.name in loaded: + continue + # Reconstruct defcomp source + param_strs = ["&key"] + list(val.params) + if val.has_children: + param_strs.extend(["&rest", "children"]) + params_sx = "(" + " ".join(param_strs) + ")" + body_sx = serialize(val.body, pretty=True) + parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})") + elif isinstance(val, Macro): + if val.name in loaded: + continue + param_strs = list(val.params) + if val.rest_param: + param_strs.extend(["&rest", val.rest_param]) + params_sx = "(" + " ".join(param_strs) + ")" + body_sx = serialize(val.body, pretty=True) + parts.append(f"(defmacro {val.name} {params_sx} {body_sx})") return "\n".join(parts) diff --git a/shared/sx/html.py b/shared/sx/html.py index c671fb7..62a94f0 100644 --- a/shared/sx/html.py +++ b/shared/sx/html.py @@ -27,8 +27,8 @@ from __future__ import annotations import contextvars from typing import Any -from .types import Component, Keyword, Lambda, NIL, Symbol -from .evaluator import _eval, _call_component +from .types import Component, Keyword, Lambda, Macro, NIL, Symbol +from .evaluator import _eval, _call_component, _expand_macro # ContextVar for collecting CSS class names during render. # Set to a set[str] to collect; None to skip. @@ -360,6 +360,8 @@ _RENDER_FORMS: dict[str, Any] = { "map-indexed": _rsf_map_indexed, "filter": _rsf_filter, "for-each": _rsf_for_each, + "defmacro": _rsf_define, # side-effect only, returns "" + "defhandler": _rsf_define, # side-effect only, returns "" } @@ -420,6 +422,13 @@ def _render_list(expr: list, env: dict[str, Any]) -> str: if name in _RENDER_FORMS: return _RENDER_FORMS[name](expr, env) + # --- Macro expansion → expand then render -------------------------- + if name in env: + val = env[name] + if isinstance(val, Macro): + expanded = _expand_macro(val, expr[1:], env) + return _render(expanded, env) + # --- HTML tag → render as element --------------------------------- if name in HTML_TAGS: return _render_element(name, expr[1:], env) diff --git a/shared/sx/jinja_bridge.py b/shared/sx/jinja_bridge.py index f6025a9..46c6d8a 100644 --- a/shared/sx/jinja_bridge.py +++ b/shared/sx/jinja_bridge.py @@ -25,7 +25,7 @@ import hashlib import os from typing import Any -from .types import NIL, Component, Keyword, Symbol +from .types import NIL, Component, Keyword, Macro, Symbol from .parser import parse from .html import render as html_render, _render_component @@ -54,7 +54,7 @@ def get_component_hash() -> str: def _compute_component_hash() -> None: - """Recompute _COMPONENT_HASH from all registered Component definitions.""" + """Recompute _COMPONENT_HASH from all registered Component and Macro definitions.""" global _COMPONENT_HASH from .parser import serialize parts = [] @@ -67,6 +67,13 @@ def _compute_component_hash() -> None: params_sx = "(" + " ".join(param_strs) + ")" body_sx = serialize(val.body) parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})") + elif isinstance(val, Macro): + param_strs = list(val.params) + if val.rest_param: + param_strs.extend(["&rest", val.rest_param]) + params_sx = "(" + " ".join(param_strs) + ")" + body_sx = serialize(val.body) + parts.append(f"(defmacro {val.name} {params_sx} {body_sx})") if parts: digest = hashlib.sha256("\n".join(parts).encode()).hexdigest()[:12] _COMPONENT_HASH = digest @@ -118,13 +125,33 @@ def reload_if_changed() -> None: load_sx_dir(directory) -def load_service_components(service_dir: str) -> None: - """Load service-specific s-expression components from {service_dir}/sx/.""" +def load_service_components(service_dir: str, service_name: str | None = None) -> None: + """Load service-specific s-expression components and handlers. + + Components from ``{service_dir}/sx/`` and handlers from + ``{service_dir}/sx/handlers/`` or ``{service_dir}/sx/handlers.sx``. + """ sx_dir = os.path.join(service_dir, "sx") if os.path.isdir(sx_dir): load_sx_dir(sx_dir) watch_sx_dir(sx_dir) + # Load handler definitions if service_name is provided + if service_name: + load_handler_dir(os.path.join(sx_dir, "handlers"), service_name) + # Also check for a single handlers.sx file + handlers_file = os.path.join(sx_dir, "handlers.sx") + if os.path.isfile(handlers_file): + from .handlers import load_handler_file + load_handler_file(handlers_file, service_name) + + +def load_handler_dir(directory: str, service_name: str) -> None: + """Load handler .sx files from a directory if it exists.""" + if os.path.isdir(directory): + from .handlers import load_handler_dir as _load + _load(directory, service_name) + def register_components(sx_source: str) -> None: """Parse and evaluate s-expression component definitions into the @@ -262,17 +289,25 @@ def client_components_tag(*names: str) -> str: from .parser import serialize parts = [] for key, val in _COMPONENT_ENV.items(): - if not isinstance(val, Component): - continue - if names and val.name not in names and key.lstrip("~") not in names: - continue - # Reconstruct defcomp source from the Component object - param_strs = ["&key"] + list(val.params) - if val.has_children: - param_strs.extend(["&rest", "children"]) - params_sx = "(" + " ".join(param_strs) + ")" - body_sx = serialize(val.body, pretty=True) - parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})") + if isinstance(val, Component): + if names and val.name not in names and key.lstrip("~") not in names: + continue + # Reconstruct defcomp source from the Component object + param_strs = ["&key"] + list(val.params) + if val.has_children: + param_strs.extend(["&rest", "children"]) + params_sx = "(" + " ".join(param_strs) + ")" + body_sx = serialize(val.body, pretty=True) + parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})") + elif isinstance(val, Macro): + if names and val.name not in names: + continue + param_strs = list(val.params) + if val.rest_param: + param_strs.extend(["&rest", val.rest_param]) + params_sx = "(" + " ".join(param_strs) + ")" + body_sx = serialize(val.body, pretty=True) + parts.append(f"(defmacro {val.name} {params_sx} {body_sx})") if not parts: return "" source = "\n".join(parts) diff --git a/shared/sx/parser.py b/shared/sx/parser.py index f8d9af1..3d085cf 100644 --- a/shared/sx/parser.py +++ b/shared/sx/parser.py @@ -225,6 +225,20 @@ def _parse_expr(tok: Tokenizer) -> Any: if raw == "{": tok.next_token() # consume the '{' return _parse_map(tok) + # Quasiquote syntax: ` , ,@ + if raw == "`": + tok._advance(1) # consume the backtick + inner = _parse_expr(tok) + return [Symbol("quasiquote"), inner] + if raw == ",": + tok._advance(1) # consume the comma + # Check for splice-unquote (,@) — no whitespace between , and @ + if tok.pos < len(tok.text) and tok.text[tok.pos] == "@": + tok._advance(1) # consume the @ + inner = _parse_expr(tok) + return [Symbol("splice-unquote"), inner] + inner = _parse_expr(tok) + return [Symbol("unquote"), inner] # Everything else: strings, keywords, symbols, numbers token = tok.next_token() return token @@ -276,6 +290,15 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str: if isinstance(expr, list): if not expr: return "()" + # Quasiquote sugar: [Symbol("quasiquote"), x] → `x + if (len(expr) == 2 and isinstance(expr[0], Symbol)): + name = expr[0].name + if name == "quasiquote": + return "`" + serialize(expr[1], indent, pretty) + if name == "unquote": + return "," + serialize(expr[1], indent, pretty) + if name == "splice-unquote": + return ",@" + serialize(expr[1], indent, pretty) if pretty: return _serialize_pretty(expr, indent) items = [serialize(item, indent, False) for item in expr] diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index cd0d504..29d4fd5 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -408,6 +408,84 @@ def prim_into(target: Any, coll: Any) -> Any: raise ValueError(f"into: unsupported target type {type(target).__name__}") +# --------------------------------------------------------------------------- +# URL helpers +# --------------------------------------------------------------------------- + +@register_primitive("app-url") +def prim_app_url(service: str, path: str = "/") -> str: + """``(app-url "blog" "/my-post/")`` → full URL for service.""" + from shared.infrastructure.urls import app_url + return app_url(service, path) + + +@register_primitive("url-for") +def prim_url_for(endpoint: str, **kwargs: Any) -> str: + """``(url-for "endpoint")`` → quart.url_for.""" + from quart import url_for + return url_for(endpoint, **kwargs) + + +@register_primitive("asset-url") +def prim_asset_url(path: str = "") -> str: + """``(asset-url "/img/logo.png")`` → versioned static URL.""" + from shared.infrastructure.urls import asset_url + return asset_url(path) + + +@register_primitive("config") +def prim_config(key: str) -> Any: + """``(config "key")`` → shared.config.config()[key].""" + from shared.config import config + cfg = config() + return cfg.get(key) + + +@register_primitive("jinja-global") +def prim_jinja_global(key: str, default: Any = None) -> Any: + """``(jinja-global "key")`` → current_app.jinja_env.globals[key].""" + from quart import current_app + return current_app.jinja_env.globals.get(key, default) + + +@register_primitive("relations-from") +def prim_relations_from(entity_type: str) -> list[dict]: + """``(relations-from "page")`` → list of RelationDef dicts.""" + from shared.sx.relations import relations_from + return [ + { + "name": d.name, "from_type": d.from_type, "to_type": d.to_type, + "cardinality": d.cardinality, "nav": d.nav, + "nav_icon": d.nav_icon, "nav_label": d.nav_label, + } + for d in relations_from(entity_type) + ] + + +# --------------------------------------------------------------------------- +# Format helpers +# --------------------------------------------------------------------------- + +@register_primitive("format-date") +def prim_format_date(date_str: Any, fmt: str) -> str: + """``(format-date date-str fmt)`` → formatted date string.""" + from datetime import datetime + try: + dt = datetime.fromisoformat(str(date_str)) + return dt.strftime(fmt) + except (ValueError, TypeError): + return str(date_str) if date_str else "" + + +@register_primitive("parse-int") +def prim_parse_int(val: Any, default: Any = 0) -> int | Any: + """``(parse-int val default?)`` → int(val) with fallback.""" + try: + return int(val) + except (ValueError, TypeError): + return default + + # --------------------------------------------------------------------------- # Assertions # --------------------------------------------------------------------------- diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 39a586a..1cb8f06 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -19,6 +19,7 @@ Usage in s-expressions:: from __future__ import annotations +import contextvars from typing import Any # --------------------------------------------------------------------------- @@ -34,6 +35,11 @@ IO_PRIMITIVES: frozenset[str] = frozenset({ "action", "current-user", "htmx-request?", + "service", + "request-arg", + "request-path", + "nav-tree", + "get-children", }) @@ -41,6 +47,23 @@ IO_PRIMITIVES: frozenset[str] = frozenset({ # Request context (set per-request by the resolver) # --------------------------------------------------------------------------- +# ContextVar for the handler's domain service object. +# Set by the handler blueprint before executing a defhandler. +_handler_service: contextvars.ContextVar[Any] = contextvars.ContextVar( + "_handler_service", default=None +) + + +def set_handler_service(service_obj: Any) -> None: + """Bind the local domain service for ``(service ...)`` primitive calls.""" + _handler_service.set(service_obj) + + +def get_handler_service() -> Any: + """Get the currently bound handler service, or None.""" + return _handler_service.get(None) + + class RequestContext: """Per-request context provided to I/O primitives. @@ -140,6 +163,129 @@ async def _io_htmx_request( return ctx.is_htmx +async def _io_service( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(service "svc-name" "method-name" :key val ...)`` → call domain service. + + Looks up the service from the shared registry by name, then calls the + named method with ``g.s`` (async session) + keyword args. Falls back + to the bound handler service if only one positional arg is given. + """ + if not args: + raise ValueError("service requires at least a method name") + + if len(args) >= 2: + # (service "calendar" "associated-entries" :key val ...) + from shared.services.registry import services as svc_registry + svc_name = str(args[0]).replace("-", "_") + svc = getattr(svc_registry, svc_name, None) + if svc is None: + raise RuntimeError(f"No service registered as: {svc_name}") + method_name = str(args[1]).replace("-", "_") + else: + # (service "method-name" :key val ...) — legacy / bound service + svc = get_handler_service() + if svc is None: + raise RuntimeError( + "No handler service bound — cannot call (service ...)") + method_name = str(args[0]).replace("-", "_") + + method = getattr(svc, method_name, None) + if method is None: + raise RuntimeError(f"Service has no method: {method_name}") + + # Convert kwarg keys from kebab-case to snake_case + clean_kwargs = {k.replace("-", "_"): v for k, v in kwargs.items()} + from quart import g + result = await method(g.s, **clean_kwargs) + + return _convert_result(result) + + +def _dto_to_dict(obj: Any) -> dict[str, Any]: + """Convert a DTO/dataclass/namedtuple to a plain dict. + + Adds ``{field}_year``, ``{field}_month``, ``{field}_day`` convenience + keys for any datetime-valued field so sx handlers can build URL paths + without parsing date strings. + """ + if hasattr(obj, "_asdict"): + d = dict(obj._asdict()) + elif hasattr(obj, "__dict__"): + d = {k: v for k, v in obj.__dict__.items() if not k.startswith("_")} + else: + return {"value": obj} + # Expand datetime fields into year/month/day convenience keys + for key, val in list(d.items()): + if hasattr(val, "year") and hasattr(val, "strftime"): + d[f"{key}_year"] = val.year + d[f"{key}_month"] = val.month + d[f"{key}_day"] = val.day + return d + + +def _convert_result(result: Any) -> Any: + """Convert a service method result for sx consumption.""" + if result is None: + from .types import NIL + return NIL + if isinstance(result, tuple): + # Tuple returns (e.g. (entries, has_more)) → list for sx access + return [_convert_result(item) for item in result] + if hasattr(result, "__dataclass_fields__") or hasattr(result, "_asdict"): + return _dto_to_dict(result) + if isinstance(result, list): + return [ + _dto_to_dict(item) + if hasattr(item, "__dataclass_fields__") or hasattr(item, "_asdict") + else item + for item in result + ] + return result + + +async def _io_request_arg( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-arg "name" default?)`` → request.args.get(name, default).""" + if not args: + raise ValueError("request-arg requires a name") + from quart import request + name = str(args[0]) + default = args[1] if len(args) > 1 else None + return request.args.get(name, default) + + +async def _io_request_path( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(request-path)`` → request.path.""" + from quart import request + return request.path + + +async def _io_nav_tree( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> list[dict[str, Any]]: + """``(nav-tree)`` → list of navigation menu node dicts.""" + from quart import g + from shared.services.navigation import get_navigation_tree + nodes = await get_navigation_tree(g.s) + return [_dto_to_dict(node) for node in nodes] + + +async def _io_get_children( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> list[dict[str, Any]]: + """``(get-children :parent-type "page" :parent-id 1 ...)``""" + from quart import g + from shared.services.relationships import get_children + clean = {k.replace("-", "_"): v for k, v in kwargs.items()} + children = await get_children(g.s, **clean) + return [_dto_to_dict(child) for child in children] + + # --------------------------------------------------------------------------- # Handler registry # --------------------------------------------------------------------------- @@ -150,4 +296,9 @@ _IO_HANDLERS: dict[str, Any] = { "action": _io_action, "current-user": _io_current_user, "htmx-request?": _io_htmx_request, + "service": _io_service, + "request-arg": _io_request_arg, + "request-path": _io_request_path, + "nav-tree": _io_nav_tree, + "get-children": _io_get_children, } diff --git a/shared/sx/tests/test_evaluator.py b/shared/sx/tests/test_evaluator.py index 2b7943c..f4e1d76 100644 --- a/shared/sx/tests/test_evaluator.py +++ b/shared/sx/tests/test_evaluator.py @@ -2,7 +2,7 @@ import pytest from shared.sx import parse, evaluate, EvalError, Symbol, Keyword, NIL -from shared.sx.types import Lambda, Component +from shared.sx.types import Lambda, Component, Macro, HandlerDef # --------------------------------------------------------------------------- @@ -324,3 +324,82 @@ class TestSetBang: env = {"x": 1} ev("(set! x 42)", env) assert env["x"] == 42 + + +# --------------------------------------------------------------------------- +# Macros +# --------------------------------------------------------------------------- + +class TestMacro: + def test_defmacro_creates_macro(self): + env = {} + ev("(defmacro double (x) `(+ ,x ,x))", env) + assert isinstance(env["double"], Macro) + assert env["double"].name == "double" + + def test_simple_expansion(self): + env = {} + ev("(defmacro double (x) `(+ ,x ,x))", env) + assert ev("(double 5)", env) == 10 + + def test_quasiquote_with_splice(self): + env = {} + ev("(defmacro add-all (&rest nums) `(+ ,@nums))", env) + assert ev("(add-all 1 2 3)", env) == 6 + + def test_rest_param(self): + env = {} + ev("(defmacro my-list (&rest items) `(list ,@items))", env) + assert ev("(my-list 1 2 3)", env) == [1, 2, 3] + + def test_macro_with_let(self): + env = {} + ev("(defmacro bind-and-add (name val) `(let ((,name ,val)) (+ ,name 1)))", env) + assert ev("(bind-and-add x 10)", env) == 11 + + def test_quasiquote_standalone(self): + """Quasiquote without defmacro works for template expansion.""" + env = {"x": 42} + result = ev("`(a ,x b)", env) + assert result == [Symbol("a"), 42, Symbol("b")] + + def test_quasiquote_splice(self): + env = {"rest": [1, 2, 3]} + result = ev("`(a ,@rest b)", env) + assert result == [Symbol("a"), 1, 2, 3, Symbol("b")] + + def test_macro_wrong_arity(self): + """Macro with too few args gets NIL for missing params.""" + env = {} + ev("(defmacro needs-two (a b) `(+ ,a ,b))", env) + # Calling with 1 arg — b becomes NIL + with pytest.raises(Exception): + ev("(needs-two 5)", env) + + def test_macro_in_html_render(self): + """Macros expand correctly in HTML render context.""" + from shared.sx.html import render as html_render + env = {} + ev('(defmacro bold (text) `(strong ,text))', env) + expr = parse('(bold "hello")') + result = html_render(expr, env) + assert result == "hello" + + +# --------------------------------------------------------------------------- +# defhandler +# --------------------------------------------------------------------------- + +class TestDefhandler: + def test_defhandler_creates_handler(self): + env = {} + ev("(defhandler link-card (&key slug keys) slug)", env) + assert isinstance(env["handler:link-card"], HandlerDef) + assert env["handler:link-card"].name == "link-card" + assert env["handler:link-card"].params == ["slug", "keys"] + + def test_defhandler_body_preserved(self): + env = {} + ev("(defhandler test-handler (&key id) (str id))", env) + handler = env["handler:test-handler"] + assert handler.body is not None diff --git a/shared/sx/tests/test_handlers.py b/shared/sx/tests/test_handlers.py new file mode 100644 index 0000000..d6f6db2 --- /dev/null +++ b/shared/sx/tests/test_handlers.py @@ -0,0 +1,159 @@ +"""Tests for the declarative handler system.""" + +import asyncio +import pytest +from shared.sx import parse, evaluate +from shared.sx.types import HandlerDef +from shared.sx.handlers import ( + register_handler, + get_handler, + get_all_handlers, + clear_handlers, +) +from shared.sx.async_eval import async_eval, async_render +from shared.sx.primitives_io import RequestContext + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +class TestHandlerRegistry: + def setup_method(self): + clear_handlers() + + def test_register_and_get(self): + env = {} + evaluate(parse("(defhandler test-card (&key slug) slug)"), env) + handler = env["handler:test-card"] + register_handler("blog", handler) + assert get_handler("blog", "test-card") is handler + + def test_get_nonexistent(self): + assert get_handler("blog", "nope") is None + + def test_get_all_handlers(self): + env = {} + evaluate(parse("(defhandler h1 (&key a) a)"), env) + evaluate(parse("(defhandler h2 (&key b) b)"), env) + register_handler("svc", env["handler:h1"]) + register_handler("svc", env["handler:h2"]) + all_h = get_all_handlers("svc") + assert "h1" in all_h + assert "h2" in all_h + + def test_clear_service(self): + env = {} + evaluate(parse("(defhandler h1 (&key a) a)"), env) + register_handler("svc", env["handler:h1"]) + clear_handlers("svc") + assert get_handler("svc", "h1") is None + + def test_clear_all(self): + env = {} + evaluate(parse("(defhandler h1 (&key a) a)"), env) + register_handler("svc1", env["handler:h1"]) + register_handler("svc2", env["handler:h1"]) + clear_handlers() + assert get_all_handlers("svc1") == {} + assert get_all_handlers("svc2") == {} + + +# --------------------------------------------------------------------------- +# HandlerDef creation via evaluator +# --------------------------------------------------------------------------- + +class TestHandlerDefCreation: + def test_basic(self): + env = {} + evaluate(parse("(defhandler my-handler (&key id name) (str id name))"), env) + h = env["handler:my-handler"] + assert isinstance(h, HandlerDef) + assert h.name == "my-handler" + assert h.params == ["id", "name"] + + def test_no_params(self): + env = {} + evaluate(parse("(defhandler simple (&key) 42)"), env) + h = env["handler:simple"] + assert h.params == [] + + def test_handler_closure_captures_env(self): + env = {"x": 99} + evaluate(parse("(defhandler uses-closure (&key) x)"), env) + h = env["handler:uses-closure"] + assert h.closure.get("x") == 99 + + +# --------------------------------------------------------------------------- +# Async evaluator +# --------------------------------------------------------------------------- + +class TestAsyncEval: + def test_literals(self): + ctx = RequestContext() + assert asyncio.get_event_loop().run_until_complete( + async_eval(parse("42"), {}, ctx)) == 42 + + def test_let_and_arithmetic(self): + ctx = RequestContext() + result = asyncio.get_event_loop().run_until_complete( + async_eval(parse("(let ((x 10) (y 20)) (+ x y))"), {}, ctx)) + assert result == 30 + + def test_if_when(self): + ctx = RequestContext() + result = asyncio.get_event_loop().run_until_complete( + async_eval(parse("(if true 1 2)"), {}, ctx)) + assert result == 1 + + def test_map_lambda(self): + ctx = RequestContext() + result = asyncio.get_event_loop().run_until_complete( + async_eval(parse("(map (fn (x) (* x x)) (list 1 2 3))"), {}, ctx)) + assert result == [1, 4, 9] + + def test_macro_expansion(self): + ctx = RequestContext() + env = {} + asyncio.get_event_loop().run_until_complete( + async_eval(parse("(defmacro double (x) `(+ ,x ,x))"), env, ctx)) + result = asyncio.get_event_loop().run_until_complete( + async_eval(parse("(double 5)"), env, ctx)) + assert result == 10 + + +class TestAsyncRender: + def test_simple_html(self): + ctx = RequestContext() + result = asyncio.get_event_loop().run_until_complete( + async_render(parse('(div :class "test" "hello")'), {}, ctx)) + assert result == '
hello
' + + def test_component(self): + ctx = RequestContext() + env = {} + evaluate(parse('(defcomp ~bold (&key text) (strong text))'), env) + result = asyncio.get_event_loop().run_until_complete( + async_render(parse('(~bold :text "hi")'), env, ctx)) + assert result == "hi" + + def test_let_with_render(self): + ctx = RequestContext() + result = asyncio.get_event_loop().run_until_complete( + async_render(parse('(let ((x "hello")) (span x))'), {}, ctx)) + assert result == "hello" + + def test_map_render(self): + ctx = RequestContext() + result = asyncio.get_event_loop().run_until_complete( + async_render(parse('(ul (map (fn (x) (li x)) (list "a" "b")))'), {}, ctx)) + assert result == "
  • a
  • b
" + + def test_macro_in_render(self): + ctx = RequestContext() + env = {} + evaluate(parse('(defmacro em-text (t) `(em ,t))'), env) + result = asyncio.get_event_loop().run_until_complete( + async_render(parse('(em-text "wow")'), env, ctx)) + assert result == "wow" diff --git a/shared/sx/tests/test_parser.py b/shared/sx/tests/test_parser.py index 4a6cb6b..32c26be 100644 --- a/shared/sx/tests/test_parser.py +++ b/shared/sx/tests/test_parser.py @@ -123,6 +123,52 @@ class TestParseAll: assert parse_all(" ; only comments\n") == [] +# --------------------------------------------------------------------------- +# Quasiquote +# --------------------------------------------------------------------------- + +class TestQuasiquote: + def test_quasiquote_symbol(self): + result = parse("`x") + assert result == [Symbol("quasiquote"), Symbol("x")] + + def test_quasiquote_list(self): + result = parse("`(a b c)") + assert result == [Symbol("quasiquote"), [Symbol("a"), Symbol("b"), Symbol("c")]] + + def test_unquote(self): + result = parse(",x") + assert result == [Symbol("unquote"), Symbol("x")] + + def test_splice_unquote(self): + result = parse(",@xs") + assert result == [Symbol("splice-unquote"), Symbol("xs")] + + def test_quasiquote_with_unquote(self): + result = parse("`(a ,x b)") + assert result == [Symbol("quasiquote"), [ + Symbol("a"), + [Symbol("unquote"), Symbol("x")], + Symbol("b"), + ]] + + def test_quasiquote_with_splice(self): + result = parse("`(a ,@rest)") + assert result == [Symbol("quasiquote"), [ + Symbol("a"), + [Symbol("splice-unquote"), Symbol("rest")], + ]] + + def test_roundtrip_quasiquote(self): + assert serialize(parse("`(a ,x ,@rest)")) == "`(a ,x ,@rest)" + + def test_roundtrip_unquote(self): + assert serialize(parse(",x")) == ",x" + + def test_roundtrip_splice_unquote(self): + assert serialize(parse(",@xs")) == ",@xs" + + # --------------------------------------------------------------------------- # Errors # --------------------------------------------------------------------------- diff --git a/shared/sx/types.py b/shared/sx/types.py index e81e043..c916f76 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -127,6 +127,29 @@ class Lambda: return evaluator(self.body, local) +# --------------------------------------------------------------------------- +# Macro +# --------------------------------------------------------------------------- + +@dataclass +class Macro: + """A macro — an AST-transforming function. + + Created by ``(defmacro name (params... &rest rest) body)``. + Receives unevaluated arguments, evaluates its body to produce a new + s-expression, which is then evaluated in the caller's environment. + """ + params: list[str] + rest_param: str | None # &rest parameter name + body: Any # unevaluated — returns an s-expression to eval + closure: dict[str, Any] = field(default_factory=dict) + name: str | None = None + + def __repr__(self): + tag = self.name or "macro" + return f"<{tag}({', '.join(self.params)})>" + + # --------------------------------------------------------------------------- # Component # --------------------------------------------------------------------------- @@ -149,6 +172,27 @@ class Component: return f"" +# --------------------------------------------------------------------------- +# HandlerDef +# --------------------------------------------------------------------------- + +@dataclass +class HandlerDef: + """A declarative fragment handler defined in an .sx file. + + Created by ``(defhandler name (&key param...) body)``. + The body is evaluated in a sandboxed environment with only + s-expression primitives available. + """ + name: str + params: list[str] # keyword parameter names + body: Any # unevaluated s-expression body + closure: dict[str, Any] = field(default_factory=dict) + + def __repr__(self): + return f"" + + # --------------------------------------------------------------------------- # RelationDef # --------------------------------------------------------------------------- @@ -174,4 +218,4 @@ class RelationDef: # --------------------------------------------------------------------------- # An s-expression value after evaluation -SExp = int | float | str | bool | Symbol | Keyword | Lambda | Component | RelationDef | list | dict | _Nil | None +SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | list | dict | _Nil | None