From ee868f686b71884da3fed42d9367289b7a8e6f9c Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 24 Mar 2026 13:26:25 +0000 Subject: [PATCH] Migrate 6 reactive demo handlers from Python f-strings to SX defhandlers Moved flash-sale, settle-data, search-products/events/posts, and catalog endpoints from bp/pages/routes.py into sx/sx/handlers/reactive-api.sx. routes.py now contains only the SSE endpoint (async generators need Python). Co-Authored-By: Claude Opus 4.6 (1M context) --- shared/sx/handlers.py | 4 +- sx/bp/pages/routes.py | 140 +-------------------------- sx/sx/handlers/reactive-api.sx | 166 +++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 137 deletions(-) create mode 100644 sx/sx/handlers/reactive-api.sx diff --git a/shared/sx/handlers.py b/shared/sx/handlers.py index 6578471..7504e9c 100644 --- a/shared/sx/handlers.py +++ b/shared/sx/handlers.py @@ -154,7 +154,9 @@ async def execute_handler( if use_ocaml: from .ocaml_bridge import get_bridge - # Serialize handler body with bound params as a let expression + # Serialize handler body with bound params as a let expression. + # Define constants and defcomps from the handler file are available + # in the kernel's global env (loaded by _ensure_components). param_bindings = [] for param in handler_def.params: val = args.get(param, args.get(param.replace("-", "_"), NIL)) diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index 260c1a7..37285e1 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -1,26 +1,22 @@ """SX docs page routes. Page GET routes are defined declaratively in sxc/pages/docs.sx via defpage. -Example API endpoints are now defined in sx/handlers/examples.sx via defhandler. -This file contains only SSE and marsh demo endpoints that need Python. +API endpoints are defined in sx/handlers/*.sx via defhandler. +This file contains only SSE endpoints that need Python (async generators). """ from __future__ import annotations import asyncio -import random from datetime import datetime -from quart import Blueprint, Response, request +from quart import Blueprint, Response def register(url_prefix: str = "/") -> Blueprint: bp = Blueprint("pages", __name__, url_prefix=url_prefix) # ------------------------------------------------------------------ - # Reference API endpoints — remaining Python-only - # - # Most reference endpoints migrated to sx/sx/handlers/ref-api.sx. - # SSE stays in Python — fundamentally different paradigm (async generator). + # SSE — async generator, fundamentally not expressible in SX # ------------------------------------------------------------------ @bp.get("/sx/(geography.(hypermedia.(reference.(api.sse-time))))") @@ -34,132 +30,4 @@ def register(url_prefix: str = "/") -> Blueprint: return Response(generate(), content_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) - # --- Marsh demos --- - - _marsh_sale_idx = {"n": 0} - - @bp.get("/sx/(geography.(reactive.(api.flash-sale)))") - async def api_marsh_flash_sale(): - from shared.sx.helpers import sx_response - prices = [14.99, 9.99, 24.99, 12.49, 7.99, 29.99, 4.99, 16.50] - _marsh_sale_idx["n"] = (_marsh_sale_idx["n"] + 1) % len(prices) - new_price = prices[_marsh_sale_idx["n"]] - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(<>' - f' (div :class "space-y-2"' - f' (p :class "text-sm text-emerald-600 font-medium"' - f' "\u26A1 Flash sale! Price: ${new_price:.2f}")' - f' (p :class "text-xs text-stone-400" "at {now}"))' - f' (script :type "text/sx" :data-init ""' - f' "(reset! (use-store \\"demo-price\\") {new_price})"))' - ) - return sx_response(sx_src) - - # --- Demo 3: sx-on-settle endpoint --- - - _settle_counter = {"n": 0} - - @bp.get("/sx/(geography.(reactive.(api.settle-data)))") - async def api_settle_data(): - from shared.sx.helpers import sx_response - _settle_counter["n"] += 1 - items = ["Widget", "Gadget", "Sprocket", "Gizmo", "Doohickey"] - item = items[_settle_counter["n"] % len(items)] - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(div :class "space-y-1"' - f' (p :class "text-sm font-medium text-stone-700" "Fetched: {item}")' - f' (p :class "text-xs text-stone-400" "at {now}"))' - ) - return sx_response(sx_src) - - # --- Demo 4: signal-bound URL endpoints --- - - @bp.get("/sx/(geography.(reactive.(api.search-products)))") - async def api_search_products(): - from shared.sx.helpers import sx_response - q = request.args.get("q", "") - items = ["Artisan Widget", "Premium Gadget", "Handcrafted Sprocket", - "Bespoke Gizmo", "Organic Doohickey"] - matches = [i for i in items if q.lower() in i.lower()] if q else items - rows = " ".join( - f'(li :class "text-sm text-stone-600" "{m}")' - for m in matches[:3] - ) - sx_src = ( - f'(div :class "space-y-1"' - f' (p :class "text-xs font-semibold text-violet-600 uppercase" "Products")' - f' (ul :class "list-disc pl-4" {rows})' - f' (p :class "text-xs text-stone-400" "{len(matches)} result(s)"))' - ) - return sx_response(sx_src) - - @bp.get("/sx/(geography.(reactive.(api.search-events)))") - async def api_search_events(): - from shared.sx.helpers import sx_response - q = request.args.get("q", "") - items = ["Summer Workshop", "Craft Fair", "Open Studio", - "Artist Talk", "Gallery Opening"] - matches = [i for i in items if q.lower() in i.lower()] if q else items - rows = " ".join( - f'(li :class "text-sm text-stone-600" "{m}")' - for m in matches[:3] - ) - sx_src = ( - f'(div :class "space-y-1"' - f' (p :class "text-xs font-semibold text-emerald-600 uppercase" "Events")' - f' (ul :class "list-disc pl-4" {rows})' - f' (p :class "text-xs text-stone-400" "{len(matches)} result(s)"))' - ) - return sx_response(sx_src) - - @bp.get("/sx/(geography.(reactive.(api.search-posts)))") - async def api_search_posts(): - from shared.sx.helpers import sx_response - q = request.args.get("q", "") - items = ["On Craft and Code", "The SX Manifesto", "Islands and Lakes", - "Reactive Marshes", "Self-Hosting Spec"] - matches = [i for i in items if q.lower() in i.lower()] if q else items - rows = " ".join( - f'(li :class "text-sm text-stone-600" "{m}")' - for m in matches[:3] - ) - sx_src = ( - f'(div :class "space-y-1"' - f' (p :class "text-xs font-semibold text-amber-600 uppercase" "Posts")' - f' (ul :class "list-disc pl-4" {rows})' - f' (p :class "text-xs text-stone-400" "{len(matches)} result(s)"))' - ) - return sx_response(sx_src) - - # --- Demo 5: marsh transform endpoint --- - - @bp.get("/sx/(geography.(reactive.(api.catalog)))") - async def api_catalog(): - from shared.sx.helpers import sx_response - items = [ - ("Artisan Widget", "19.99", "Hand-crafted with care"), - ("Premium Gadget", "34.50", "Top-of-the-line quality"), - ("Vintage Sprocket", "12.99", "Classic design"), - ("Custom Gizmo", "27.00", "Made to order"), - ] - random.shuffle(items) - now = datetime.now().strftime("%H:%M:%S") - # Build an SX list literal for the data-init script. - # Inner quotes must be escaped since the whole expression lives - # inside an SX string literal (the script tag's text content). - items_sx = "(list " + " ".join( - f'(dict \\"name\\" \\"{n}\\" \\"price\\" \\"{p}\\" \\"desc\\" \\"{d}\\")' - for n, p, d in items - ) + ")" - sx_src = ( - f'(<>' - f' (p :class "text-sm text-emerald-600 font-medium"' - f' "Catalog loaded: {len(items)} items (shuffled at {now})")' - f' (script :type "text/sx" :data-init ""' - f' "(reset! (use-store \\"catalog-items\\") {items_sx})"))' - ) - return sx_response(sx_src) - return bp diff --git a/sx/sx/handlers/reactive-api.sx b/sx/sx/handlers/reactive-api.sx new file mode 100644 index 0000000..8f1b39f --- /dev/null +++ b/sx/sx/handlers/reactive-api.sx @@ -0,0 +1,166 @@ +;; ========================================================================== +;; Reactive API endpoints — live demos for marsh/reactive-islands pages +;; +;; Migrated from bp/pages/routes.py (Python f-string handlers). +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Data constants +;; -------------------------------------------------------------------------- + +(define flash-sale-prices + (list 14.99 9.99 24.99 12.49 7.99 29.99 4.99 16.50)) + +(define settle-items + (list "Widget" "Gadget" "Sprocket" "Gizmo" "Doohickey")) + +(define search-product-items + (list "Artisan Widget" "Premium Gadget" "Handcrafted Sprocket" + "Bespoke Gizmo" "Organic Doohickey")) + +(define search-event-items + (list "Summer Workshop" "Craft Fair" "Open Studio" + "Artist Talk" "Gallery Opening")) + +(define search-post-items + (list "On Craft and Code" "The SX Manifesto" "Islands and Lakes" + "Reactive Marshes" "Self-Hosting Spec")) + +(define catalog-items + (list + (dict "name" "Artisan Widget" "price" "19.99" "desc" "Hand-crafted with care") + (dict "name" "Premium Gadget" "price" "34.50" "desc" "Top-of-the-line quality") + (dict "name" "Vintage Sprocket" "price" "12.99" "desc" "Classic design") + (dict "name" "Custom Gizmo" "price" "27.00" "desc" "Made to order"))) + + +;; -------------------------------------------------------------------------- +;; Helper: search results panel +;; -------------------------------------------------------------------------- + +(defcomp ~reactive-api/search-results (&key label color items query) + (let ((matches (if (= query "") + items + (filter (fn (item) (string-contains? (lower item) (lower query))) + items)))) + (let ((shown (slice matches 0 3))) + (div :class "space-y-1" + (p :class (str "text-xs font-semibold uppercase " color) label) + (ul :class "list-disc pl-4" + (map (fn (m) (li :class "text-sm text-stone-600" m)) shown)) + (p :class "text-xs text-stone-400" + (str (length matches) " result(s)")))))) + + +;; -------------------------------------------------------------------------- +;; Flash Sale +;; -------------------------------------------------------------------------- + +(defhandler reactive-flash-sale + :path "/sx/(geography.(reactive.(api.flash-sale)))" + :method :get + :returns "element" + (&key) + (let ((idx (random-int 0 (- (length flash-sale-prices) 1))) + (now (helper "now" "%H:%M:%S"))) + (let ((price (nth flash-sale-prices idx))) + (<> + (div :class "space-y-2" + (p :class "text-sm text-emerald-600 font-medium" + (str "⚡ Flash sale! Price: $" price)) + (p :class "text-xs text-stone-400" (str "at " now))) + (script :type "text/sx" :data-init "" + (str "(reset! (use-store \"demo-price\") " price ")")))))) + + +;; -------------------------------------------------------------------------- +;; Settle Data +;; -------------------------------------------------------------------------- + +(defhandler reactive-settle-data + :path "/sx/(geography.(reactive.(api.settle-data)))" + :method :get + :returns "element" + (&key) + (let ((idx (random-int 0 (- (length settle-items) 1))) + (now (helper "now" "%H:%M:%S"))) + (let ((item (nth settle-items idx))) + (div :class "space-y-1" + (p :class "text-sm font-medium text-stone-700" (str "Fetched: " item)) + (p :class "text-xs text-stone-400" (str "at " now)))))) + + +;; -------------------------------------------------------------------------- +;; Search Products +;; -------------------------------------------------------------------------- + +(defhandler reactive-search-products + :path "/sx/(geography.(reactive.(api.search-products)))" + :method :get + :returns "element" + (&key) + (let ((q (helper "request-arg" "q" ""))) + (~reactive-api/search-results + :label "Products" :color "text-violet-600" + :items search-product-items :query q))) + + +;; -------------------------------------------------------------------------- +;; Search Events +;; -------------------------------------------------------------------------- + +(defhandler reactive-search-events + :path "/sx/(geography.(reactive.(api.search-events)))" + :method :get + :returns "element" + (&key) + (let ((q (helper "request-arg" "q" ""))) + (~reactive-api/search-results + :label "Events" :color "text-emerald-600" + :items search-event-items :query q))) + + +;; -------------------------------------------------------------------------- +;; Search Posts +;; -------------------------------------------------------------------------- + +(defhandler reactive-search-posts + :path "/sx/(geography.(reactive.(api.search-posts)))" + :method :get + :returns "element" + (&key) + (let ((q (helper "request-arg" "q" ""))) + (~reactive-api/search-results + :label "Posts" :color "text-amber-600" + :items search-post-items :query q))) + + +;; -------------------------------------------------------------------------- +;; Catalog +;; -------------------------------------------------------------------------- + +(defhandler reactive-catalog + :path "/sx/(geography.(reactive.(api.catalog)))" + :method :get + :returns "element" + (&key) + (let ((now (helper "now" "%H:%M:%S")) + (idx (random-int 0 3))) + ;; Rotate items by a random offset to simulate shuffle + (let ((rotated (append (slice catalog-items idx (length catalog-items)) + (slice catalog-items 0 idx)))) + (let ((items-sx + (str "(list " + (reduce (fn (acc item) + (str acc + "(dict \"name\" \"" (get item "name") + "\" \"price\" \"" (get item "price") + "\" \"desc\" \"" (get item "desc") "\") ")) + "" rotated) + ")"))) + (<> + (p :class "text-sm text-emerald-600 font-medium" + (str "Catalog loaded: " (length rotated) " items (shuffled at " now ")")) + (script :type "text/sx" :data-init "" + (str "(reset! (use-store \"catalog-items\") " items-sx ")")))))))