From fba84540e27ab5a7afd602d52a73b14109e517e3 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Mar 2026 23:48:05 +0000 Subject: [PATCH] Extend defhandler with :path/:method/:csrf, migrate 12 ref endpoints to SX defhandler now supports keyword options for public route registration: (defhandler name :path "/..." :method :post :csrf false (&key) body) Infrastructure: forms.sx parses options, HandlerDef stores path/method/csrf, register_route_handlers() mounts path-based handlers as app routes. New IO primitives (boundary.sx "Web interop" section): now, sleep, request-form, request-json, request-header, request-content-type. First migration: 12 reference API endpoints from Python f-string SX to declarative .sx handlers in sx/sx/handlers/ref-api.sx. Co-Authored-By: Claude Opus 4.6 --- shared/sx/handlers.py | 50 +++++++++++ shared/sx/primitives_io.py | 75 +++++++++++++++++ shared/sx/ref/boundary.sx | 51 ++++++++++++ shared/sx/ref/forms.sx | 62 ++++++++++++-- shared/sx/ref/platform_py.py | 10 ++- shared/sx/ref/sx_ref.py | 47 +++++++++-- shared/sx/types.py | 19 ++++- sx/app.py | 8 ++ sx/bp/pages/routes.py | 123 ++------------------------- sx/sx/handlers/ref-api.sx | 156 +++++++++++++++++++++++++++++++++++ 10 files changed, 468 insertions(+), 133 deletions(-) create mode 100644 sx/sx/handlers/ref-api.sx diff --git a/shared/sx/handlers.py b/shared/sx/handlers.py index 1a775d4..31f8772 100644 --- a/shared/sx/handlers.py +++ b/shared/sx/handlers.py @@ -215,6 +215,56 @@ def create_handler_blueprint(service_name: str) -> Any: return bp +# --------------------------------------------------------------------------- +# Public route registration — handlers with :path get mounted as routes +# --------------------------------------------------------------------------- + +def register_route_handlers(app_or_bp: Any, service_name: str) -> int: + """Register public routes for all handlers with :path defined. + + Returns the number of routes registered. + """ + from quart import Response, request + from shared.browser.app.csrf import csrf_exempt + + handlers = get_all_handlers(service_name) + count = 0 + + for name, hdef in handlers.items(): + if not hdef.is_route: + continue + + # Capture hdef in closure + _hdef = hdef + + async def _route_view(_h=_hdef, **path_kwargs): + from shared.sx.helpers import sx_response + args = dict(request.args) + args.update(path_kwargs) + result = await execute_handler(_h, service_name, args=args) + return sx_response(result) + + endpoint = f"sx_route_{name}" + view_fn = _route_view + + if not _hdef.csrf: + view_fn = csrf_exempt(view_fn) + + method = _hdef.method.lower() + route_reg = getattr(app_or_bp, method, None) + if route_reg is None: + logger.warning("Unsupported HTTP method %s for handler %s", + _hdef.method, name) + continue + + route_reg(_hdef.path, endpoint=endpoint)(view_fn) + logger.info("Registered route %s %s → handler:%s", + _hdef.method.upper(), _hdef.path, name) + count += 1 + + return count + + # --------------------------------------------------------------------------- # Direct app mount — replaces per-service fragment blueprint boilerplate # --------------------------------------------------------------------------- diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 3356048..79898b5 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -297,6 +297,81 @@ async def _io_g( return getattr(g, key, None) +@register_io_handler("now") +async def _io_now( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(now)`` or ``(now "%H:%M:%S")`` → formatted timestamp string.""" + from datetime import datetime + fmt = str(args[0]) if args else None + dt = datetime.now() + return dt.strftime(fmt) if fmt else dt.isoformat() + + +@register_io_handler("sleep") +async def _io_sleep( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(sleep 800)`` → pause for 800ms.""" + import asyncio + from .types import NIL + if not args: + raise ValueError("sleep requires milliseconds") + ms = int(args[0]) + await asyncio.sleep(ms / 1000.0) + return NIL + + +@register_io_handler("request-form") +async def _io_request_form( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-form "name" default?)`` → read a form field.""" + if not args: + raise ValueError("request-form requires a field name") + from quart import request + from .types import NIL + name = str(args[0]) + default = args[1] if len(args) > 1 else NIL + form = await request.form + return form.get(name, default) + + +@register_io_handler("request-json") +async def _io_request_json( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-json)`` → JSON body as dict, or nil.""" + from quart import request + from .types import NIL + data = await request.get_json(silent=True) + return data if data is not None else NIL + + +@register_io_handler("request-header") +async def _io_request_header( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-header "name" default?)`` → request header value.""" + if not args: + raise ValueError("request-header requires a header name") + from quart import request + from .types import NIL + name = str(args[0]) + default = args[1] if len(args) > 1 else NIL + return request.headers.get(name, default) + + +@register_io_handler("request-content-type") +async def _io_request_content_type( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-content-type)`` → content-type string or nil.""" + from quart import request + from .types import NIL + return request.content_type or NIL + + @register_io_handler("csrf-token") async def _io_csrf_token( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx index 32c2d08..ccb6cf6 100644 --- a/shared/sx/ref/boundary.sx +++ b/shared/sx/ref/boundary.sx @@ -126,6 +126,57 @@ "list" "dict" "sx-source")) +;; -------------------------------------------------------------------------- +;; Web interop — reading non-SX request formats +;; +;; SX's native wire format is SX (text/sx). These primitives bridge to +;; legacy web formats: HTML form encoding, JSON bodies, HTTP headers. +;; They're useful for interop but not fundamental to SX-to-SX communication. +;; -------------------------------------------------------------------------- + +(define-io-primitive "now" + :params (&rest format) + :returns "string" + :async true + :doc "Current timestamp. Optional format string (strftime). Default ISO 8601." + :context :request) + +(define-io-primitive "sleep" + :params (ms) + :returns "nil" + :async true + :doc "Pause execution for ms milliseconds. For demos and testing." + :context :request) + +(define-io-primitive "request-form" + :params (name &rest default) + :returns "any" + :async true + :doc "Read a form field from a POST/PUT/PATCH request body." + :context :request) + +(define-io-primitive "request-json" + :params () + :returns "dict?" + :async true + :doc "Read JSON body from the current request, or nil if not JSON." + :context :request) + +(define-io-primitive "request-header" + :params (name &rest default) + :returns "string?" + :async true + :doc "Read a request header value by name." + :context :request) + +(define-io-primitive "request-content-type" + :params () + :returns "string?" + :async true + :doc "Content-Type of the current request." + :context :request) + + ;; -------------------------------------------------------------------------- ;; Tier 3: Signal primitives — reactive state for islands ;; diff --git a/shared/sx/ref/forms.sx b/shared/sx/ref/forms.sx index cb1ff77..afe7e55 100644 --- a/shared/sx/ref/forms.sx +++ b/shared/sx/ref/forms.sx @@ -38,17 +38,65 @@ ;; -------------------------------------------------------------------------- -;; defhandler — (defhandler name (&key param...) body) +;; defhandler — (defhandler name [:path "..." :method :get :csrf false] (&key param...) body) +;; +;; Keyword options between name and params list: +;; :path — public route path (string). Without :path, handler is internal-only. +;; :method — HTTP method (keyword: :get :post :put :patch :delete). Default :get. +;; :csrf — CSRF protection (boolean). Default true; set false for POST/PUT etc. ;; -------------------------------------------------------------------------- +(define parse-handler-args + (fn ((args :as list)) + "Parse defhandler args after the name symbol. + Scans for :keyword value option pairs, then a list (params), then body. + Returns dict with keys: opts, params, body." + (let ((opts {}) + (params (list)) + (body nil) + (i 0) + (n (len args)) + (done false)) + (for-each + (fn (idx) + (when (and (not done) (= idx i)) + (let ((arg (nth args idx))) + (cond + ;; keyword-value pair → consume two items + (= (type-of arg) "keyword") + (do + (when (< (+ idx 1) n) + (let ((val (nth args (+ idx 1)))) + ;; For :method, extract keyword name; for :csrf, keep as-is + (dict-set! opts (keyword-name arg) + (if (= (type-of val) "keyword") + (keyword-name val) + val)))) + (set! i (+ idx 2))) + ;; list → params, next element is body + (= (type-of arg) "list") + (do + (set! params (parse-key-params arg)) + (when (< (+ idx 1) n) + (set! body (nth args (+ idx 1)))) + (set! done true)) + ;; anything else → no explicit params, this is body + :else + (do + (set! body arg) + (set! done true)))))) + (range 0 n)) + (dict :opts opts :params params :body body)))) + (define sf-defhandler (fn ((args :as list) (env :as dict)) - (let ((name-sym (first args)) - (params-raw (nth args 1)) - (body (nth args 2)) - (name (symbol-name name-sym)) - (params (parse-key-params params-raw))) - (let ((hdef (make-handler-def name params body env))) + (let ((name-sym (first args)) + (name (symbol-name name-sym)) + (parsed (parse-handler-args (rest args))) + (opts (get parsed "opts")) + (params (get parsed "params")) + (body (get parsed "body"))) + (let ((hdef (make-handler-def name params body env opts))) (env-set! env (str "handler:" name) hdef) hdef)))) diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index e2ccec0..a999f92 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -222,8 +222,14 @@ def make_macro(params, rest_param, body, env, name=None): closure=dict(env), name=name) -def make_handler_def(name, params, body, env): - return HandlerDef(name=name, params=list(params), body=body, closure=dict(env)) +def make_handler_def(name, params, body, env, opts=None): + path = opts.get('path') if opts else None + method = str(opts.get('method', 'get')) if opts else 'get' + csrf = opts.get('csrf', True) if opts else True + if isinstance(csrf, str): + csrf = csrf.lower() not in ('false', 'nil', 'no') + return HandlerDef(name=name, params=list(params), body=body, closure=dict(env), + path=path, method=method.lower(), csrf=csrf) def make_query_def(name, params, doc, body, env): diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 71cee96..4ee5a8a 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -181,8 +181,14 @@ def make_macro(params, rest_param, body, env, name=None): closure=dict(env), name=name) -def make_handler_def(name, params, body, env): - return HandlerDef(name=name, params=list(params), body=body, closure=dict(env)) +def make_handler_def(name, params, body, env, opts=None): + path = opts.get('path') if opts else None + method = str(opts.get('method', 'get')) if opts else 'get' + csrf = opts.get('csrf', True) if opts else True + if isinstance(csrf, str): + csrf = csrf.lower() not in ('false', 'nil', 'no') + return HandlerDef(name=name, params=list(params), body=body, closure=dict(env), + path=path, method=method.lower(), csrf=csrf) def make_query_def(name, params, doc, body, env): @@ -1776,14 +1782,43 @@ def parse_key_params(params_expr): params.append(name) return params +# parse-handler-args +def parse_handler_args(args): + _cells = {} + 'Parse defhandler args after the name symbol.\n Scans for :keyword value option pairs, then a list (params), then body.\n Returns dict with keys: opts, params, body.' + opts = {} + _cells['params'] = [] + _cells['body'] = NIL + _cells['i'] = 0 + n = len(args) + _cells['done'] = False + for idx in range(0, n): + if sx_truthy(((not sx_truthy(_cells['done'])) if not sx_truthy((not sx_truthy(_cells['done']))) else (idx == _cells['i']))): + arg = nth(args, idx) + if sx_truthy((type_of(arg) == 'keyword')): + if sx_truthy(((idx + 1) < n)): + val = nth(args, (idx + 1)) + opts[keyword_name(arg)] = (keyword_name(val) if sx_truthy((type_of(val) == 'keyword')) else val) + _cells['i'] = (idx + 2) + elif sx_truthy((type_of(arg) == 'list')): + _cells['params'] = parse_key_params(arg) + if sx_truthy(((idx + 1) < n)): + _cells['body'] = nth(args, (idx + 1)) + _cells['done'] = True + else: + _cells['body'] = arg + _cells['done'] = True + return {'opts': opts, 'params': _cells['params'], 'body': _cells['body']} + # sf-defhandler def sf_defhandler(args, env): name_sym = first(args) - params_raw = nth(args, 1) - body = nth(args, 2) name = symbol_name(name_sym) - params = parse_key_params(params_raw) - hdef = make_handler_def(name, params, body, env) + parsed = parse_handler_args(rest(args)) + opts = get(parsed, 'opts') + params = get(parsed, 'params') + body = get(parsed, 'body') + hdef = make_handler_def(name, params, body, env, opts) env[sx_str('handler:', name)] = hdef return hdef diff --git a/shared/sx/types.py b/shared/sx/types.py index 15bd49f..426b004 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -221,18 +221,29 @@ class Island: @dataclass class HandlerDef: - """A declarative fragment handler defined in an .sx file. + """A declarative 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. + Created by ``(defhandler name :path "/..." :method :get (&key param...) body)``. + + When ``path`` is set, the handler is registered as a public route. + When ``path`` is None, it's an internal fragment handler (legacy behaviour). """ name: str params: list[str] # keyword parameter names body: Any # unevaluated s-expression body closure: dict[str, Any] = field(default_factory=dict) + path: str | None = None # public route path (None = internal fragment only) + method: str = "get" # HTTP method (get, post, put, patch, delete) + csrf: bool = True # CSRF protection enabled + + @property + def is_route(self) -> bool: + """True if this handler has a public route path.""" + return self.path is not None def __repr__(self): + if self.path: + return f"" return f"" diff --git a/sx/app.py b/sx/app.py index aaee202..bba5790 100644 --- a/sx/app.py +++ b/sx/app.py @@ -103,6 +103,14 @@ def create_app() -> "Quart": bp = register_pages(url_prefix="/") app.register_blueprint(bp) + # Register SX-defined route handlers (defhandler with :path) + from shared.sx.handlers import register_route_handlers + n_routes = register_route_handlers(app, "sx") + if n_routes: + import logging + logging.getLogger("sx.handlers").info( + "Registered %d route handler(s) for sx", n_routes) + from shared.sx.pages import auto_mount_pages auto_mount_pages(app, "sx") diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index e295d96..e52fc25 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -710,7 +710,15 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') # ------------------------------------------------------------------ - # Reference attribute detail API endpoints (for live demos) + # Reference API endpoints — remaining Python-only + # + # Most reference endpoints migrated to sx/sx/handlers/ref-api.sx. + # These remain because they need Python-specific features: + # - File upload access (request.files) + # - Dynamic all-params iteration + # - Stateful counters with non-200 responses + # - SSE streaming + # - Custom response headers # ------------------------------------------------------------------ def _ref_wire(wire_id: str, sx_src: str) -> str: @@ -718,105 +726,6 @@ def register(url_prefix: str = "/") -> Blueprint: from sxc.pages.renders import _oob_code return _oob_code(f"ref-wire-{wire_id}", sx_src) - @bp.get("/geography/hypermedia/reference/api/time") - async def ref_time(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(span :class "text-stone-800 text-sm" "Server time: " (strong "{now}"))' - oob = _ref_wire("sx-get", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.post("/geography/hypermedia/reference/api/greet") - async def ref_greet(): - from shared.sx.helpers import sx_response - form = await request.form - name = form.get("name") or "stranger" - sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")' - oob = _ref_wire("sx-post", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.put("/geography/hypermedia/reference/api/status") - async def ref_status(): - from shared.sx.helpers import sx_response - form = await request.form - status = form.get("status", "unknown") - sx_src = f'(span :class "text-stone-700 text-sm" "Status: " (strong "{status}") " — updated via PUT")' - oob = _ref_wire("sx-put", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.patch("/geography/hypermedia/reference/api/theme") - async def ref_theme(): - from shared.sx.helpers import sx_response - form = await request.form - theme = form.get("theme", "unknown") - sx_src = f'"{theme}"' - oob = _ref_wire("sx-patch", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.delete("/geography/hypermedia/reference/api/item/") - async def ref_delete(item_id: str): - from shared.sx.helpers import sx_response - oob = _ref_wire("sx-delete", '""') - return sx_response(f'(<> {oob})') - - @bp.get("/geography/hypermedia/reference/api/trigger-search") - async def ref_trigger_search(): - from shared.sx.helpers import sx_response - q = request.args.get("q", "") - if not q: - sx_src = '(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")' - else: - sx_src = f'(span :class "text-stone-800 text-sm" "Results for: " (strong "{q}"))' - oob = _ref_wire("sx-trigger", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/swap-item") - async def ref_swap_item(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(div :class "text-sm text-violet-700" "New item (" "{now}" ")")' - oob = _ref_wire("sx-swap", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/oob") - async def ref_oob(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(<>' - f' (span :class "text-emerald-700 text-sm" "Main updated at " "{now}")' - f' (div :id "ref-oob-side" :sx-swap-oob "innerHTML"' - f' (span :class "text-violet-700 text-sm" "OOB updated at " "{now}")))') - oob = _ref_wire("sx-swap-oob", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/select-page") - async def ref_select_page(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(<>' - f' (div :id "the-header" (h3 "Page header — not selected"))' - f' (div :id "the-content"' - f' (span :class "text-emerald-700 text-sm"' - f' "This fragment was selected from a larger response. Time: " "{now}"))' - f' (div :id "the-footer" (p "Page footer — not selected")))') - oob = _ref_wire("sx-select", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/slow-echo") - async def ref_slow_echo(): - from shared.sx.helpers import sx_response - await asyncio.sleep(0.8) - q = request.args.get("q", "") - sx_src = f'(span :class "text-stone-800 text-sm" "Echo: " (strong "{q}"))' - oob = _ref_wire("sx-sync", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - @csrf_exempt @bp.post("/geography/hypermedia/reference/api/upload-name") async def ref_upload_name(): @@ -882,14 +791,6 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-retry", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/geography/hypermedia/reference/api/prompt-echo") - async def ref_prompt_echo(): - from shared.sx.helpers import sx_response - name = request.headers.get("SX-Prompt", "anonymous") - sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")' - oob = _ref_wire("sx-prompt", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/geography/hypermedia/reference/api/sse-time") async def ref_sse_time(): async def generate(): @@ -1049,10 +950,4 @@ def register(url_prefix: str = "/") -> Blueprint: resp.headers["SX-Retarget"] = "#ref-hdr-retarget-alt" return resp - # --- Event demos --- - - @bp.get("/geography/hypermedia/reference/api/error-500") - async def ref_error_500(): - return Response("Server error", status=500, content_type="text/plain") - return bp diff --git a/sx/sx/handlers/ref-api.sx b/sx/sx/handlers/ref-api.sx new file mode 100644 index 0000000..afeb5a4 --- /dev/null +++ b/sx/sx/handlers/ref-api.sx @@ -0,0 +1,156 @@ +;; Reference API endpoints — live demos for hypermedia attribute docs +;; +;; These replace the Python endpoints in bp/pages/routes.py. +;; Each defhandler with :path registers as a public route automatically. + +;; --- sx-get demo: server time --- + +(defhandler ref-time + :path "/geography/hypermedia/reference/api/time" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (span :class "text-stone-800 text-sm" "Server time: " (strong now)) + (~doc-oob-code :target-id "ref-wire-sx-get" + :text (str "(span :class \"text-stone-800 text-sm\" \"Server time: \" (strong \"" now "\"))"))))) + +;; --- sx-post demo: greet --- + +(defhandler ref-greet + :path "/geography/hypermedia/reference/api/greet" + :method :post + :csrf false + (&key) + (let ((name (request-form "name" "stranger"))) + (<> + (span :class "text-stone-800 text-sm" "Hello, " (strong name) "!") + (~doc-oob-code :target-id "ref-wire-sx-post" + :text (str "(span :class \"text-stone-800 text-sm\" \"Hello, \" (strong \"" name "\") \"!\")"))))) + +;; --- sx-put demo: status update --- + +(defhandler ref-status + :path "/geography/hypermedia/reference/api/status" + :method :put + :csrf false + (&key) + (let ((status (request-form "status" "unknown"))) + (<> + (span :class "text-stone-700 text-sm" "Status: " (strong status) " — updated via PUT") + (~doc-oob-code :target-id "ref-wire-sx-put" + :text (str "(span :class \"text-stone-700 text-sm\" \"Status: \" (strong \"" status "\") \" — updated via PUT\")"))))) + +;; --- sx-patch demo: theme --- + +(defhandler ref-theme + :path "/geography/hypermedia/reference/api/theme" + :method :patch + :csrf false + (&key) + (let ((theme (request-form "theme" "unknown"))) + (<> + theme + (~doc-oob-code :target-id "ref-wire-sx-patch" + :text (str "\"" theme "\""))))) + +;; --- sx-delete demo --- + +(defhandler ref-delete-item + :path "/geography/hypermedia/reference/api/item/" + :method :delete + :csrf false + (&key) + (<> + (~doc-oob-code :target-id "ref-wire-sx-delete" :text "\"\""))) + +;; --- sx-trigger demo: search --- + +(defhandler ref-trigger-search + :path "/geography/hypermedia/reference/api/trigger-search" + :method :get + (&key) + (let ((q (request-arg "q" ""))) + (let ((sx-text (if (= q "") + "(span :class \"text-stone-400 text-sm\" \"Start typing to trigger a search.\")" + (str "(span :class \"text-stone-800 text-sm\" \"Results for: \" (strong \"" q "\"))")))) + (<> + (if (= q "") + (span :class "text-stone-400 text-sm" "Start typing to trigger a search.") + (span :class "text-stone-800 text-sm" "Results for: " (strong q))) + (~doc-oob-code :target-id "ref-wire-sx-trigger" :text sx-text))))) + +;; --- sx-swap demo --- + +(defhandler ref-swap-item + :path "/geography/hypermedia/reference/api/swap-item" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (div :class "text-sm text-violet-700" (str "New item (" now ")")) + (~doc-oob-code :target-id "ref-wire-sx-swap" + :text (str "(div :class \"text-sm text-violet-700\" \"New item (" now ")\")"))))) + +;; --- sx-swap-oob demo --- + +(defhandler ref-oob + :path "/geography/hypermedia/reference/api/oob" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (span :class "text-emerald-700 text-sm" "Main updated at " now) + (div :id "ref-oob-side" :sx-swap-oob "innerHTML" + (span :class "text-violet-700 text-sm" "OOB updated at " now)) + (~doc-oob-code :target-id "ref-wire-sx-swap-oob" + :text (str "(<> (span ... \"" now "\") (div :id \"ref-oob-side\" :sx-swap-oob \"innerHTML\" ...))"))))) + +;; --- sx-select demo --- + +(defhandler ref-select-page + :path "/geography/hypermedia/reference/api/select-page" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (div :id "the-header" (h3 "Page header — not selected")) + (div :id "the-content" + (span :class "text-emerald-700 text-sm" + "This fragment was selected from a larger response. Time: " now)) + (div :id "the-footer" (p "Page footer — not selected")) + (~doc-oob-code :target-id "ref-wire-sx-select" + :text (str "(<> (div :id \"the-header\" ...) (div :id \"the-content\" ... \"" now "\") (div :id \"the-footer\" ...))"))))) + +;; --- sx-sync demo: slow echo --- + +(defhandler ref-slow-echo + :path "/geography/hypermedia/reference/api/slow-echo" + :method :get + (&key) + (let ((q (request-arg "q" ""))) + (sleep 800) + (<> + (span :class "text-stone-800 text-sm" "Echo: " (strong q)) + (~doc-oob-code :target-id "ref-wire-sx-sync" + :text (str "(span :class \"text-stone-800 text-sm\" \"Echo: \" (strong \"" q "\"))"))))) + +;; --- sx-prompt demo --- + +(defhandler ref-prompt-echo + :path "/geography/hypermedia/reference/api/prompt-echo" + :method :get + (&key) + (let ((name (request-header "SX-Prompt" "anonymous"))) + (<> + (span :class "text-stone-800 text-sm" "Hello, " (strong name) "!") + (~doc-oob-code :target-id "ref-wire-sx-prompt" + :text (str "(span :class \"text-stone-800 text-sm\" \"Hello, \" (strong \"" name "\") \"!\")"))))) + +;; --- Error demo --- + +(defhandler ref-error-500 + :path "/geography/hypermedia/reference/api/error-500" + :method :get + (&key) + (abort 500 "Server error"))