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:
@@ -3,8 +3,8 @@
|
|||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
by other coop apps via the fragment client.
|
||||||
|
|
||||||
Fragments:
|
All handlers are defined declaratively in .sx files under
|
||||||
auth-menu Desktop + mobile auth menu (sign-in or user link)
|
``account/sx/handlers/`` and dispatched via the sx handler registry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,32 +12,12 @@ from __future__ import annotations
|
|||||||
from quart import Blueprint, Response, request
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
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
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -45,10 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("account", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
src = await handler()
|
handler_def, "account", args=dict(request.args),
|
||||||
return Response(src, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
|
return Response("", status=200, content_type="text/sx")
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
8
account/sx/handlers/auth-menu.sx
Normal file
8
account/sx/handlers/auth-menu.sx
Normal 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" "")))
|
||||||
@@ -15,8 +15,9 @@ from shared.sx.helpers import (
|
|||||||
root_header_sx, full_page_sx, header_child_sx, oob_page_sx,
|
root_header_sx, full_page_sx, header_child_sx, oob_page_sx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load account-specific .sx components at import time
|
# Load account-specific .sx components + handlers at import time
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
service_name="account")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,21 +2,22 @@
|
|||||||
|
|
||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
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 __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.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
from shared.services.navigation import get_navigation_tree
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
_handlers: dict[str, object] = {}
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -24,138 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("blog", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
result = await handler()
|
handler_def, "blog", args=dict(request.args),
|
||||||
return Response(result, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
# --- nav-tree fragment — returns sx source ---
|
return Response("", status=200, content_type="text/sx")
|
||||||
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
|
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
31
blog/sx/handlers/link-card.sx
Normal file
31
blog/sx/handlers/link-card.sx
Normal 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"))))))
|
||||||
80
blog/sx/handlers/nav-tree.sx
Normal file
80
blog/sx/handlers/nav-tree.sx
Normal 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)))))
|
||||||
@@ -28,8 +28,8 @@ from shared.sx.helpers import (
|
|||||||
full_page_sx,
|
full_page_sx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load blog service .sx component definitions
|
# Load blog service .sx component definitions + handler definitions
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)), service_name="blog")
|
||||||
|
|
||||||
|
|
||||||
def _ctx_csrf(ctx: dict) -> str:
|
def _ctx_csrf(ctx: dict) -> str:
|
||||||
|
|||||||
@@ -3,61 +3,21 @@
|
|||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
by other coop apps via the fragment client.
|
||||||
|
|
||||||
Fragments:
|
All handlers are defined declaratively in .sx files under
|
||||||
cart-mini Cart icon with badge (or logo when empty)
|
``cart/sx/handlers/`` and dispatched via the sx handler registry.
|
||||||
account-nav-item "orders" link for account dashboard
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
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
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -65,10 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("cart", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
src = await handler()
|
handler_def, "cart", args=dict(request.args),
|
||||||
return Response(src, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
|
return Response("", status=200, content_type="text/sx")
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
8
cart/sx/handlers/account-nav-item.sx
Normal file
8
cart/sx/handlers/account-nav-item.sx
Normal 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"))
|
||||||
16
cart/sx/handlers/cart-mini.sx
Normal file
16
cart/sx/handlers/cart-mini.sx
Normal 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))))
|
||||||
@@ -20,8 +20,9 @@ from shared.sx.helpers import (
|
|||||||
)
|
)
|
||||||
from shared.infrastructure.urls import market_product_url, cart_url
|
from shared.infrastructure.urls import market_product_url, cart_url
|
||||||
|
|
||||||
# Load cart-specific .sx components at import time
|
# Load cart-specific .sx components + handlers at import time
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
service_name="cart")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
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
|
from __future__ import annotations
|
||||||
@@ -9,9 +15,8 @@ from __future__ import annotations
|
|||||||
from quart import Blueprint, Response, g, render_template, request
|
from quart import Blueprint, Response, g, render_template, request
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
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.services.registry import services
|
||||||
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -19,7 +24,7 @@ def register():
|
|||||||
|
|
||||||
_handlers: dict[str, object] = {}
|
_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"}
|
_html_types = {"container-cards", "account-page"}
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
@@ -29,78 +34,24 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
|
# 1. Check Python handlers first (Jinja HTML types)
|
||||||
handler = _handlers.get(fragment_type)
|
handler = _handlers.get(fragment_type)
|
||||||
if handler is None:
|
if handler is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await handler()
|
||||||
result = await handler()
|
ct = "text/html" if fragment_type in _html_types else "text/sx"
|
||||||
ct = "text/html" if fragment_type in _html_types else "text/sx"
|
return Response(result, status=200, content_type=ct)
|
||||||
return Response(result, status=200, content_type=ct)
|
|
||||||
|
|
||||||
# --- container-nav fragment: calendar entries + calendar links -----------
|
# 2. Check sx handler registry
|
||||||
|
handler_def = get_handler("events", fragment_type)
|
||||||
async def _container_nav_handler():
|
if handler_def is not None:
|
||||||
from quart import current_app
|
result = await execute_handler(
|
||||||
from shared.infrastructure.urls import events_url
|
handler_def, "events", args=dict(request.args),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
for entry in entries:
|
return Response(result, status=200, content_type="text/sx")
|
||||||
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"}))
|
|
||||||
|
|
||||||
# Calendar links nav
|
return Response("", status=200, content_type="text/sx")
|
||||||
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))
|
|
||||||
|
|
||||||
if not parts:
|
# --- container-cards fragment: entries for blog listing cards (Jinja HTML) --
|
||||||
return ""
|
|
||||||
return "(<> " + " ".join(parts) + ")"
|
|
||||||
|
|
||||||
_handlers["container-nav"] = _container_nav_handler
|
|
||||||
|
|
||||||
# --- container-cards fragment: entries for blog listing cards (still Jinja) --
|
|
||||||
|
|
||||||
async def _container_cards_handler():
|
async def _container_cards_handler():
|
||||||
post_ids_raw = request.args.get("post_ids", "")
|
post_ids_raw = request.args.get("post_ids", "")
|
||||||
@@ -122,30 +73,7 @@ def register():
|
|||||||
|
|
||||||
_handlers["container-cards"] = _container_cards_handler
|
_handlers["container-cards"] = _container_cards_handler
|
||||||
|
|
||||||
# --- account-nav-item fragment: tickets + bookings links -----------------
|
# --- account-page fragment: tickets or bookings panel (Jinja HTML) ------
|
||||||
|
|
||||||
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) ------
|
|
||||||
|
|
||||||
async def _account_page_handler():
|
async def _account_page_handler():
|
||||||
slug = request.args.get("slug", "")
|
slug = request.args.get("slug", "")
|
||||||
@@ -169,52 +97,6 @@ def register():
|
|||||||
|
|
||||||
_handlers["account-page"] = _account_page_handler
|
_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
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
19
events/sx/handlers/account-nav-item.sx
Normal file
19
events/sx/handlers/account-nav-item.sx
Normal 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"))))
|
||||||
81
events/sx/handlers/container-nav.sx
Normal file
81
events/sx/handlers/container-nav.sx
Normal 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))))))
|
||||||
34
events/sx/handlers/link-card.sx
Normal file
34
events/sx/handlers/link-card.sx
Normal 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"))))))))))
|
||||||
@@ -21,8 +21,9 @@ from shared.sx.helpers import (
|
|||||||
)
|
)
|
||||||
from shared.sx.parser import SxExpr
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
# Load events-specific .sx components at import time
|
# Load events-specific .sx components + handlers at import time
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
service_name="events")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
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
|
from __future__ import annotations
|
||||||
@@ -9,13 +12,12 @@ from __future__ import annotations
|
|||||||
from quart import Blueprint, Response, request
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
_handlers: dict[str, object] = {}
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -23,60 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("federation", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
src = await handler()
|
handler_def, "federation", args=dict(request.args),
|
||||||
return Response(src, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
# --- link-card fragment: actor profile preview card --------------------------
|
return Response("", status=200, content_type="text/sx")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
40
federation/sx/handlers/link-card.sx
Normal file
40
federation/sx/handlers/link-card.sx
Normal 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")))))))
|
||||||
@@ -17,8 +17,9 @@ from shared.sx.helpers import (
|
|||||||
root_header_sx, full_page_sx, header_child_sx,
|
root_header_sx, full_page_sx, header_child_sx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load federation-specific .sx components at import time
|
# Load federation-specific .sx components + handlers at import time
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
service_name="federation")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,21 +2,22 @@
|
|||||||
|
|
||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
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 __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.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
from shared.services.registry import services
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
_handlers: dict[str, object] = {}
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -24,90 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("market", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
src = await handler()
|
handler_def, "market", args=dict(request.args),
|
||||||
return Response(src, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
# --- container-nav fragment: market links --------------------------------
|
return Response("", status=200, content_type="text/sx")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
21
market/sx/handlers/container-nav.sx
Normal file
21
market/sx/handlers/container-nav.sx
Normal 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))))))
|
||||||
42
market/sx/handlers/link-card.sx
Normal file
42
market/sx/handlers/link-card.sx
Normal 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)))))))
|
||||||
@@ -22,8 +22,9 @@ from shared.sx.helpers import (
|
|||||||
full_page_sx, oob_page_sx,
|
full_page_sx, oob_page_sx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load market-specific .sx components at import time
|
# Load market-specific .sx components + handlers at import time
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
service_name="market")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
"""Orders app fragment endpoints.
|
"""Orders app fragment endpoints.
|
||||||
|
|
||||||
Fragments:
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
account-nav-item "orders" link for account dashboard
|
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 __future__ import annotations
|
||||||
|
|
||||||
from quart import Blueprint, Response, request
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
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
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -31,10 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("orders", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
src = await handler()
|
handler_def, "orders", args=dict(request.args),
|
||||||
return Response(src, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
|
return Response("", status=200, content_type="text/sx")
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
8
orders/sx/handlers/account-nav-item.sx
Normal file
8
orders/sx/handlers/account-nav-item.sx
Normal 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"))
|
||||||
@@ -19,8 +19,9 @@ from shared.sx.helpers import (
|
|||||||
)
|
)
|
||||||
from shared.infrastructure.urls import market_product_url, cart_url
|
from shared.infrastructure.urls import market_product_url, cart_url
|
||||||
|
|
||||||
# Load orders-specific .sx components at import time
|
# Load orders-specific .sx components + handlers at import time
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
service_name="orders")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401
|
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
|
from shared.infrastructure.factory import create_base_app
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
"""Relations app fragment endpoints.
|
"""Relations app fragment endpoints.
|
||||||
|
|
||||||
Generic container-nav fragment that renders navigation items for all
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
related entities, driven by the relation registry.
|
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 __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.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
_handlers: dict[str, object] = {}
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -23,70 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("relations", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
src = await handler()
|
handler_def, "relations", args=dict(request.args),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
for child in children:
|
return Response(result, status=200, content_type="text/sx")
|
||||||
slug = (child.metadata_ or {}).get("slug", "")
|
return Response("", status=200, content_type="text/sx")
|
||||||
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 bp
|
return bp
|
||||||
|
|||||||
0
relations/sx/__init__.py
Normal file
0
relations/sx/__init__.py
Normal file
47
relations/sx/handlers/container-nav.sx
Normal file
47
relations/sx/handlers/container-nav.sx
Normal 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)))))
|
||||||
14
relations/sx/sx_components.py
Normal file
14
relations/sx/sx_components.py
Normal 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")
|
||||||
@@ -138,6 +138,8 @@ class MarketService(Protocol):
|
|||||||
|
|
||||||
async def product_by_id(self, session: AsyncSession, product_id: int) -> ProductDTO | None: ...
|
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(
|
async def create_marketplace(
|
||||||
self, session: AsyncSession, container_type: str, container_id: int,
|
self, session: AsyncSession, container_type: str, container_id: int,
|
||||||
name: str, slug: str,
|
name: str, slug: str,
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ class SqlMarketService:
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return _product_to_dto(product) if product else 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(
|
async def create_marketplace(
|
||||||
self, session: AsyncSession, container_type: str, container_id: int,
|
self, session: AsyncSession, container_type: str, container_id: int,
|
||||||
name: str, slug: str,
|
name: str, slug: str,
|
||||||
|
|||||||
@@ -50,6 +50,15 @@
|
|||||||
}
|
}
|
||||||
Component.prototype._component = true;
|
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. */
|
/** Marker for pre-rendered HTML that bypasses escaping. */
|
||||||
function RawHTML(html) { this.html = html; }
|
function RawHTML(html) { this.html = html; }
|
||||||
RawHTML.prototype._raw = true;
|
RawHTML.prototype._raw = true;
|
||||||
@@ -58,6 +67,7 @@
|
|||||||
function isKw(x) { return x && x._kw === true; }
|
function isKw(x) { return x && x._kw === true; }
|
||||||
function isLambda(x) { return x && x._lambda === true; }
|
function isLambda(x) { return x && x._lambda === true; }
|
||||||
function isComponent(x) { return x && x._component === 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; }
|
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 parseList(tok, "]"); }
|
if (raw === "[") { tok.next(); return parseList(tok, "]"); }
|
||||||
if (raw === "{") { tok.next(); return parseMap(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();
|
return tok.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +392,15 @@
|
|||||||
if (sf) return sf(expr, env);
|
if (sf) return sf(expr, env);
|
||||||
var ho = HO_FORMS[head.name];
|
var ho = HO_FORMS[head.name];
|
||||||
if (ho) return ho(expr, env);
|
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
|
// Function call
|
||||||
@@ -576,6 +605,64 @@
|
|||||||
return result;
|
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 --------------------------------------------------
|
// --- Higher-order forms --------------------------------------------------
|
||||||
|
|
||||||
var HO_FORMS = {};
|
var HO_FORMS = {};
|
||||||
@@ -772,6 +859,8 @@
|
|||||||
|
|
||||||
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
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["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) {
|
RENDER_FORMS["map"] = function (expr, env) {
|
||||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||||
@@ -913,6 +1002,12 @@
|
|||||||
// Render-aware special forms
|
// Render-aware special forms
|
||||||
if (RENDER_FORMS[name]) return RENDER_FORMS[name](expr, env);
|
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
|
// HTML tag
|
||||||
if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env);
|
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));
|
for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env));
|
||||||
return bs.join("");
|
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)
|
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ Quick start::
|
|||||||
from .types import (
|
from .types import (
|
||||||
NIL,
|
NIL,
|
||||||
Component,
|
Component,
|
||||||
|
HandlerDef,
|
||||||
Keyword,
|
Keyword,
|
||||||
Lambda,
|
Lambda,
|
||||||
|
Macro,
|
||||||
Symbol,
|
Symbol,
|
||||||
)
|
)
|
||||||
from .parser import (
|
from .parser import (
|
||||||
@@ -46,7 +48,9 @@ __all__ = [
|
|||||||
"Symbol",
|
"Symbol",
|
||||||
"Keyword",
|
"Keyword",
|
||||||
"Lambda",
|
"Lambda",
|
||||||
|
"Macro",
|
||||||
"Component",
|
"Component",
|
||||||
|
"HandlerDef",
|
||||||
"NIL",
|
"NIL",
|
||||||
# Parser
|
# Parser
|
||||||
"parse",
|
"parse",
|
||||||
|
|||||||
837
shared/sx/async_eval.py
Normal file
837
shared/sx/async_eval.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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
|
from .primitives import _PRIMITIVES
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +117,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
|
|||||||
if ho is not None:
|
if ho is not None:
|
||||||
return ho(expr, env)
|
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 -------------------------------------------
|
# --- function / lambda call -------------------------------------------
|
||||||
fn = _eval(head, env)
|
fn = _eval(head, env)
|
||||||
args = [_eval(a, env) for a in expr[1:]]
|
args = [_eval(a, env) for a in expr[1:]]
|
||||||
@@ -417,6 +424,135 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
|
|||||||
return result
|
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:
|
def _sf_set_bang(expr: list, env: dict) -> Any:
|
||||||
"""``(set! name value)`` — mutate existing binding."""
|
"""``(set! name value)`` — mutate existing binding."""
|
||||||
if len(expr) != 3:
|
if len(expr) != 3:
|
||||||
@@ -518,6 +654,9 @@ _SPECIAL_FORMS: dict[str, Any] = {
|
|||||||
"quote": _sf_quote,
|
"quote": _sf_quote,
|
||||||
"->": _sf_thread_first,
|
"->": _sf_thread_first,
|
||||||
"set!": _sf_set_bang,
|
"set!": _sf_set_bang,
|
||||||
|
"defmacro": _sf_defmacro,
|
||||||
|
"quasiquote": _sf_quasiquote,
|
||||||
|
"defhandler": _sf_defhandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
205
shared/sx/handlers.py
Normal file
205
shared/sx/handlers.py
Normal 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
|
||||||
@@ -314,15 +314,15 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def components_for_request() -> 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
|
Reads the ``SX-Components`` header (comma-separated component names
|
||||||
like ``~card,~nav-item``) and returns only the definitions the client
|
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 quart import request
|
||||||
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
|
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
|
||||||
from .types import Component
|
from .types import Component, Macro
|
||||||
from .parser import serialize
|
from .parser import serialize
|
||||||
|
|
||||||
loaded_raw = request.headers.get("SX-Components", "")
|
loaded_raw = request.headers.get("SX-Components", "")
|
||||||
@@ -338,18 +338,26 @@ def components_for_request() -> str:
|
|||||||
loaded = set(loaded_raw.split(","))
|
loaded = set(loaded_raw.split(","))
|
||||||
parts = []
|
parts = []
|
||||||
for key, val in _COMPONENT_ENV.items():
|
for key, val in _COMPONENT_ENV.items():
|
||||||
if not isinstance(val, Component):
|
if isinstance(val, Component):
|
||||||
continue
|
# Skip components the client already has
|
||||||
# Skip components the client already has
|
if f"~{val.name}" in loaded or val.name in loaded:
|
||||||
if f"~{val.name}" in loaded or val.name in loaded:
|
continue
|
||||||
continue
|
# Reconstruct defcomp source
|
||||||
# Reconstruct defcomp source
|
param_strs = ["&key"] + list(val.params)
|
||||||
param_strs = ["&key"] + list(val.params)
|
if val.has_children:
|
||||||
if val.has_children:
|
param_strs.extend(["&rest", "children"])
|
||||||
param_strs.extend(["&rest", "children"])
|
params_sx = "(" + " ".join(param_strs) + ")"
|
||||||
params_sx = "(" + " ".join(param_strs) + ")"
|
body_sx = serialize(val.body, pretty=True)
|
||||||
body_sx = serialize(val.body, pretty=True)
|
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
||||||
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)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ from __future__ import annotations
|
|||||||
import contextvars
|
import contextvars
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .types import Component, Keyword, Lambda, NIL, Symbol
|
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
|
||||||
from .evaluator import _eval, _call_component
|
from .evaluator import _eval, _call_component, _expand_macro
|
||||||
|
|
||||||
# ContextVar for collecting CSS class names during render.
|
# ContextVar for collecting CSS class names during render.
|
||||||
# Set to a set[str] to collect; None to skip.
|
# Set to a set[str] to collect; None to skip.
|
||||||
@@ -360,6 +360,8 @@ _RENDER_FORMS: dict[str, Any] = {
|
|||||||
"map-indexed": _rsf_map_indexed,
|
"map-indexed": _rsf_map_indexed,
|
||||||
"filter": _rsf_filter,
|
"filter": _rsf_filter,
|
||||||
"for-each": _rsf_for_each,
|
"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:
|
if name in _RENDER_FORMS:
|
||||||
return _RENDER_FORMS[name](expr, env)
|
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 ---------------------------------
|
# --- HTML tag → render as element ---------------------------------
|
||||||
if name in HTML_TAGS:
|
if name in HTML_TAGS:
|
||||||
return _render_element(name, expr[1:], env)
|
return _render_element(name, expr[1:], env)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import hashlib
|
|||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .types import NIL, Component, Keyword, Symbol
|
from .types import NIL, Component, Keyword, Macro, Symbol
|
||||||
from .parser import parse
|
from .parser import parse
|
||||||
from .html import render as html_render, _render_component
|
from .html import render as html_render, _render_component
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ def get_component_hash() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _compute_component_hash() -> None:
|
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
|
global _COMPONENT_HASH
|
||||||
from .parser import serialize
|
from .parser import serialize
|
||||||
parts = []
|
parts = []
|
||||||
@@ -67,6 +67,13 @@ def _compute_component_hash() -> None:
|
|||||||
params_sx = "(" + " ".join(param_strs) + ")"
|
params_sx = "(" + " ".join(param_strs) + ")"
|
||||||
body_sx = serialize(val.body)
|
body_sx = serialize(val.body)
|
||||||
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
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:
|
if parts:
|
||||||
digest = hashlib.sha256("\n".join(parts).encode()).hexdigest()[:12]
|
digest = hashlib.sha256("\n".join(parts).encode()).hexdigest()[:12]
|
||||||
_COMPONENT_HASH = digest
|
_COMPONENT_HASH = digest
|
||||||
@@ -118,13 +125,33 @@ def reload_if_changed() -> None:
|
|||||||
load_sx_dir(directory)
|
load_sx_dir(directory)
|
||||||
|
|
||||||
|
|
||||||
def load_service_components(service_dir: str) -> None:
|
def load_service_components(service_dir: str, service_name: str | None = None) -> None:
|
||||||
"""Load service-specific s-expression components from {service_dir}/sx/."""
|
"""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")
|
sx_dir = os.path.join(service_dir, "sx")
|
||||||
if os.path.isdir(sx_dir):
|
if os.path.isdir(sx_dir):
|
||||||
load_sx_dir(sx_dir)
|
load_sx_dir(sx_dir)
|
||||||
watch_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:
|
def register_components(sx_source: str) -> None:
|
||||||
"""Parse and evaluate s-expression component definitions into the
|
"""Parse and evaluate s-expression component definitions into the
|
||||||
@@ -262,17 +289,25 @@ def client_components_tag(*names: str) -> str:
|
|||||||
from .parser import serialize
|
from .parser import serialize
|
||||||
parts = []
|
parts = []
|
||||||
for key, val in _COMPONENT_ENV.items():
|
for key, val in _COMPONENT_ENV.items():
|
||||||
if not isinstance(val, Component):
|
if isinstance(val, Component):
|
||||||
continue
|
if names and val.name not in names and key.lstrip("~") not in names:
|
||||||
if names and val.name not in names and key.lstrip("~") not in names:
|
continue
|
||||||
continue
|
# Reconstruct defcomp source from the Component object
|
||||||
# Reconstruct defcomp source from the Component object
|
param_strs = ["&key"] + list(val.params)
|
||||||
param_strs = ["&key"] + list(val.params)
|
if val.has_children:
|
||||||
if val.has_children:
|
param_strs.extend(["&rest", "children"])
|
||||||
param_strs.extend(["&rest", "children"])
|
params_sx = "(" + " ".join(param_strs) + ")"
|
||||||
params_sx = "(" + " ".join(param_strs) + ")"
|
body_sx = serialize(val.body, pretty=True)
|
||||||
body_sx = serialize(val.body, pretty=True)
|
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
||||||
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:
|
if not parts:
|
||||||
return ""
|
return ""
|
||||||
source = "\n".join(parts)
|
source = "\n".join(parts)
|
||||||
|
|||||||
@@ -225,6 +225,20 @@ def _parse_expr(tok: Tokenizer) -> Any:
|
|||||||
if raw == "{":
|
if raw == "{":
|
||||||
tok.next_token() # consume the '{'
|
tok.next_token() # consume the '{'
|
||||||
return _parse_map(tok)
|
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
|
# Everything else: strings, keywords, symbols, numbers
|
||||||
token = tok.next_token()
|
token = tok.next_token()
|
||||||
return token
|
return token
|
||||||
@@ -276,6 +290,15 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
|||||||
if isinstance(expr, list):
|
if isinstance(expr, list):
|
||||||
if not expr:
|
if not expr:
|
||||||
return "()"
|
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:
|
if pretty:
|
||||||
return _serialize_pretty(expr, indent)
|
return _serialize_pretty(expr, indent)
|
||||||
items = [serialize(item, indent, False) for item in expr]
|
items = [serialize(item, indent, False) for item in expr]
|
||||||
|
|||||||
@@ -408,6 +408,84 @@ def prim_into(target: Any, coll: Any) -> Any:
|
|||||||
raise ValueError(f"into: unsupported target type {type(target).__name__}")
|
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
|
# Assertions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Usage in s-expressions::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -34,6 +35,11 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
|
|||||||
"action",
|
"action",
|
||||||
"current-user",
|
"current-user",
|
||||||
"htmx-request?",
|
"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)
|
# 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:
|
class RequestContext:
|
||||||
"""Per-request context provided to I/O primitives.
|
"""Per-request context provided to I/O primitives.
|
||||||
|
|
||||||
@@ -140,6 +163,129 @@ async def _io_htmx_request(
|
|||||||
return ctx.is_htmx
|
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
|
# Handler registry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -150,4 +296,9 @@ _IO_HANDLERS: dict[str, Any] = {
|
|||||||
"action": _io_action,
|
"action": _io_action,
|
||||||
"current-user": _io_current_user,
|
"current-user": _io_current_user,
|
||||||
"htmx-request?": _io_htmx_request,
|
"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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from shared.sx import parse, evaluate, EvalError, Symbol, Keyword, NIL
|
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}
|
env = {"x": 1}
|
||||||
ev("(set! x 42)", env)
|
ev("(set! x 42)", env)
|
||||||
assert env["x"] == 42
|
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
|
||||||
|
|||||||
159
shared/sx/tests/test_handlers.py
Normal file
159
shared/sx/tests/test_handlers.py
Normal 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>"
|
||||||
@@ -123,6 +123,52 @@ class TestParseAll:
|
|||||||
assert parse_all(" ; only comments\n") == []
|
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
|
# Errors
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -127,6 +127,29 @@ class Lambda:
|
|||||||
return evaluator(self.body, local)
|
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
|
# Component
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -149,6 +172,27 @@ class Component:
|
|||||||
return f"<Component ~{self.name}({', '.join(self.params)})>"
|
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
|
# RelationDef
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -174,4 +218,4 @@ class RelationDef:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# An s-expression value after evaluation
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user