diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index 27269d4..ca09d1c 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -561,3 +561,15 @@ def prim_into(target: Any, coll: Any) -> Any: return result raise ValueError(f"into: unsupported target type {type(target).__name__}") + +@register_primitive("random-int") +def prim_random_int(low: int, high: int) -> int: + import random + return random.randint(int(low), int(high)) + + +@register_primitive("json-encode") +def prim_json_encode(value) -> str: + import json + return json.dumps(value, indent=2) + diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 1fa1a61..9c392dd 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -408,6 +408,18 @@ async def _io_request_form_all( return dict(form) +@register_io_handler("request-form-list") +async def _io_request_form_list( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> list: + """``(request-form-list "field")`` → all values for a multi-value form field.""" + if not args: + raise ValueError("request-form-list requires a field name") + from quart import request + form = await request.form + return form.getlist(str(args[0])) + + @register_io_handler("request-headers-all") async def _io_request_headers_all( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx index edce59c..95edfd8 100644 --- a/shared/sx/ref/boundary.sx +++ b/shared/sx/ref/boundary.sx @@ -202,6 +202,13 @@ :doc "All form fields as a dict." :context :request) +(define-io-primitive "request-form-list" + :params (field-name) + :returns "list" + :async true + :doc "All values for a multi-value form field as a list." + :context :request) + (define-io-primitive "request-headers-all" :params () :returns "dict" diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx index fb19207..3687243 100644 --- a/shared/sx/ref/primitives.sx +++ b/shared/sx/ref/primitives.sx @@ -70,6 +70,17 @@ :doc "Modulo a % b." :body (native-mod a b)) +(define-primitive "random-int" + :params ((low :as number) (high :as number)) + :returns "number" + :doc "Random integer in [low, high] inclusive." + :body (native-random-int low high)) + +(define-primitive "json-encode" + :params (value) + :returns "string" + :doc "Encode value as JSON string with indentation.") + (define-primitive "sqrt" :params ((x :as number)) :returns "number" diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index 6222597..44881f2 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -1,714 +1,21 @@ """SX docs page routes. Page GET routes are defined declaratively in sxc/pages/docs.sx via defpage. -This file contains only redirect routes and example API endpoints. +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. """ from __future__ import annotations import asyncio -import json import random from datetime import datetime -from uuid import uuid4 -from quart import Blueprint, Response, make_response, request -from shared.browser.app.csrf import csrf_exempt +from quart import Blueprint, Response, request def register(url_prefix: str = "/") -> Blueprint: bp = Blueprint("pages", __name__, url_prefix=url_prefix) - # ------------------------------------------------------------------ - # Example API endpoints (for live demos) - # ------------------------------------------------------------------ - - @bp.get("/geography/hypermedia/examples/api/click") - async def api_click(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - sx_src = f'(~click-result :time "{now}")' - comp_text = _component_source_text("click-result") - wire_text = _full_wire_text(sx_src, "click-result") - oob_wire = _oob_code("click-wire", wire_text) - oob_comp = _oob_code("click-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/form") - async def api_form(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - form = await request.form - name = form.get("name", "") - escaped = name.replace('"', '\\"') - sx_src = f'(~form-result :name "{escaped}")' - comp_text = _component_source_text("form-result") - wire_text = _full_wire_text(sx_src, "form-result") - oob_wire = _oob_code("form-wire", wire_text) - oob_comp = _oob_code("form-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - _poll_count = {"n": 0} - - @bp.get("/geography/hypermedia/examples/api/poll") - async def api_poll(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - _poll_count["n"] += 1 - now = datetime.now().strftime("%H:%M:%S") - count = min(_poll_count["n"], 10) - sx_src = f'(~poll-result :time "{now}" :count {count})' - comp_text = _component_source_text("poll-result") - wire_text = _full_wire_text(sx_src, "poll-result") - oob_wire = _oob_code("poll-wire", wire_text) - oob_comp = _oob_code("poll-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @csrf_exempt - @bp.delete("/geography/hypermedia/examples/api/delete/") - async def api_delete(item_id: str): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - # Empty primary response — outerHTML swap removes the row - # But send OOB swaps to show what happened - wire_text = _full_wire_text(f'(empty — row #{item_id} removed by outerHTML swap)') - comp_text = _component_source_text("delete-row") - oob_wire = _oob_code("delete-wire", wire_text) - oob_comp = _oob_code("delete-comp", comp_text) - return sx_response(f'(<> {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/edit") - async def api_edit_form(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - value = request.args.get("value", "") - escaped = value.replace('"', '\\"') - sx_src = f'(~inline-edit-form :value "{escaped}")' - comp_text = _component_source_text("inline-edit-form") - wire_text = _full_wire_text(sx_src, "inline-edit-form") - oob_wire = _oob_code("edit-wire", wire_text) - oob_comp = _oob_code("edit-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/edit") - async def api_edit_save(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - form = await request.form - value = form.get("value", "") - escaped = value.replace('"', '\\"') - sx_src = f'(~inline-view :value "{escaped}")' - comp_text = _component_source_text("inline-view") - wire_text = _full_wire_text(sx_src, "inline-view") - oob_wire = _oob_code("edit-wire", wire_text) - oob_comp = _oob_code("edit-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/edit/cancel") - async def api_edit_cancel(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - value = request.args.get("value", "") - escaped = value.replace('"', '\\"') - sx_src = f'(~inline-view :value "{escaped}")' - comp_text = _component_source_text("inline-view") - wire_text = _full_wire_text(sx_src, "inline-view") - oob_wire = _oob_code("edit-wire", wire_text) - oob_comp = _oob_code("edit-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/oob") - async def api_oob(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _full_wire_text - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(<>' - f' (p :class "text-emerald-600 font-medium" "Box A updated!")' - f' (p :class "text-sm text-stone-500" "at {now}")' - f' (div :id "oob-box-b" :sx-swap-oob "innerHTML"' - f' (p :class "text-violet-600 font-medium" "Box B updated via OOB!")' - f' (p :class "text-sm text-stone-500" "at {now}")))' - ) - wire_text = _full_wire_text(sx_src) - oob_wire = _oob_code("oob-wire", wire_text) - return sx_response(f'(<> {sx_src} {oob_wire})') - - # --- Lazy Loading --- - - @bp.get("/geography/hypermedia/examples/api/lazy") - async def api_lazy(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(~lazy-result :time "{now}")' - comp_text = _component_source_text("lazy-result") - wire_text = _full_wire_text(sx_src, "lazy-result") - oob_wire = _oob_code("lazy-wire", wire_text) - oob_comp = _oob_code("lazy-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Infinite Scroll --- - - @bp.get("/geography/hypermedia/examples/api/scroll") - async def api_scroll(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _full_wire_text - page = int(request.args.get("page", 2)) - start = (page - 1) * 5 + 1 - next_page = page + 1 - items_html = " ".join( - f'(div :class "px-4 py-3 border-b border-stone-100 text-sm text-stone-700" "Item {i} — loaded from page {page}")' - for i in range(start, start + 5) - ) - if next_page <= 6: - sentinel = ( - f'(div :id "scroll-sentinel"' - f' :sx-get "/geography/hypermedia/examples/api/scroll?page={next_page}"' - f' :sx-trigger "intersect once"' - f' :sx-target "#scroll-items"' - f' :sx-swap "beforeend"' - f' :class "p-3 text-center text-stone-400 text-sm"' - f' "Loading more...")' - ) - else: - sentinel = ( - '(div :class "p-3 text-center text-stone-500 text-sm font-medium"' - ' "All items loaded.")' - ) - sx_src = f'(<> {items_html} {sentinel})' - wire_text = _full_wire_text(sx_src) - oob_wire = _oob_code("scroll-wire", wire_text) - return sx_response(f'(<> {sx_src} {oob_wire})') - - # --- Progress Bar --- - - _jobs: dict[str, int] = {} - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/progress/start") - async def api_progress_start(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - job_id = str(uuid4())[:8] - _jobs[job_id] = 0 - sx_src = f'(~progress-status :percent 0 :job-id "{job_id}")' - comp_text = _component_source_text("progress-status") - wire_text = _full_wire_text(sx_src, "progress-status") - oob_wire = _oob_code("progress-wire", wire_text) - oob_comp = _oob_code("progress-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/progress/status") - async def api_progress_status(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - job_id = request.args.get("job", "") - current = _jobs.get(job_id, 0) - current = min(current + random.randint(15, 30), 100) - _jobs[job_id] = current - sx_src = f'(~progress-status :percent {current} :job-id "{job_id}")' - comp_text = _component_source_text("progress-status") - wire_text = _full_wire_text(sx_src, "progress-status") - oob_wire = _oob_code("progress-wire", wire_text) - oob_comp = _oob_code("progress-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Active Search --- - - @bp.get("/geography/hypermedia/examples/api/search") - async def api_search(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - from content.pages import SEARCH_LANGUAGES - q = request.args.get("q", "").strip().lower() - if not q: - results = SEARCH_LANGUAGES - else: - results = [lang for lang in SEARCH_LANGUAGES if q in lang.lower()] - items_sx = " ".join(f'"{r}"' for r in results) - escaped_q = q.replace('"', '\\"') - sx_src = f'(~search-results :items (list {items_sx}) :query "{escaped_q}")' - comp_text = _component_source_text("search-results") - wire_text = _full_wire_text(sx_src, "search-results") - oob_wire = _oob_code("search-wire", wire_text) - oob_comp = _oob_code("search-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Inline Validation --- - - _TAKEN_EMAILS = {"admin@example.com", "test@example.com", "user@example.com"} - - @bp.get("/geography/hypermedia/examples/api/validate") - async def api_validate(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - email = request.args.get("email", "").strip() - if not email: - sx_src = '(~validation-error :message "Email is required")' - comp_name = "validation-error" - elif "@" not in email or "." not in email.split("@")[-1]: - sx_src = '(~validation-error :message "Invalid email format")' - comp_name = "validation-error" - elif email.lower() in _TAKEN_EMAILS: - escaped = email.replace('"', '\\"') - sx_src = f'(~validation-error :message "{escaped} is already taken")' - comp_name = "validation-error" - else: - escaped = email.replace('"', '\\"') - sx_src = f'(~validation-ok :email "{escaped}")' - comp_name = "validation-ok" - comp_text = _component_source_text(comp_name) - wire_text = _full_wire_text(sx_src, comp_name) - oob_wire = _oob_code("validate-wire", wire_text) - oob_comp = _oob_code("validate-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/validate/submit") - async def api_validate_submit(): - from shared.sx.helpers import sx_response - form = await request.form - email = form.get("email", "").strip() - if not email or "@" not in email: - return sx_response('(p :class "text-sm text-rose-600 mt-2" "Please enter a valid email.")') - escaped = email.replace('"', '\\"') - return sx_response(f'(p :class "text-sm text-emerald-600 mt-2" "Form submitted with: {escaped}")') - - # --- Value Select --- - - @bp.get("/geography/hypermedia/examples/api/values") - async def api_values(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _full_wire_text - from content.pages import VALUE_SELECT_DATA - cat = request.args.get("category", "") - items = VALUE_SELECT_DATA.get(cat, []) - options_sx = " ".join(f'(option :value "{i}" "{i}")' for i in items) - if not options_sx: - options_sx = '(option :value "" "No items")' - sx_src = f'(<> {options_sx})' - wire_text = _full_wire_text(sx_src) - oob_wire = _oob_code("values-wire", wire_text) - return sx_response(f'(<> {options_sx} {oob_wire})') - - # --- Reset on Submit --- - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/reset-submit") - async def api_reset_submit(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - form = await request.form - msg = form.get("message", "").strip() or "(empty)" - escaped = msg.replace('"', '\\"') - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(~reset-message :message "{escaped}" :time "{now}")' - comp_text = _component_source_text("reset-message") - wire_text = _full_wire_text(sx_src, "reset-message") - oob_wire = _oob_code("reset-wire", wire_text) - oob_comp = _oob_code("reset-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Edit Row --- - - _edit_rows: dict[str, dict] = {} - - def _get_edit_rows() -> dict[str, dict]: - if not _edit_rows: - from content.pages import EDIT_ROW_DATA - for r in EDIT_ROW_DATA: - _edit_rows[r["id"]] = dict(r) - return _edit_rows - - @bp.get("/geography/hypermedia/examples/api/editrow/") - async def api_editrow_form(row_id: str): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - rows = _get_edit_rows() - row = rows.get(row_id, {"id": row_id, "name": "", "price": "0", "stock": "0"}) - sx_src = (f'(~edit-row-form :id "{row["id"]}" :name "{row["name"]}"' - f' :price "{row["price"]}" :stock "{row["stock"]}")') - comp_text = _component_source_text("edit-row-form") - wire_text = _full_wire_text(sx_src, "edit-row-form") - oob_wire = _oob_code("editrow-wire", wire_text) - oob_comp = _oob_code("editrow-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/editrow/") - async def api_editrow_save(row_id: str): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - form = await request.form - rows = _get_edit_rows() - rows[row_id] = { - "id": row_id, - "name": form.get("name", ""), - "price": form.get("price", "0"), - "stock": form.get("stock", "0"), - } - row = rows[row_id] - sx_src = (f'(~edit-row-view :id "{row["id"]}" :name "{row["name"]}"' - f' :price "{row["price"]}" :stock "{row["stock"]}")') - comp_text = _component_source_text("edit-row-view") - wire_text = _full_wire_text(sx_src, "edit-row-view") - oob_wire = _oob_code("editrow-wire", wire_text) - oob_comp = _oob_code("editrow-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/editrow//cancel") - async def api_editrow_cancel(row_id: str): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - rows = _get_edit_rows() - row = rows.get(row_id, {"id": row_id, "name": "", "price": "0", "stock": "0"}) - sx_src = (f'(~edit-row-view :id "{row["id"]}" :name "{row["name"]}"' - f' :price "{row["price"]}" :stock "{row["stock"]}")') - comp_text = _component_source_text("edit-row-view") - wire_text = _full_wire_text(sx_src, "edit-row-view") - oob_wire = _oob_code("editrow-wire", wire_text) - oob_comp = _oob_code("editrow-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Bulk Update --- - - _bulk_users: dict[str, dict] = {} - - def _get_bulk_users() -> dict[str, dict]: - if not _bulk_users: - from content.pages import BULK_USERS - for u in BULK_USERS: - _bulk_users[u["id"]] = dict(u) - return _bulk_users - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/bulk") - async def api_bulk(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - action = request.args.get("action", "activate") - form = await request.form - ids = form.getlist("ids") - users = _get_bulk_users() - new_status = "active" if action == "activate" else "inactive" - for uid in ids: - if uid in users: - users[uid]["status"] = new_status - rows = [] - for u in users.values(): - rows.append( - f'(~bulk-row :id "{u["id"]}" :name "{u["name"]}"' - f' :email "{u["email"]}" :status "{u["status"]}")' - ) - sx_src = f'(<> {" ".join(rows)})' - comp_text = _component_source_text("bulk-row") - wire_text = _full_wire_text(sx_src, "bulk-row") - oob_wire = _oob_code("bulk-wire", wire_text) - oob_comp = _oob_code("bulk-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Swap Positions --- - - _swap_count = {"n": 0} - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/swap-log") - async def api_swap_log(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _full_wire_text - mode = request.args.get("mode", "beforeend") - _swap_count["n"] += 1 - now = datetime.now().strftime("%H:%M:%S") - n = _swap_count["n"] - entry = f'(div :class "px-3 py-2 text-sm text-stone-700" "[{now}] {mode} (#{n})")' - oob_counter = ( - f'(span :id "swap-counter" :sx-swap-oob "innerHTML"' - f' :class "self-center text-sm text-stone-500" "Count: {n}")' - ) - sx_src = f'(<> {entry} {oob_counter})' - wire_text = _full_wire_text(sx_src) - oob_wire = _oob_code("swap-wire", wire_text) - return sx_response(f'(<> {sx_src} {oob_wire})') - - # --- Select Filter (dashboard) --- - - @bp.get("/geography/hypermedia/examples/api/dashboard") - async def api_dashboard(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _full_wire_text - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(<>' - f' (div :id "dash-header" :class "p-3 bg-violet-50 rounded mb-3"' - f' (h4 :class "font-semibold text-violet-800" "Dashboard Header")' - f' (p :class "text-sm text-violet-600" "Generated at {now}"))' - f' (div :id "dash-stats" :class "grid grid-cols-3 gap-3 mb-3"' - f' (div :class "p-3 bg-emerald-50 rounded text-center"' - f' (p :class "text-2xl font-bold text-emerald-700" "142")' - f' (p :class "text-xs text-emerald-600" "Users"))' - f' (div :class "p-3 bg-blue-50 rounded text-center"' - f' (p :class "text-2xl font-bold text-blue-700" "89")' - f' (p :class "text-xs text-blue-600" "Orders"))' - f' (div :class "p-3 bg-amber-50 rounded text-center"' - f' (p :class "text-2xl font-bold text-amber-700" "$4.2k")' - f' (p :class "text-xs text-amber-600" "Revenue")))' - f' (div :id "dash-footer" :class "p-3 bg-stone-50 rounded"' - f' (p :class "text-sm text-stone-500" "Last updated: {now}")))' - ) - wire_text = _full_wire_text(sx_src) - oob_wire = _oob_code("filter-wire", wire_text) - return sx_response(f'(<> {sx_src} {oob_wire})') - - # --- Tabs --- - - _TAB_CONTENT = { - "tab1": ('(div (p :class "text-stone-700" "Welcome to the Overview tab.")' - ' (p :class "text-stone-500 text-sm mt-2"' - ' "This is the default tab content loaded via sx-get."))'), - "tab2": ('(div (p :class "text-stone-700" "Here are the details.")' - ' (ul :class "mt-2 space-y-1 text-sm text-stone-600"' - ' (li "Version: 1.0.0")' - ' (li "Build: 2024-01-15")' - ' (li "Engine: sx")))'), - "tab3": ('(div (p :class "text-stone-700" "Recent history:")' - ' (ol :class "mt-2 space-y-1 text-sm text-stone-600 list-decimal list-inside"' - ' (li "Initial release")' - ' (li "Added component caching")' - ' (li "Wire format v2")))'), - } - - @bp.get("/geography/hypermedia/examples/api/tabs/") - async def api_tabs(tab: str): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _full_wire_text - sx_src = _TAB_CONTENT.get(tab, _TAB_CONTENT["tab1"]) - buttons = [] - for t, label in [("tab1", "Overview"), ("tab2", "Details"), ("tab3", "History")]: - active = "true" if t == tab else "false" - buttons.append(f'(~tab-btn :tab "{t}" :label "{label}" :active "{active}")') - oob_tabs = ( - f'(div :id "tab-buttons" :sx-swap-oob "innerHTML"' - f' :class "flex border-b border-stone-200"' - f' {" ".join(buttons)})' - ) - wire_text = _full_wire_text(f'(<> {sx_src} {oob_tabs})', "tab-btn") - oob_wire = _oob_code("tabs-wire", wire_text) - return sx_response(f'(<> {sx_src} {oob_tabs} {oob_wire})') - - # --- Animations --- - - @bp.get("/geography/hypermedia/examples/api/animate") - async def api_animate(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - colors = ["bg-violet-100", "bg-emerald-100", "bg-blue-100", "bg-amber-100", "bg-rose-100"] - color = random.choice(colors) - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(~anim-result :color "{color}" :time "{now}")' - comp_text = _component_source_text("anim-result") - wire_text = _full_wire_text(sx_src, "anim-result") - oob_wire = _oob_code("anim-wire", wire_text) - oob_comp = _oob_code("anim-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Dialogs --- - - @bp.get("/geography/hypermedia/examples/api/dialog") - async def api_dialog(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - sx_src = '(~dialog-modal :title "Confirm Action" :message "Are you sure you want to proceed? This is a demo dialog rendered entirely with sx components.")' - comp_text = _component_source_text("dialog-modal") - wire_text = _full_wire_text(sx_src, "dialog-modal") - oob_wire = _oob_code("dialog-wire", wire_text) - oob_comp = _oob_code("dialog-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/dialog/close") - async def api_dialog_close(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _full_wire_text - wire_text = _full_wire_text("(empty — dialog closed)") - oob_wire = _oob_code("dialog-wire", wire_text) - return sx_response(f'(<> {oob_wire})') - - # --- Keyboard Shortcuts --- - - _KBD_ACTIONS = { - "s": "Search panel activated", - "n": "New item created", - "h": "Help panel opened", - } - - @bp.get("/geography/hypermedia/examples/api/keyboard") - async def api_keyboard(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - key = request.args.get("key", "") - action = _KBD_ACTIONS.get(key, f"Unknown key: {key}") - escaped_action = action.replace('"', '\\"') - escaped_key = key.replace('"', '\\"') - sx_src = f'(~kbd-result :key "{escaped_key}" :action "{escaped_action}")' - comp_text = _component_source_text("kbd-result") - wire_text = _full_wire_text(sx_src, "kbd-result") - oob_wire = _oob_code("kbd-wire", wire_text) - oob_comp = _oob_code("kbd-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- PUT / PATCH --- - - _profile = {} - - def _get_profile() -> dict: - if not _profile: - from content.pages import PROFILE_DEFAULT - _profile.update(PROFILE_DEFAULT) - return _profile - - @bp.get("/geography/hypermedia/examples/api/putpatch/edit-all") - async def api_pp_edit_all(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - p = _get_profile() - sx_src = f'(~pp-form-full :name "{p["name"]}" :email "{p["email"]}" :role "{p["role"]}")' - comp_text = _component_source_text("pp-form-full") - wire_text = _full_wire_text(sx_src, "pp-form-full") - oob_wire = _oob_code("pp-wire", wire_text) - oob_comp = _oob_code("pp-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @csrf_exempt - @bp.put("/geography/hypermedia/examples/api/putpatch") - async def api_pp_put(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - form = await request.form - p = _get_profile() - p["name"] = form.get("name", p["name"]) - p["email"] = form.get("email", p["email"]) - p["role"] = form.get("role", p["role"]) - sx_src = f'(~pp-view :name "{p["name"]}" :email "{p["email"]}" :role "{p["role"]}")' - comp_text = _component_source_text("pp-view") - wire_text = _full_wire_text(sx_src, "pp-view") - oob_wire = _oob_code("pp-wire", wire_text) - oob_comp = _oob_code("pp-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/putpatch/cancel") - async def api_pp_cancel(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - p = _get_profile() - sx_src = f'(~pp-view :name "{p["name"]}" :email "{p["email"]}" :role "{p["role"]}")' - comp_text = _component_source_text("pp-view") - wire_text = _full_wire_text(sx_src, "pp-view") - oob_wire = _oob_code("pp-wire", wire_text) - oob_comp = _oob_code("pp-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- JSON Encoding --- - - @csrf_exempt - @bp.post("/geography/hypermedia/examples/api/json-echo") - async def api_json_echo(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - data = await request.get_json(silent=True) or {} - body = json.dumps(data, indent=2) - ct = request.content_type or "unknown" - escaped_body = body.replace('\\', '\\\\').replace('"', '\\"') - escaped_ct = ct.replace('"', '\\"') - sx_src = f'(~json-result :body "{escaped_body}" :content-type "{escaped_ct}")' - comp_text = _component_source_text("json-result") - wire_text = _full_wire_text(sx_src, "json-result") - oob_wire = _oob_code("json-wire", wire_text) - oob_comp = _oob_code("json-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Vals & Headers --- - - @bp.get("/geography/hypermedia/examples/api/echo-vals") - async def api_echo_vals(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - vals = {k: v for k, v in request.args.items() - if k not in ("_", "sx-request")} - items_sx = " ".join(f'"{k}: {v}"' for k, v in vals.items()) - sx_src = f'(~echo-result :label "values" :items (list {items_sx}))' - comp_text = _component_source_text("echo-result") - wire_text = _full_wire_text(sx_src, "echo-result") - oob_wire = _oob_code("vals-wire", wire_text) - oob_comp = _oob_code("vals-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - @bp.get("/geography/hypermedia/examples/api/echo-headers") - async def api_echo_headers(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - custom = {k: v for k, v in request.headers if k.lower().startswith("x-")} - items_sx = " ".join(f'"{k}: {v}"' for k, v in custom.items()) - sx_src = f'(~echo-result :label "headers" :items (list {items_sx}))' - comp_text = _component_source_text("echo-result") - wire_text = _full_wire_text(sx_src, "echo-result") - oob_wire = _oob_code("vals-wire", wire_text) - oob_comp = _oob_code("vals-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Loading States --- - - @bp.get("/geography/hypermedia/examples/api/slow") - async def api_slow(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - await asyncio.sleep(2) - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(~loading-result :time "{now}")' - comp_text = _component_source_text("loading-result") - wire_text = _full_wire_text(sx_src, "loading-result") - oob_wire = _oob_code("loading-wire", wire_text) - oob_comp = _oob_code("loading-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Request Abort (sync replace) --- - - @bp.get("/geography/hypermedia/examples/api/slow-search") - async def api_slow_search(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - delay = random.uniform(0.5, 2.0) - await asyncio.sleep(delay) - q = request.args.get("q", "").strip() - delay_ms = int(delay * 1000) - escaped = q.replace('"', '\\"') - sx_src = f'(~sync-result :query "{escaped}" :delay "{delay_ms}")' - comp_text = _component_source_text("sync-result") - wire_text = _full_wire_text(sx_src, "sync-result") - oob_wire = _oob_code("sync-wire", wire_text) - oob_comp = _oob_code("sync-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - - # --- Retry --- - - _flaky = {"n": 0} - - @bp.get("/geography/hypermedia/examples/api/flaky") - async def api_flaky(): - from shared.sx.helpers import sx_response - from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text - _flaky["n"] += 1 - n = _flaky["n"] - if n % 3 != 0: - return Response("", status=503, content_type="text/plain") - sx_src = f'(~retry-result :attempt "{n}" :message "Success! The endpoint finally responded.")' - comp_text = _component_source_text("retry-result") - wire_text = _full_wire_text(sx_src, "retry-result") - oob_wire = _oob_code("retry-wire", wire_text) - oob_comp = _oob_code("retry-comp", comp_text) - return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - # ------------------------------------------------------------------ # Reference API endpoints — remaining Python-only # diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx index 9279936..f4c45c9 100644 --- a/sx/sx/boundary.sx +++ b/sx/sx/boundary.sx @@ -114,3 +114,8 @@ :params (filename title desc) :returns "dict" :service "sx") + +(define-page-helper "handler-source" + :params (name) + :returns "string" + :service "sx") diff --git a/sx/sx/examples-content.sx b/sx/sx/examples-content.sx index 3f2bc07..07f097d 100644 --- a/sx/sx/examples-content.sx +++ b/sx/sx/examples-content.sx @@ -9,7 +9,7 @@ :demo-description "Click the button to load server-rendered content." :demo (~click-to-load-demo) :sx-code "(button\n :sx-get \"/geography/hypermedia/examples/api/click\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/click\")\nasync def api_click():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~click-result :time \"{now}\")')" + :handler-code (handler-source "ex-click") :comp-placeholder-id "click-comp" :wire-placeholder-id "click-wire" :wire-note "The server responds with content-type text/sx. New CSS rules are prepended as a style tag. Clear the component cache to see component definitions included in the wire response.")) @@ -21,7 +21,7 @@ :demo-description "Enter a name and submit." :demo (~form-demo) :sx-code "(form\n :sx-post \"/geography/hypermedia/examples/api/form\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))" - :handler-code "@bp.post(\"/geography/hypermedia/examples/api/form\")\nasync def api_form():\n form = await request.form\n name = form.get(\"name\", \"\")\n return sx_response(\n f'(~form-result :name \"{name}\")')" + :handler-code (handler-source "ex-form") :comp-placeholder-id "form-comp" :wire-placeholder-id "form-wire")) @@ -32,7 +32,7 @@ :demo-description "This div polls the server every 2 seconds." :demo (~polling-demo) :sx-code "(div\n :sx-get \"/geography/hypermedia/examples/api/poll\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/poll\")\nasync def api_poll():\n poll_count[\"n\"] += 1\n now = datetime.now().strftime(\"%H:%M:%S\")\n count = min(poll_count[\"n\"], 10)\n return sx_response(\n f'(~poll-result :time \"{now}\" :count {count})')" + :handler-code (handler-source "ex-poll") :comp-placeholder-id "poll-comp" :wire-placeholder-id "poll-wire" :wire-note "Updates every 2 seconds — watch the time and count change.")) @@ -49,7 +49,7 @@ (list "4" "Deploy to production") (list "5" "Add unit tests"))) :sx-code "(button\n :sx-delete \"/api/delete/1\"\n :sx-target \"#row-1\"\n :sx-swap \"outerHTML\"\n :sx-confirm \"Delete this item?\"\n \"delete\")" - :handler-code "@bp.delete(\"/geography/hypermedia/examples/api/delete/\")\nasync def api_delete(item_id: str):\n # Empty response — outerHTML swap removes the row\n return Response(\"\", status=200,\n content_type=\"text/sx\")" + :handler-code (handler-source "ex-delete") :comp-placeholder-id "delete-comp" :wire-placeholder-id "delete-wire" :wire-note "Empty body — outerHTML swap replaces the target element with nothing.")) @@ -61,7 +61,7 @@ :demo-description "Click edit, modify the text, save or cancel." :demo (~inline-edit-demo) :sx-code ";; View mode — shows text + edit button\n(~inline-view :value \"some text\")\n\n;; Edit mode — returned by server on click\n(~inline-edit-form :value \"some text\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/edit\")\nasync def api_edit_form():\n value = request.args.get(\"value\", \"\")\n return sx_response(\n f'(~inline-edit-form :value \"{value}\")')\n\n@bp.post(\"/geography/hypermedia/examples/api/edit\")\nasync def api_edit_save():\n form = await request.form\n value = form.get(\"value\", \"\")\n return sx_response(\n f'(~inline-view :value \"{value}\")')" + :handler-code (str (handler-source "ex-edit-form") "\n\n" (handler-source "ex-edit-save")) :comp-placeholder-id "edit-comp" :comp-heading "Components" :handler-heading "Server handlers" @@ -74,7 +74,7 @@ :demo-description "One request updates both Box A (via sx-target) and Box B (via sx-swap-oob)." :demo (~oob-demo) :sx-code ";; Button targets Box A\n(button\n :sx-get \"/geography/hypermedia/examples/api/oob\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/oob\")\nasync def api_oob():\n now = datetime.now().strftime(\"%H:%M:%S\")\n return sx_response(\n f'(<>'\n f' (p \"Box A updated at {now}\")'\n f' (div :id \"oob-box-b\"'\n f' :sx-swap-oob \"innerHTML\"'\n f' (p \"Box B updated at {now}\")))')" + :handler-code (handler-source "ex-oob") :wire-placeholder-id "oob-wire" :wire-note "The fragment contains both the main content and an OOB element. sx.js splits them: main content goes to sx-target, OOB elements find their targets by ID.")) @@ -85,7 +85,7 @@ :demo-description "Content loads automatically when the page renders." :demo (~lazy-loading-demo) :sx-code "(div\n :sx-get \"/geography/hypermedia/examples/api/lazy\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/lazy\")\nasync def api_lazy():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~lazy-result :time \"{now}\")')" + :handler-code (handler-source "ex-lazy") :comp-placeholder-id "lazy-comp" :wire-placeholder-id "lazy-wire")) @@ -96,7 +96,7 @@ :demo-description "Scroll down in the container to load more items (5 pages total)." :demo (~infinite-scroll-demo) :sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/geography/hypermedia/examples/api/scroll?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/scroll\")\nasync def api_scroll():\n page = int(request.args.get(\"page\", 2))\n items = [f\"Item {i}\" for i in range(...)]\n # Include next sentinel if more pages\n return sx_response(items_sx + sentinel_sx)" + :handler-code (handler-source "ex-scroll") :comp-placeholder-id "scroll-comp" :wire-placeholder-id "scroll-wire")) @@ -107,7 +107,7 @@ :demo-description "Click start to begin a simulated job." :demo (~progress-bar-demo) :sx-code ";; Start the job\n(button\n :sx-post \"/geography/hypermedia/examples/api/progress/start\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/api/progress/status?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")" - :handler-code "@bp.post(\"/geography/hypermedia/examples/api/progress/start\")\nasync def api_progress_start():\n job_id = str(uuid4())[:8]\n _jobs[job_id] = 0\n return sx_response(\n f'(~progress-status :percent 0 :job-id \"{job_id}\")')" + :handler-code (str (handler-source "ex-progress-start") "\n\n" (handler-source "ex-progress-status")) :comp-placeholder-id "progress-comp" :wire-placeholder-id "progress-wire")) @@ -118,7 +118,7 @@ :demo-description "Type to search through 20 programming languages." :demo (~active-search-demo) :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/geography/hypermedia/examples/api/search\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/search\")\nasync def api_search():\n q = request.args.get(\"q\", \"\").lower()\n results = [l for l in LANGUAGES if q in l.lower()]\n return sx_response(\n f'(~search-results :items (...) :query \"{q}\")')" + :handler-code (handler-source "ex-search") :comp-placeholder-id "search-comp" :wire-placeholder-id "search-wire")) @@ -129,7 +129,7 @@ :demo-description "Enter an email and click away (blur) to validate." :demo (~inline-validation-demo) :sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/geography/hypermedia/examples/api/validate\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/validate\")\nasync def api_validate():\n email = request.args.get(\"email\", \"\")\n if \"@\" not in email:\n return sx_response('(~validation-error ...)')\n return sx_response('(~validation-ok ...)')" + :handler-code (handler-source "ex-validate") :comp-placeholder-id "validate-comp" :wire-placeholder-id "validate-wire")) @@ -140,7 +140,7 @@ :demo-description "Select a category to populate the item dropdown." :demo (~value-select-demo) :sx-code "(select :name \"category\"\n :sx-get \"/geography/hypermedia/examples/api/values\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/values\")\nasync def api_values():\n cat = request.args.get(\"category\", \"\")\n items = VALUE_SELECT_DATA.get(cat, [])\n return sx_response(\n f'(~value-options :items (list ...))')" + :handler-code (handler-source "ex-values") :comp-placeholder-id "values-comp" :wire-placeholder-id "values-wire")) @@ -151,7 +151,7 @@ :demo-description "Submit a message — the input resets after each send." :demo (~reset-on-submit-demo) :sx-code "(form :id \"reset-form\"\n :sx-post \"/geography/hypermedia/examples/api/reset-submit\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))" - :handler-code "@bp.post(\"/geography/hypermedia/examples/api/reset-submit\")\nasync def api_reset_submit():\n form = await request.form\n msg = form.get(\"message\", \"\")\n return sx_response(\n f'(~reset-message :message \"{msg}\" :time \"...\")')" + :handler-code (handler-source "ex-reset-submit") :comp-placeholder-id "reset-comp" :wire-placeholder-id "reset-wire")) @@ -166,7 +166,7 @@ (list "3" "Widget C" "12.00" "305") (list "4" "Widget D" "45.00" "67"))) :sx-code "(button\n :sx-get \"/geography/hypermedia/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/geography/hypermedia/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/editrow/\")\nasync def api_editrow_form(id):\n row = EDIT_ROW_DATA[id]\n return sx_response(\n f'(~edit-row-form :id ... :name ...)')\n\n@bp.post(\"/geography/hypermedia/examples/api/editrow/\")\nasync def api_editrow_save(id):\n form = await request.form\n return sx_response(\n f'(~edit-row-view :id ... :name ...)')" + :handler-code (str (handler-source "ex-editrow-form") "\n\n" (handler-source "ex-editrow-save")) :comp-placeholder-id "editrow-comp" :wire-placeholder-id "editrow-wire")) @@ -182,7 +182,7 @@ (list "4" "Dan Okafor" "dan@example.com" "inactive") (list "5" "Eve Larsson" "eve@example.com" "active"))) :sx-code "(button\n :sx-post \"/geography/hypermedia/examples/api/bulk?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")" - :handler-code "@bp.post(\"/geography/hypermedia/examples/api/bulk\")\nasync def api_bulk():\n action = request.args.get(\"action\")\n form = await request.form\n ids = form.getlist(\"ids\")\n # Update matching users\n return sx_response(updated_rows)" + :handler-code (handler-source "ex-bulk") :comp-placeholder-id "bulk-comp" :wire-placeholder-id "bulk-wire")) @@ -193,7 +193,7 @@ :demo-description "Try each button to see different swap behaviours." :demo (~swap-positions-demo) :sx-code ";; Append to end\n(button :sx-post \"/api/swap-log?mode=beforeend\"\n :sx-target \"#swap-log\" :sx-swap \"beforeend\"\n \"Add to End\")\n\n;; Prepend to start\n(button :sx-post \"/api/swap-log?mode=afterbegin\"\n :sx-target \"#swap-log\" :sx-swap \"afterbegin\"\n \"Add to Start\")\n\n;; No swap — OOB counter update only\n(button :sx-post \"/api/swap-log?mode=none\"\n :sx-target \"#swap-log\" :sx-swap \"none\"\n \"Silent Ping\")" - :handler-code "@bp.post(\"/geography/hypermedia/examples/api/swap-log\")\nasync def api_swap_log():\n mode = request.args.get(\"mode\")\n # OOB counter updates on every request\n oob = f'(span :id \"swap-counter\" :sx-swap-oob \"innerHTML\" \"Count: {n}\")'\n return sx_response(entry + oob)" + :handler-code (handler-source "ex-swap-log") :wire-placeholder-id "swap-wire")) (defcomp ~example-select-filter () @@ -203,7 +203,7 @@ :demo-description "Different buttons select different parts of the same server response." :demo (~select-filter-demo) :sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/geography/hypermedia/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/geography/hypermedia/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/dashboard\")\nasync def api_dashboard():\n # Returns header + stats + footer\n # Client uses sx-select to pick sections\n return sx_response(\n '(<> (div :id \"dash-header\" ...) '\n ' (div :id \"dash-stats\" ...) '\n ' (div :id \"dash-footer\" ...))')" + :handler-code (handler-source "ex-dashboard") :wire-placeholder-id "filter-wire")) (defcomp ~example-tabs () @@ -213,7 +213,7 @@ :demo-description "Click tabs to switch content. Watch the browser URL change." :demo (~tabs-demo) :sx-code "(button\n :sx-get \"/geography/hypermedia/examples/api/tabs/tab1\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/geography/hypermedia/examples/tabs?tab=tab1\"\n \"Overview\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/tabs/\")\nasync def api_tabs(tab: str):\n content = TAB_CONTENT[tab]\n return sx_response(content)" + :handler-code (handler-source "ex-tabs") :wire-placeholder-id "tabs-wire")) (defcomp ~example-animations () @@ -223,7 +223,7 @@ :demo-description "Click to swap in content with a fade-in animation." :demo (~animations-demo) :sx-code "(button\n :sx-get \"/geography/hypermedia/examples/api/animate\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/animate\")\nasync def api_animate():\n colors = [\"bg-violet-100\", \"bg-emerald-100\", ...]\n color = random.choice(colors)\n return sx_response(\n f'(~anim-result :color \"{color}\" :time \"{now}\")')" + :handler-code (handler-source "ex-animate") :comp-placeholder-id "anim-comp" :wire-placeholder-id "anim-wire")) @@ -234,7 +234,7 @@ :demo-description "Click to open a modal dialog." :demo (~dialogs-demo) :sx-code "(button\n :sx-get \"/geography/hypermedia/examples/api/dialog\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/geography/hypermedia/examples/api/dialog/close\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/dialog\")\nasync def api_dialog():\n return sx_response(\n '(~dialog-modal :title \"Confirm\"'\n ' :message \"Are you sure?\")')\n\n@bp.get(\"/geography/hypermedia/examples/api/dialog/close\")\nasync def api_dialog_close():\n return sx_response(\"\")" + :handler-code (str (handler-source "ex-dialog") "\n\n" (handler-source "ex-dialog-close")) :comp-placeholder-id "dialog-comp" :wire-placeholder-id "dialog-wire")) @@ -245,7 +245,7 @@ :demo-description "Press s, n, or h on your keyboard." :demo (~keyboard-shortcuts-demo) :sx-code "(div :id \"kbd-target\"\n :sx-get \"/geography/hypermedia/examples/api/keyboard?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/keyboard\")\nasync def api_keyboard():\n key = request.args.get(\"key\", \"\")\n actions = {\"s\": \"Search\", \"n\": \"New item\", \"h\": \"Help\"}\n return sx_response(\n f'(~kbd-result :key \"{key}\" :action \"{actions[key]}\")')" + :handler-code (handler-source "ex-keyboard") :comp-placeholder-id "kbd-comp" :wire-placeholder-id "kbd-wire")) @@ -256,7 +256,7 @@ :demo-description "Click Edit All to replace the full profile via PUT." :demo (~put-patch-demo :name "Ada Lovelace" :email "ada@example.com" :role "Engineer") :sx-code ";; Replace entire resource\n(form :sx-put \"/geography/hypermedia/examples/api/putpatch\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))" - :handler-code "@bp.put(\"/geography/hypermedia/examples/api/putpatch\")\nasync def api_put():\n form = await request.form\n # Full replacement\n return sx_response('(~pp-view ...)')" + :handler-code (str (handler-source "ex-pp-edit-all") "\n\n" (handler-source "ex-pp-put")) :comp-placeholder-id "pp-comp" :wire-placeholder-id "pp-wire")) @@ -267,7 +267,7 @@ :demo-description "Submit the form and see the JSON body the server received." :demo (~json-encoding-demo) :sx-code "(form\n :sx-post \"/geography/hypermedia/examples/api/json-echo\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))" - :handler-code "@bp.post(\"/geography/hypermedia/examples/api/json-echo\")\nasync def api_json_echo():\n data = await request.get_json()\n body = json.dumps(data, indent=2)\n ct = request.content_type\n return sx_response(\n f'(~json-result :body \"{body}\" :content-type \"{ct}\")')" + :handler-code (handler-source "ex-json-echo") :comp-placeholder-id "json-comp" :wire-placeholder-id "json-wire")) @@ -278,7 +278,7 @@ :demo-description "Click each button to see what the server receives." :demo (~vals-headers-demo) :sx-code ";; Send extra values with the request\n(button\n :sx-get \"/geography/hypermedia/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/geography/hypermedia/examples/api/echo-headers\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/geography/hypermedia/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')" + :handler-code (str (handler-source "ex-echo-vals") "\n\n" (handler-source "ex-echo-headers")) :comp-placeholder-id "vals-comp" :wire-placeholder-id "vals-wire")) @@ -289,7 +289,7 @@ :demo-description "Click the button — it shows a spinner during the 2-second request." :demo (~loading-states-demo) :sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/geography/hypermedia/examples/api/slow\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/slow\")\nasync def api_slow():\n await asyncio.sleep(2)\n return sx_response(\n f'(~loading-result :time \"{now}\")')" + :handler-code (handler-source "ex-slow") :comp-placeholder-id "loading-comp" :wire-placeholder-id "loading-wire")) @@ -300,7 +300,7 @@ :demo-description "Type quickly — only the latest result appears despite random 0.5-2s server delays." :demo (~sync-replace-demo) :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/geography/hypermedia/examples/api/slow-search\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/slow-search\")\nasync def api_slow_search():\n delay = random.uniform(0.5, 2.0)\n await asyncio.sleep(delay)\n q = request.args.get(\"q\", \"\")\n return sx_response(\n f'(~sync-result :query \"{q}\" :delay \"{delay_ms}\")')" + :handler-code (handler-source "ex-slow-search") :comp-placeholder-id "sync-comp" :wire-placeholder-id "sync-wire")) @@ -311,6 +311,6 @@ :demo-description "Click the button — watch it retry automatically after failures." :demo (~retry-demo) :sx-code "(button\n :sx-get \"/geography/hypermedia/examples/api/flaky\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")" - :handler-code "@bp.get(\"/geography/hypermedia/examples/api/flaky\")\nasync def api_flaky():\n _flaky[\"n\"] += 1\n if _flaky[\"n\"] % 3 != 0:\n return Response(\"\", status=503)\n return sx_response(\n f'(~retry-result :attempt {n} ...)')" + :handler-code (handler-source "ex-flaky") :comp-placeholder-id "retry-comp" :wire-placeholder-id "retry-wire")) diff --git a/sx/sx/handlers/examples.sx b/sx/sx/handlers/examples.sx index 5d2f90e..75b96b3 100644 --- a/sx/sx/handlers/examples.sx +++ b/sx/sx/handlers/examples.sx @@ -24,19 +24,9 @@ (list "admin@example.com" "test@example.com" "user@example.com")) (define tab-content - {"tab1" (div (p :class "text-stone-700" "Welcome to the Overview tab.") - (p :class "text-stone-500 text-sm mt-2" - "This is the default tab content loaded via sx-get.")) - "tab2" (div (p :class "text-stone-700" "Here are the details.") - (ul :class "mt-2 space-y-1 text-sm text-stone-600" - (li "Version: 1.0.0") - (li "Build: 2024-01-15") - (li "Engine: sx"))) - "tab3" (div (p :class "text-stone-700" "Recent history:") - (ol :class "mt-2 space-y-1 text-sm text-stone-600 list-decimal list-inside" - (li "Initial release") - (li "Added component caching") - (li "Wire format v2")))}) + {"tab1" "Welcome to the Overview tab. This is the default tab content loaded via sx-get." + "tab2" "Here are the details. Version: 1.0.0, Build: 2024-01-15, Engine: sx" + "tab3" "Recent history: Initial release, Added component caching, Wire format v2"}) (define kbd-actions {"s" "Search panel activated" diff --git a/sx/sxc/handlers/examples.sx b/sx/sxc/handlers/examples.sx deleted file mode 100644 index c6e0e0d..0000000 --- a/sx/sxc/handlers/examples.sx +++ /dev/null @@ -1,367 +0,0 @@ -;; SX example API handlers — defhandler definitions -;; -;; These serve the live demos on the Examples docs pages. -;; Each handler's source is displayed in the "Server handler" code block -;; on its corresponding example page (self-referencing via handler-source). - -;; --------------------------------------------------------------------------- -;; Click to Load -;; --------------------------------------------------------------------------- - -(defhandler click (&key) - (let ((now (format-time (now) "%H:%M:%S"))) - (~click-result :time now))) - -;; --------------------------------------------------------------------------- -;; Form Submission -;; --------------------------------------------------------------------------- - -(defhandler form (&key) - (let ((name (form-data "name"))) - (~form-result :name name))) - -;; --------------------------------------------------------------------------- -;; Polling -;; --------------------------------------------------------------------------- - -(defhandler poll (&key) - (let ((now (format-time (now) "%H:%M:%S")) - (count (inc-counter "poll" :max 10))) - (~poll-result :time now :count count))) - -;; --------------------------------------------------------------------------- -;; Delete Row -;; --------------------------------------------------------------------------- - -(defhandler delete (&key item-id) - ;; Empty response — outerHTML swap removes the row - "") - -;; --------------------------------------------------------------------------- -;; Inline Edit -;; --------------------------------------------------------------------------- - -(defhandler edit-form (&key) - (let ((value (request-arg "value"))) - (~inline-edit-form :value value))) - -(defhandler edit-save (&key) - (let ((value (form-data "value"))) - (~inline-view :value value))) - -(defhandler edit-cancel (&key) - (let ((value (request-arg "value"))) - (~inline-view :value value))) - -;; --------------------------------------------------------------------------- -;; Out-of-Band Swaps -;; --------------------------------------------------------------------------- - -(defhandler oob (&key) - (let ((now (format-time (now) "%H:%M:%S"))) - (<> - (p :class "text-emerald-600 font-medium" "Box A updated!") - (p :class "text-sm text-stone-500" "at " now) - (div :id "oob-box-b" :sx-swap-oob "innerHTML" - (p :class "text-violet-600 font-medium" "Box B updated via OOB!") - (p :class "text-sm text-stone-500" "at " now))))) - -;; --------------------------------------------------------------------------- -;; Lazy Loading -;; --------------------------------------------------------------------------- - -(defhandler lazy (&key) - (let ((now (format-time (now) "%H:%M:%S"))) - (~lazy-result :time now))) - -;; --------------------------------------------------------------------------- -;; Infinite Scroll -;; --------------------------------------------------------------------------- - -(defhandler scroll (&key) - (let ((page (or (parse-int (request-arg "page")) 2)) - (start (+ (* (- page 1) 5) 1)) - (next (+ page 1))) - (<> - (map (fn (i) - (div :class "px-4 py-3 border-b border-stone-100 text-sm text-stone-700" - "Item " i " — loaded from page " page)) - (range start (+ start 5))) - (if (<= next 6) - (div :id "scroll-sentinel" - :sx-get (str "/geography/hypermedia/examples/api/scroll?page=" next) - :sx-trigger "intersect once" - :sx-target "#scroll-items" - :sx-swap "beforeend" - :class "p-3 text-center text-stone-400 text-sm" - "Loading more...") - (div :class "p-3 text-center text-stone-500 text-sm font-medium" - "All items loaded."))))) - -;; --------------------------------------------------------------------------- -;; Progress Bar -;; --------------------------------------------------------------------------- - -(defhandler progress-start (&key) - (let ((job-id (new-job))) - (~progress-status :percent 0 :job-id job-id))) - -(defhandler progress-status (&key) - (let ((job-id (request-arg "job")) - (percent (advance-job job-id))) - (~progress-status :percent percent :job-id job-id))) - -;; --------------------------------------------------------------------------- -;; Active Search -;; --------------------------------------------------------------------------- - -(defhandler search (&key) - (let ((q (request-arg "q")) - (results (filter-list LANGUAGES q))) - (~search-results :items results :query q))) - -;; --------------------------------------------------------------------------- -;; Inline Validation -;; --------------------------------------------------------------------------- - -(defhandler validate (&key) - (let ((email (request-arg "email"))) - (cond - ((not email) - (~validation-error :message "Email is required")) - ((not (contains? email "@")) - (~validation-error :message "Invalid email format")) - ((contains? TAKEN_EMAILS (lower email)) - (~validation-error - :message (str email " is already taken"))) - (t (~validation-ok :email email))))) - -;; --------------------------------------------------------------------------- -;; Value Select -;; --------------------------------------------------------------------------- - -(defhandler values (&key) - (let ((cat (request-arg "category")) - (items (get VALUE_SELECT_DATA cat))) - (if (empty? items) - (option :value "" "No items") - (map (fn (i) (option :value i i)) items)))) - -;; --------------------------------------------------------------------------- -;; Reset on Submit -;; --------------------------------------------------------------------------- - -(defhandler reset-submit (&key) - (let ((msg (or (form-data "message") "(empty)")) - (now (format-time (now) "%H:%M:%S"))) - (~reset-message :message msg :time now))) - -;; --------------------------------------------------------------------------- -;; Edit Row -;; --------------------------------------------------------------------------- - -(defhandler editrow-form (&key row-id) - (let ((row (get ROWS row-id))) - (~edit-row-form :id row-id - :name (get row "name") - :price (get row "price") - :stock (get row "stock")))) - -(defhandler editrow-save (&key row-id) - (let ((name (form-data "name")) - (price (form-data "price")) - (stock (form-data "stock"))) - (~edit-row-view :id row-id - :name name :price price :stock stock))) - -(defhandler editrow-cancel (&key row-id) - (let ((row (get ROWS row-id))) - (~edit-row-view :id row-id - :name (get row "name") - :price (get row "price") - :stock (get row "stock")))) - -;; --------------------------------------------------------------------------- -;; Bulk Update -;; --------------------------------------------------------------------------- - -(defhandler bulk (&key) - (let ((action (request-arg "action")) - (ids (form-list "ids")) - (status (if (= action "activate") - "active" "inactive"))) - (update-users ids :status status) - (map (fn (u) - (~bulk-row - :id (get u "id") - :name (get u "name") - :email (get u "email") - :status (get u "status"))) - USERS))) - -;; --------------------------------------------------------------------------- -;; Swap Positions -;; --------------------------------------------------------------------------- - -(defhandler swap-log (&key) - (let ((mode (request-arg "mode")) - (n (inc-counter "swap")) - (now (format-time (now) "%H:%M:%S"))) - (<> - (div :class "px-3 py-2 text-sm text-stone-700" - "[" now "] " mode " (#" n ")") - (span :id "swap-counter" - :sx-swap-oob "innerHTML" - :class "self-center text-sm text-stone-500" - "Count: " n)))) - -;; --------------------------------------------------------------------------- -;; Select Filter (Dashboard) -;; --------------------------------------------------------------------------- - -(defhandler dashboard (&key) - (let ((now (format-time (now) "%H:%M:%S"))) - (<> - (div :id "dash-header" :class "p-3 bg-violet-50 rounded mb-3" - (h4 :class "font-semibold text-violet-800" "Dashboard Header") - (p :class "text-sm text-violet-600" "Generated at " now)) - (div :id "dash-stats" :class "grid grid-cols-3 gap-3 mb-3" - (div :class "p-3 bg-emerald-50 rounded text-center" - (p :class "text-2xl font-bold text-emerald-700" "142") - (p :class "text-xs text-emerald-600" "Users")) - (div :class "p-3 bg-blue-50 rounded text-center" - (p :class "text-2xl font-bold text-blue-700" "89") - (p :class "text-xs text-blue-600" "Orders")) - (div :class "p-3 bg-amber-50 rounded text-center" - (p :class "text-2xl font-bold text-amber-700" "$4.2k") - (p :class "text-xs text-amber-600" "Revenue"))) - (div :id "dash-footer" :class "p-3 bg-stone-100 rounded" - (p :class "text-sm text-stone-500" "Last updated: " now))))) - -;; --------------------------------------------------------------------------- -;; Tabs -;; --------------------------------------------------------------------------- - -(defhandler tabs (&key tab) - (let ((content (get TAB_CONTENT tab))) - (<> content - (div :id "tab-buttons" - :sx-swap-oob "innerHTML" - :class "flex border-b border-stone-200" - (map (fn (t) - (~tab-btn - :tab (first t) - :label (last t) - :active (if (= (first t) tab) "true" "false"))) - TAB_LIST))))) - -;; --------------------------------------------------------------------------- -;; Animations -;; --------------------------------------------------------------------------- - -(defhandler animate (&key) - (let ((color (random-choice - "bg-violet-100" "bg-emerald-100" - "bg-blue-100" "bg-amber-100")) - (now (format-time (now) "%H:%M:%S"))) - (~anim-result :color color :time now))) - -;; --------------------------------------------------------------------------- -;; Dialogs -;; --------------------------------------------------------------------------- - -(defhandler dialog (&key) - (~dialog-modal - :title "Confirm Action" - :message "Are you sure you want to proceed?")) - -(defhandler dialog-close (&key) - "") - -;; --------------------------------------------------------------------------- -;; Keyboard Shortcuts -;; --------------------------------------------------------------------------- - -(defhandler keyboard (&key) - (let ((key (request-arg "key")) - (actions {:s "Search panel activated" - :n "New item created" - :h "Help panel opened"}) - (action (get actions key))) - (~kbd-result :key key :action action))) - -;; --------------------------------------------------------------------------- -;; PUT / PATCH -;; --------------------------------------------------------------------------- - -(defhandler pp-edit-all (&key) - (let ((p (get-profile))) - (~pp-form-full - :name (get p "name") - :email (get p "email") - :role (get p "role")))) - -(defhandler put-profile (&key) - (let ((name (form-data "name")) - (email (form-data "email")) - (role (form-data "role"))) - (~pp-view :name name :email email :role role))) - -(defhandler pp-cancel (&key) - (let ((p (get-profile))) - (~pp-view - :name (get p "name") - :email (get p "email") - :role (get p "role")))) - -;; --------------------------------------------------------------------------- -;; JSON Encoding -;; --------------------------------------------------------------------------- - -(defhandler json-echo (&key) - (let ((data (request-json)) - (body (json-pretty data)) - (ct (request-header "content-type"))) - (~json-result :body body :content-type ct))) - -;; --------------------------------------------------------------------------- -;; Vals & Headers -;; --------------------------------------------------------------------------- - -(defhandler echo-vals (&key) - (let ((vals (request-args))) - (~echo-result :label "values" :items vals))) - -(defhandler echo-headers (&key) - (let ((headers (request-headers :prefix "X-"))) - (~echo-result :label "headers" :items headers))) - -;; --------------------------------------------------------------------------- -;; Loading States -;; --------------------------------------------------------------------------- - -(defhandler slow (&key) - (sleep 2000) - (let ((now (format-time (now) "%H:%M:%S"))) - (~loading-result :time now))) - -;; --------------------------------------------------------------------------- -;; Request Abort (sync replace) -;; --------------------------------------------------------------------------- - -(defhandler slow-search (&key) - (let ((delay (random-int 500 2000))) - (sleep delay) - (let ((q (request-arg "q"))) - (~sync-result :query q :delay delay)))) - -;; --------------------------------------------------------------------------- -;; Retry -;; --------------------------------------------------------------------------- - -(defhandler flaky (&key) - (let ((n (inc-counter "flaky"))) - (if (!= (mod n 3) 0) - (error 503) - (~retry-result :attempt n - :message "Success! The endpoint finally responded.")))) diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 34e679a..431c6ac 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -35,6 +35,7 @@ def _register_sx_helpers() -> None: "prove-data": _prove_data, "page-helpers-demo-data": _page_helpers_demo_data, "spec-explorer-data": _spec_explorer_data, + "handler-source": _handler_source, }) @@ -68,6 +69,35 @@ def _component_source(name: str) -> str: }) +def _handler_source(name: str) -> str: + """Return the pretty-printed defhandler source for a named handler.""" + from shared.sx.handlers import get_handler + from shared.sx.parser import serialize + + hdef = get_handler("sx", name) + if not hdef: + return f";;; Handler not found: {name}" + + parts = [f"(defhandler {hdef.name}"] + if hdef.path: + parts.append(f' :path "{hdef.path}"') + if hdef.method != "get": + parts.append(f" :method :{hdef.method}") + if not hdef.csrf: + parts.append(" :csrf false") + if hdef.returns != "element": + parts.append(f' :returns "{hdef.returns}"') + param_strs = ["&key"] + list(hdef.params) if hdef.params else [] + parts.append(f" ({' '.join(param_strs)})" if param_strs else " ()") + body_sx = serialize(hdef.body, pretty=True) + # Indent body by 2 spaces + body_lines = body_sx.split("\n") + parts.append(" " + body_lines[0]) + for line in body_lines[1:]: + parts.append(" " + line) + return "\n".join(parts) + ")" + + def _primitives_data() -> dict: """Return the PRIMITIVES dict for the primitives docs page.""" from content.pages import PRIMITIVES