Add macros, declarative handlers (defhandler), and convert all fragment routes to sx

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

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

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

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

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

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

View File

@@ -3,8 +3,8 @@
Exposes sx fragments at ``/internal/fragments/<type>`` 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("/<fragment_type>")
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

View File

@@ -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" "")))

View File

@@ -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")
# ---------------------------------------------------------------------------

View File

@@ -2,21 +2,22 @@
Exposes sx fragments at ``/internal/fragments/<type>`` 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("/<fragment_type>")
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"<!-- fragment:{s} -->")
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

View File

@@ -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 "<!-- fragment:" (trim s) " -->")
(~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"))))))

View File

@@ -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)))))

View File

@@ -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:

View File

@@ -3,61 +3,21 @@
Exposes sx fragments at ``/internal/fragments/<type>`` 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("/<fragment_type>")
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

View File

@@ -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"))

View File

@@ -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))))

View File

@@ -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")
# ---------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,9 @@
Exposes sx fragments at ``/internal/fragments/<type>`` 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("/<fragment_type>")
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"<!-- fragment:{u} -->")
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

View File

@@ -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 "<!-- fragment:" u " -->")
(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")))))))

View File

@@ -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")
# ---------------------------------------------------------------------------

View File

@@ -2,21 +2,22 @@
Exposes sx fragments at ``/internal/fragments/<type>`` 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("/<fragment_type>")
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"<!-- fragment:{s} -->")
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

View File

@@ -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))))))

View File

@@ -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 "<!-- fragment:" s " -->")
(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)))))))

View File

@@ -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")
# ---------------------------------------------------------------------------

View File

@@ -1,29 +1,23 @@
"""Orders app fragment endpoints.
Fragments:
account-nav-item "orders" link for account dashboard
Exposes sx fragments at ``/internal/fragments/<type>`` 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("/<fragment_type>")
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

View File

@@ -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"))

View File

@@ -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")
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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/<type>`` 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("/<fragment_type>")
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

0
relations/sx/__init__.py Normal file
View File

View File

@@ -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)))))

View File

@@ -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")

View File

@@ -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,

View File

@@ -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,

View File

@@ -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") {

View File

@@ -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",

837
shared/sx/async_eval.py Normal file
View File

@@ -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)}</{tag}>"
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,
}

View File

@@ -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,
}

205
shared/sx/handlers.py Normal file
View File

@@ -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("/<fragment_type>")
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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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,
}

View File

@@ -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 == "<strong>hello</strong>"
# ---------------------------------------------------------------------------
# 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

View File

@@ -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 == '<div class="test">hello</div>'
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 == "<strong>hi</strong>"
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 == "<span>hello</span>"
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 == "<ul><li>a</li><li>b</li></ul>"
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 == "<em>wow</em>"

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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"<Component ~{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# 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"<handler:{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# 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