From 0c9dbd66577847c54c74f9a262db1a023555da68 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 17:12:57 +0000 Subject: [PATCH] Add attribute detail pages with live demos for SX reference Per-attribute documentation pages at /reference/attributes/ with: - Live interactive demos (demo components in reference.sx) - S-expression source code display - Server handler code shown as s-expressions (defhandlers in handlers/reference.sx) - Wire response display via OOB swaps on demo interaction - Linked attribute names in the reference table Covers all 20 implemented attributes (sx-get/post/put/delete/patch, sx-trigger/target/swap/swap-oob/select/confirm/push-url/sync/encoding/ headers/include/vals/media/disable/on:*, sx-retry, data-sx, data-sx-env). Also adds sx-on:* to BEHAVIOR_ATTRS, updates REFERENCE_NAV to link /reference/attributes, and makes /reference/ an index page. Co-Authored-By: Claude Opus 4.6 --- sx/bp/pages/routes.py | 173 +++++++++++++ sx/content/pages.py | 481 ++++++++++++++++++++++++++++++++++- sx/sxc/docs.sx | 10 +- sx/sxc/handlers/reference.sx | 149 +++++++++++ sx/sxc/pages/__init__.py | 3 + sx/sxc/pages/docs.sx | 17 +- sx/sxc/reference.sx | 408 +++++++++++++++++++++++++++++ sx/sxc/sx_components.py | 98 ++++++- 8 files changed, 1331 insertions(+), 8 deletions(-) create mode 100644 sx/sxc/handlers/reference.sx create mode 100644 sx/sxc/reference.sx diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index 81f1b31..dd48a0e 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -709,4 +709,177 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("retry-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') + # ------------------------------------------------------------------ + # Reference attribute detail API endpoints (for live demos) + # ------------------------------------------------------------------ + + def _ref_wire(wire_id: str, sx_src: str) -> str: + """Build OOB swap showing the wire response text.""" + from sxc.sx_components import _oob_code + return _oob_code(f"ref-wire-{wire_id}", sx_src) + + @bp.get("/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("/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("/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("/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("/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("/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("/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("/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("/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("/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("/reference/api/upload-name") + async def ref_upload_name(): + from shared.sx.helpers import sx_response + files = await request.files + f = files.get("file") + name = f.filename if f else "(no file)" + sx_src = f'(span :class "text-stone-800 text-sm" "Received: " (strong "{name}"))' + oob = _ref_wire("sx-encoding", sx_src) + return sx_response(f'(<> {sx_src} {oob})') + + @bp.get("/reference/api/echo-headers") + async def ref_echo_headers(): + from shared.sx.helpers import sx_response + custom = [(k, v) for k, v in request.headers if k.lower().startswith("x-")] + if not custom: + sx_src = '(span :class "text-stone-400 text-sm" "No custom headers received.")' + else: + items = " ".join( + f'(li (strong "{k}") ": " "{v}")' for k, v in custom) + sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})' + oob = _ref_wire("sx-headers", sx_src) + return sx_response(f'(<> {sx_src} {oob})') + + @bp.get("/reference/api/echo-vals") + async def ref_echo_vals_get(): + from shared.sx.helpers import sx_response + vals = list(request.args.items()) + if not vals: + sx_src = '(span :class "text-stone-400 text-sm" "No values received.")' + else: + items = " ".join( + f'(li (strong "{k}") ": " "{v}")' for k, v in vals) + sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})' + oob_include = _ref_wire("sx-include", sx_src) + return sx_response(f'(<> {sx_src} {oob_include})') + + @csrf_exempt + @bp.post("/reference/api/echo-vals") + async def ref_echo_vals_post(): + from shared.sx.helpers import sx_response + form = await request.form + vals = list(form.items()) + if not vals: + sx_src = '(span :class "text-stone-400 text-sm" "No values received.")' + else: + items = " ".join( + f'(li (strong "{k}") ": " "{v}")' for k, v in vals) + sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})' + oob = _ref_wire("sx-vals", sx_src) + return sx_response(f'(<> {sx_src} {oob})') + + _ref_flaky = {"n": 0} + + @bp.get("/reference/api/flaky") + async def ref_flaky(): + from shared.sx.helpers import sx_response + _ref_flaky["n"] += 1 + n = _ref_flaky["n"] + if n % 3 != 0: + return Response("", status=503, content_type="text/plain") + sx_src = f'(span :class "text-emerald-700 text-sm" "Success on attempt " "{n}" "!")' + oob = _ref_wire("sx-retry", sx_src) + return sx_response(f'(<> {sx_src} {oob})') + return bp diff --git a/sx/content/pages.py b/sx/content/pages.py index fccfdfb..b4a9f7c 100644 --- a/sx/content/pages.py +++ b/sx/content/pages.py @@ -20,7 +20,7 @@ DOCS_NAV = [ ] REFERENCE_NAV = [ - ("Attributes", "/reference/"), + ("Attributes", "/reference/attributes"), ("Headers", "/reference/headers"), ("Events", "/reference/events"), ("JS API", "/reference/js-api"), @@ -107,6 +107,7 @@ BEHAVIOR_ATTRS = [ ("sx-vals", "Add values to the request as a JSON string", True), ("sx-media", "Only enable this element when the media query matches", True), ("sx-disable", "Disable sx processing on this element and its children", True), + ("sx-on:*", "Inline event handler — e.g. sx-on:click runs JavaScript on event", True), ] SX_UNIQUE_ATTRS = [ @@ -236,3 +237,481 @@ EDIT_ROW_DATA = [ {"id": "3", "name": "Widget C", "price": "12.00", "stock": "305"}, {"id": "4", "name": "Widget D", "price": "45.00", "stock": "67"}, ] + +# --------------------------------------------------------------------------- +# Reference: Attribute detail pages +# --------------------------------------------------------------------------- + +ATTR_DETAILS: dict[str, dict] = { + # --- Request Attributes --- + "sx-get": { + "description": ( + "Issues a GET request to the given URL when triggered. " + "The response HTML is swapped into the target element. " + "This is the most common sx attribute — use it for loading content, " + "navigation, and any read operation." + ), + "demo": "ref-get-demo", + "example": ( + '(button :sx-get "/reference/api/time"\n' + ' :sx-target "#ref-get-result"\n' + ' :sx-swap "innerHTML"\n' + ' "Load server time")' + ), + "handler": ( + '(defhandler ref-time (&key)\n' + ' (let ((now (format-time (now) "%H:%M:%S")))\n' + ' (span :class "text-stone-800 text-sm"\n' + ' "Server time: " (strong now))))' + ), + }, + "sx-post": { + "description": ( + "Issues a POST request to the given URL. " + "Form values from the enclosing form (or sx-include target) are sent as the request body. " + "Use for creating resources, submitting forms, and any write operation." + ), + "demo": "ref-post-demo", + "example": ( + '(form :sx-post "/reference/api/greet"\n' + ' :sx-target "#ref-post-result"\n' + ' :sx-swap "innerHTML"\n' + ' (input :type "text" :name "name"\n' + ' :placeholder "Your name")\n' + ' (button :type "submit" "Greet"))' + ), + "handler": ( + '(defhandler ref-greet (&key)\n' + ' (let ((name (or (form-data "name") "stranger")))\n' + ' (span :class "text-stone-800 text-sm"\n' + ' "Hello, " (strong name) "!")))' + ), + }, + "sx-put": { + "description": ( + "Issues a PUT request to the given URL. " + "Used for full replacement updates of a resource. " + "Form values are sent as the request body." + ), + "demo": "ref-put-demo", + "example": ( + '(button :sx-put "/reference/api/status"\n' + ' :sx-target "#ref-put-view"\n' + ' :sx-swap "innerHTML"\n' + ' :sx-vals "{\\"status\\": \\"published\\"}"\n' + ' "Publish")' + ), + "handler": ( + '(defhandler ref-status (&key)\n' + ' (let ((status (or (form-data "status") "unknown")))\n' + ' (span :class "text-stone-700 text-sm"\n' + ' "Status: " (strong status) " — updated via PUT")))' + ), + }, + "sx-delete": { + "description": ( + "Issues a DELETE request to the given URL. " + "Commonly paired with sx-confirm for a confirmation dialog, " + 'and sx-swap "delete" to remove the element from the DOM.' + ), + "demo": "ref-delete-demo", + "example": ( + '(button :sx-delete "/reference/api/item/1"\n' + ' :sx-target "#ref-del-1"\n' + ' :sx-swap "delete"\n' + ' "Remove")' + ), + "handler": ( + '(defhandler ref-delete (&key item-id)\n' + ' ;; Empty response — swap "delete" removes the target\n' + ' "")' + ), + }, + "sx-patch": { + "description": ( + "Issues a PATCH request to the given URL. " + "Used for partial updates — only changed fields are sent. " + "Form values are sent as the request body." + ), + "demo": "ref-patch-demo", + "example": ( + '(button :sx-patch "/reference/api/theme"\n' + ' :sx-vals "{\\"theme\\": \\"dark\\"}"\n' + ' :sx-target "#ref-patch-val"\n' + ' :sx-swap "innerHTML"\n' + ' "Dark")' + ), + "handler": ( + '(defhandler ref-theme (&key)\n' + ' (let ((theme (or (form-data "theme") "unknown")))\n' + ' (str theme)))' + ), + }, + + # --- Behavior Attributes --- + "sx-trigger": { + "description": ( + "Specifies which DOM event triggers the request. " + "Defaults to 'click' for most elements and 'submit' for forms. " + "Supports modifiers: once, changed, delay: