From 16f0908ec9c58aa332547550a5d1701f8876fe6f Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 11:24:26 +0000 Subject: [PATCH] Move SX docs rendering from sx_components.py to sxc/pages (phase 8) Consolidate 86 component rendering functions into sxc/pages/__init__.py, update 37 import sites in routes.py, remove app.py side-effect imports, and delete sx/sxc/sx_components.py. Co-Authored-By: Claude Opus 4.6 --- sx/app.py | 4 - sx/bp/pages/routes.py | 74 +- sx/sxc/pages/__init__.py | 3057 +++++++++++++++++++++++++++++++++++++- sx/sxc/sx_components.py | 3029 ------------------------------------- 4 files changed, 3069 insertions(+), 3095 deletions(-) delete mode 100644 sx/sxc/sx_components.py diff --git a/sx/app.py b/sx/app.py index 390e490..92a0c50 100644 --- a/sx/app.py +++ b/sx/app.py @@ -1,7 +1,5 @@ from __future__ import annotations import path_setup # noqa: F401 -import sxc.sx_components as sx_components # noqa: F401 - from shared.infrastructure.factory import create_base_app from bp import register_pages @@ -48,8 +46,6 @@ def create_app() -> "Quart": domain_services_fn=register_domain_services, ) - import sxc.sx_components # noqa: F401 - from sxc.pages import setup_sx_pages setup_sx_pages() diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index dd48a0e..2513df8 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -25,7 +25,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/click") async def api_click(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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") @@ -38,7 +38,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/form") async def api_form(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text form = await request.form name = form.get("name", "") escaped = name.replace('"', '\\"') @@ -54,7 +54,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/poll") async def api_poll(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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) @@ -69,7 +69,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.delete("/examples/api/delete/") async def api_delete(item_id: str): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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)') @@ -81,7 +81,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/edit") async def api_edit_form(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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}")' @@ -95,7 +95,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/edit") async def api_edit_save(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text form = await request.form value = form.get("value", "") escaped = value.replace('"', '\\"') @@ -109,7 +109,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/edit/cancel") async def api_edit_cancel(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text value = request.args.get("value", "") escaped = value.replace('"', '\\"') sx_src = f'(~inline-view :value "{escaped}")' @@ -122,7 +122,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/oob") async def api_oob(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _full_wire_text + from sxc.pages import _oob_code, _full_wire_text now = datetime.now().strftime("%H:%M:%S") sx_src = ( f'(<>' @@ -141,7 +141,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/lazy") async def api_lazy(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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") @@ -155,7 +155,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/scroll") async def api_scroll(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _full_wire_text + from sxc.pages import _oob_code, _full_wire_text page = int(request.args.get("page", 2)) start = (page - 1) * 5 + 1 next_page = page + 1 @@ -191,7 +191,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/progress/start") async def api_progress_start(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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}")' @@ -204,7 +204,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/progress/status") async def api_progress_status(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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) @@ -221,7 +221,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/search") async def api_search(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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: @@ -244,7 +244,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/validate") async def api_validate(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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")' @@ -282,7 +282,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/values") async def api_values(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _full_wire_text + from sxc.pages import _oob_code, _full_wire_text from content.pages import VALUE_SELECT_DATA cat = request.args.get("category", "") items = VALUE_SELECT_DATA.get(cat, []) @@ -300,7 +300,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/reset-submit") async def api_reset_submit(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text form = await request.form msg = form.get("message", "").strip() or "(empty)" escaped = msg.replace('"', '\\"') @@ -326,7 +326,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/editrow/") async def api_editrow_form(row_id: str): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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"]}"' @@ -341,7 +341,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/editrow/") async def api_editrow_save(row_id: str): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text form = await request.form rows = _get_edit_rows() rows[row_id] = { @@ -362,7 +362,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/editrow//cancel") async def api_editrow_cancel(row_id: str): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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"]}"' @@ -388,7 +388,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/bulk") async def api_bulk(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text action = request.args.get("action", "activate") form = await request.form ids = form.getlist("ids") @@ -418,7 +418,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/swap-log") async def api_swap_log(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _full_wire_text + from sxc.pages import _oob_code, _full_wire_text mode = request.args.get("mode", "beforeend") _swap_count["n"] += 1 now = datetime.now().strftime("%H:%M:%S") @@ -438,7 +438,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/dashboard") async def api_dashboard(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _full_wire_text + from sxc.pages import _oob_code, _full_wire_text now = datetime.now().strftime("%H:%M:%S") sx_src = ( f'(<>' @@ -483,7 +483,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/tabs/") async def api_tabs(tab: str): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _full_wire_text + from sxc.pages 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")]: @@ -503,7 +503,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/animate") async def api_animate(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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") @@ -519,7 +519,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/dialog") async def api_dialog(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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") @@ -530,7 +530,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/dialog/close") async def api_dialog_close(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _full_wire_text + from sxc.pages 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})') @@ -546,7 +546,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/keyboard") async def api_keyboard(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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('"', '\\"') @@ -571,7 +571,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/putpatch/edit-all") async def api_pp_edit_all(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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") @@ -584,7 +584,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.put("/examples/api/putpatch") async def api_pp_put(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text form = await request.form p = _get_profile() p["name"] = form.get("name", p["name"]) @@ -600,7 +600,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/putpatch/cancel") async def api_pp_cancel(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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") @@ -615,7 +615,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.post("/examples/api/json-echo") async def api_json_echo(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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" @@ -633,7 +633,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/echo-vals") async def api_echo_vals(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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()) @@ -647,7 +647,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/echo-headers") async def api_echo_headers(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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}))' @@ -662,7 +662,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/slow") async def api_slow(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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}")' @@ -677,7 +677,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/slow-search") async def api_slow_search(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages 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() @@ -697,7 +697,7 @@ def register(url_prefix: str = "/") -> Blueprint: @bp.get("/examples/api/flaky") async def api_flaky(): from shared.sx.helpers import sx_response - from sxc.sx_components import _oob_code, _component_source_text, _full_wire_text + from sxc.pages import _oob_code, _component_source_text, _full_wire_text _flaky["n"] += 1 n = _flaky["n"] if n % 3 != 0: @@ -715,7 +715,7 @@ def register(url_prefix: str = "/") -> Blueprint: 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 + from sxc.pages import _oob_code return _oob_code(f"ref-wire-{wire_id}", sx_src) @bp.get("/reference/api/time") diff --git a/sx/sxc/pages/__init__.py b/sx/sxc/pages/__init__.py index 4f9393d..92676e2 100644 --- a/sx/sxc/pages/__init__.py +++ b/sx/sxc/pages/__init__.py @@ -1,14 +1,18 @@ """SX docs defpage setup — registers layouts and page helpers.""" from __future__ import annotations +import os from typing import Any +from shared.sx.jinja_bridge import load_sx_dir, watch_sx_dir +from shared.sx.helpers import ( + render_to_sx, SxExpr, get_asset_url, +) +from content.highlight import highlight + def setup_sx_pages() -> None: - """Register sx-specific layouts, page helpers, and load page definitions. - - Called during app startup before mount_pages(). - """ + """Register sx-specific layouts, page helpers, and load page definitions.""" _register_sx_layouts() _register_sx_helpers() _load_sx_page_files() @@ -16,10 +20,3031 @@ def setup_sx_pages() -> None: def _load_sx_page_files() -> None: """Load defpage definitions from sx/sxc/pages/*.sx.""" - import os from shared.sx.pages import load_page_dir - pages_dir = os.path.dirname(__file__) - load_page_dir(pages_dir, "sx") + from shared.sx.jinja_bridge import load_service_components + base = os.path.dirname(os.path.dirname(__file__)) + load_service_components(base, service_name="sx") + _sxc_dir = os.path.dirname(__file__) + load_sx_dir(_sxc_dir) + watch_sx_dir(_sxc_dir) + load_page_dir(os.path.dirname(__file__), "sx") + + +# --------------------------------------------------------------------------- +# Component rendering functions (moved from sx_components.py) +# --------------------------------------------------------------------------- + +def _code(code: str, language: str = "lisp") -> str: + """Build a ~doc-code component with highlighted content.""" + highlighted = highlight(code, language) + return f'(~doc-code :code {highlighted})' + + +def _example_code(code: str, language: str = "lisp") -> str: + """Build an ~example-source component with highlighted content.""" + highlighted = highlight(code, language) + return f'(~example-source :code {highlighted})' + + +def _placeholder(div_id: str) -> str: + """Empty placeholder that will be filled by OOB swap on interaction.""" + return (f'(div :id "{div_id}"' + f' (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3"' + f' (p :class "text-stone-400 italic text-sm"' + f' "Trigger the demo to see the actual content.")))') + + +def _component_source_text(*names: str) -> str: + """Get defcomp source text for named components.""" + from shared.sx.jinja_bridge import _COMPONENT_ENV + from shared.sx.types import Component + from shared.sx.parser import serialize + parts = [] + for name in names: + key = name if name.startswith("~") else f"~{name}" + val = _COMPONENT_ENV.get(key) + if isinstance(val, Component): + param_strs = ["&key"] + list(val.params) + if val.has_children: + param_strs.extend(["&rest", "children"]) + params_sx = "(" + " ".join(param_strs) + ")" + body_sx = serialize(val.body, pretty=True) + parts.append(f"(defcomp ~{val.name} {params_sx}\n{body_sx})") + return "\n\n".join(parts) + + +def _oob_code(target_id: str, text: str) -> str: + """OOB swap that displays plain code in a styled block.""" + escaped = text.replace('\\', '\\\\').replace('"', '\\"') + return (f'(div :id "{target_id}" :sx-swap-oob "innerHTML"' + f' (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto"' + f' (pre :class "text-sm whitespace-pre-wrap"' + f' (code "{escaped}"))))') + + +def _clear_components_btn() -> str: + """Button that clears the client-side component cache (localStorage + in-memory).""" + js = ("localStorage.removeItem('sx-components-hash');" + "localStorage.removeItem('sx-components-src');" + "var e=Sx.getEnv();Object.keys(e).forEach(function(k){if(k.charAt(0)==='~')delete e[k]});" + "var b=this;b.textContent='Cleared!';setTimeout(function(){b.textContent='Clear component cache'},2000)") + return (f'(button :onclick "{js}"' + f' :class "text-xs text-stone-400 hover:text-stone-600 border border-stone-200' + f' rounded px-2 py-1 transition-colors"' + f' "Clear component cache")') + + +def _full_wire_text(sx_src: str, *comp_names: str) -> str: + """Build the full wire response text showing component defs + CSS note + sx source. + + Only includes component definitions the client doesn't already have, + matching the real behaviour of sx_response(). + """ + from quart import request + parts = [] + if comp_names: + # Check which components the client already has + loaded_raw = request.headers.get("SX-Components", "") + loaded = set(loaded_raw.split(",")) if loaded_raw else set() + missing = [n for n in comp_names + if f"~{n}" not in loaded and n not in loaded] + if missing: + comp_text = _component_source_text(*missing) + if comp_text: + parts.append(f'') + parts.append('') + # Pretty-print the sx source for readable display + try: + from shared.sx.parser import parse as _parse, serialize as _serialize + parts.append(_serialize(_parse(sx_src), pretty=True)) + except Exception: + parts.append(sx_src) + return "\n\n".join(parts) + + +# --------------------------------------------------------------------------- +# Navigation helpers +# --------------------------------------------------------------------------- + +async def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str: + """Build nav link items as sx.""" + parts = [] + for label, href in items: + parts.append(await render_to_sx("nav-link", + href=href, label=label, + is_selected="true" if current == label else None, + select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900", + )) + return "(<> " + " ".join(parts) + ")" + + +async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str: + """Build the sx docs menu-row.""" + return await render_to_sx("menu-row-sx", + id="sx-row", level=1, colour="violet", + link_href="/", link_label="sx", + link_label_content=SxExpr('(span :class "font-mono" "()")'), + nav=SxExpr(nav) if nav else None, + child_id="sx-header-child", + child=SxExpr(child) if child else None, + ) + + +async def _docs_nav_sx(current: str | None = None) -> str: + from content.pages import DOCS_NAV + return await _nav_items_sx(DOCS_NAV, current) + + +async def _reference_nav_sx(current: str | None = None) -> str: + from content.pages import REFERENCE_NAV + return await _nav_items_sx(REFERENCE_NAV, current) + + +async def _protocols_nav_sx(current: str | None = None) -> str: + from content.pages import PROTOCOLS_NAV + return await _nav_items_sx(PROTOCOLS_NAV, current) + + +async def _examples_nav_sx(current: str | None = None) -> str: + from content.pages import EXAMPLES_NAV + return await _nav_items_sx(EXAMPLES_NAV, current) + + +async def _essays_nav_sx(current: str | None = None) -> str: + from content.pages import ESSAYS_NAV + return await _nav_items_sx(ESSAYS_NAV, current) + + +async def _main_nav_sx(current_section: str | None = None) -> str: + from content.pages import MAIN_NAV + return await _nav_items_sx(MAIN_NAV, current_section) + + +async def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str, + selected: str = "") -> str: + """Build the level-2 sub-section menu-row.""" + return await render_to_sx("menu-row-sx", + id="sx-sub-row", level=2, colour="violet", + link_href=sub_href, link_label=sub_label, + selected=selected or None, + nav=SxExpr(sub_nav), + ) + + + +# --------------------------------------------------------------------------- +# Content builders — return sx source strings +# --------------------------------------------------------------------------- + +async def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str: + """Build the in-page doc navigation pills.""" + items_sx = " ".join( + f'(list "{label}" "{href}")' + for label, href in items + ) + return await render_to_sx("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current) + + +async def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: + """Build an attribute reference table.""" + from content.pages import ATTR_DETAILS + rows = [] + for attr, desc, exists in attrs: + href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None + rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc, + exists="true" if exists else None, + href=href)) + return ( + f'(div :class "space-y-3"' + f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' + f' (div :class "overflow-x-auto rounded border border-stone-200"' + f' (table :class "w-full text-left text-sm"' + f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Attribute")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")' + f' (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))' + f' (tbody {" ".join(rows)}))))' + ) + + +async def _primitives_section_sx() -> str: + """Build the primitives section.""" + from content.pages import PRIMITIVES + parts = [] + for category, prims in PRIMITIVES.items(): + prims_sx = " ".join(f'"{p}"' for p in prims) + parts.append(await render_to_sx("doc-primitives-table", + category=category, + primitives=SxExpr(f"(list {prims_sx})"))) + return " ".join(parts) + + +def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: + """Build a headers reference table.""" + rows = [] + for name, value, desc in headers: + rows.append( + f'(tr :class "border-b border-stone-100"' + f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' + f' (td :class "px-3 py-2 font-mono text-sm text-stone-500" "{value}")' + f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' + ) + return ( + f'(div :class "space-y-3"' + f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' + f' (div :class "overflow-x-auto rounded border border-stone-200"' + f' (table :class "w-full text-left text-sm"' + f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Header")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Value")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' + f' (tbody {" ".join(rows)}))))' + ) + + + +async def _docs_content_sx(slug: str) -> str: + """Route to the right docs content builder.""" + import inspect + builders = { + "introduction": _docs_introduction_sx, + "getting-started": _docs_getting_started_sx, + "components": _docs_components_sx, + "evaluator": _docs_evaluator_sx, + "primitives": _docs_primitives_sx, + "css": _docs_css_sx, + "server-rendering": _docs_server_rendering_sx, + } + builder = builders.get(slug, _docs_introduction_sx) + result = builder() + return await result if inspect.isawaitable(result) else result + + +def _docs_introduction_sx() -> str: + return ( + '(~doc-page :title "Introduction"' + ' (~doc-section :title "What is sx?" :id "what"' + ' (p :class "text-stone-600"' + ' "sx is an s-expression language for building web UIs. ' + 'It combines htmx\'s server-first hypermedia approach with React\'s component model. ' + 'The server sends s-expression source code over the wire. The client parses, evaluates, and renders it to DOM.")' + ' (p :class "text-stone-600"' + ' "The same evaluator runs on both server (Python) and client (JavaScript). ' + 'Components defined once render identically in both environments."))' + ' (~doc-section :title "Design decisions" :id "design"' + ' (p :class "text-stone-600"' + ' "HTML elements are first-class: (div :class \\"card\\" (p \\"hello\\")) renders exactly what you\'d expect. ' + 'Components use defcomp with keyword parameters and optional children. ' + 'The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")' + ' (p :class "text-stone-600"' + ' "sx replaces the pattern of ' + 'shipping a JS framework + build step + client-side router + state management library ' + 'just to render some server data. For most applications, sx eliminates the need for ' + 'JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, ' + 'and the server handles everything else."))' + ' (~doc-section :title "What sx is not" :id "not"' + ' (ul :class "space-y-2 text-stone-600"' + ' (li "Not a general-purpose programming language — it\'s a UI rendering language")' + ' (li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")' + ' (li "Not production-hardened at scale — it runs one website"))))' + ) + + +def _docs_getting_started_sx() -> str: + c1 = _code('(div :class "p-4 bg-white rounded"\n (h1 :class "text-2xl font-bold" "Hello, world!")\n (p "This is rendered from an s-expression."))') + c2 = _code('(button\n :sx-get "/api/data"\n :sx-target "#result"\n :sx-swap "innerHTML"\n "Load data")') + return ( + f'(~doc-page :title "Getting Started"' + f' (~doc-section :title "Minimal example" :id "minimal"' + f' (p :class "text-stone-600"' + f' "An sx response is s-expression source code with content type text/sx:")' + f' {c1}' + f' (p :class "text-stone-600"' + f' "Add sx-get to any element to make it fetch and render sx:"))' + f' (~doc-section :title "Hypermedia attributes" :id "attrs"' + f' (p :class "text-stone-600"' + f' "Like htmx, sx adds attributes to HTML elements to trigger HTTP requests:")' + f' {c2}' + f' (p :class "text-stone-600"' + f' "sx-get, sx-post, sx-put, sx-delete, sx-patch — all work the same way. ' + f'The response is parsed as sx and rendered into the target element.")))' + ) + + +def _docs_components_sx() -> str: + c1 = _code('(defcomp ~card (&key title subtitle &rest children)\n' + ' (div :class "border rounded p-4"\n' + ' (h2 :class "font-bold" title)\n' + ' (when subtitle (p :class "text-stone-500" subtitle))\n' + ' (div :class "mt-3" children)))') + c2 = _code('(~card :title "My Card" :subtitle "A description"\n' + ' (p "First child")\n' + ' (p "Second child"))') + return ( + f'(~doc-page :title "Components"' + f' (~doc-section :title "defcomp" :id "defcomp"' + f' (p :class "text-stone-600"' + f' "Components are defined with defcomp. They take keyword parameters and optional children:")' + f' {c1}' + f' (p :class "text-stone-600"' + f' "Use components with the ~ prefix:")' + f' {c2})' + f' (~doc-section :title "Component caching" :id "caching"' + f' (p :class "text-stone-600"' + f' "Component definitions are sent in a ') - parts.append('') - # Pretty-print the sx source for readable display - try: - from shared.sx.parser import parse as _parse, serialize as _serialize - parts.append(_serialize(_parse(sx_src), pretty=True)) - except Exception: - parts.append(sx_src) - return "\n\n".join(parts) - - -# --------------------------------------------------------------------------- -# Navigation helpers -# --------------------------------------------------------------------------- - -async def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str: - """Build nav link items as sx.""" - parts = [] - for label, href in items: - parts.append(await render_to_sx("nav-link", - href=href, label=label, - is_selected="true" if current == label else None, - select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900", - )) - return "(<> " + " ".join(parts) + ")" - - -async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str: - """Build the sx docs menu-row.""" - return await render_to_sx("menu-row-sx", - id="sx-row", level=1, colour="violet", - link_href="/", link_label="sx", - link_label_content=SxExpr('(span :class "font-mono" "()")'), - nav=SxExpr(nav) if nav else None, - child_id="sx-header-child", - child=SxExpr(child) if child else None, - ) - - -async def _docs_nav_sx(current: str | None = None) -> str: - from content.pages import DOCS_NAV - return await _nav_items_sx(DOCS_NAV, current) - - -async def _reference_nav_sx(current: str | None = None) -> str: - from content.pages import REFERENCE_NAV - return await _nav_items_sx(REFERENCE_NAV, current) - - -async def _protocols_nav_sx(current: str | None = None) -> str: - from content.pages import PROTOCOLS_NAV - return await _nav_items_sx(PROTOCOLS_NAV, current) - - -async def _examples_nav_sx(current: str | None = None) -> str: - from content.pages import EXAMPLES_NAV - return await _nav_items_sx(EXAMPLES_NAV, current) - - -async def _essays_nav_sx(current: str | None = None) -> str: - from content.pages import ESSAYS_NAV - return await _nav_items_sx(ESSAYS_NAV, current) - - -async def _main_nav_sx(current_section: str | None = None) -> str: - from content.pages import MAIN_NAV - return await _nav_items_sx(MAIN_NAV, current_section) - - -async def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str, - selected: str = "") -> str: - """Build the level-2 sub-section menu-row.""" - return await render_to_sx("menu-row-sx", - id="sx-sub-row", level=2, colour="violet", - link_href=sub_href, link_label=sub_label, - selected=selected or None, - nav=SxExpr(sub_nav), - ) - - - -# --------------------------------------------------------------------------- -# Content builders — return sx source strings -# --------------------------------------------------------------------------- - -async def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str: - """Build the in-page doc navigation pills.""" - items_sx = " ".join( - f'(list "{label}" "{href}")' - for label, href in items - ) - return await render_to_sx("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current) - - -async def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: - """Build an attribute reference table.""" - from content.pages import ATTR_DETAILS - rows = [] - for attr, desc, exists in attrs: - href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None - rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc, - exists="true" if exists else None, - href=href)) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Attribute")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")' - f' (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))' - f' (tbody {" ".join(rows)}))))' - ) - - -async def _primitives_section_sx() -> str: - """Build the primitives section.""" - from content.pages import PRIMITIVES - parts = [] - for category, prims in PRIMITIVES.items(): - prims_sx = " ".join(f'"{p}"' for p in prims) - parts.append(await render_to_sx("doc-primitives-table", - category=category, - primitives=SxExpr(f"(list {prims_sx})"))) - return " ".join(parts) - - -def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: - """Build a headers reference table.""" - rows = [] - for name, value, desc in headers: - rows.append( - f'(tr :class "border-b border-stone-100"' - f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' - f' (td :class "px-3 py-2 font-mono text-sm text-stone-500" "{value}")' - f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' - ) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Header")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Value")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' - f' (tbody {" ".join(rows)}))))' - ) - - - -async def _docs_content_sx(slug: str) -> str: - """Route to the right docs content builder.""" - import inspect - builders = { - "introduction": _docs_introduction_sx, - "getting-started": _docs_getting_started_sx, - "components": _docs_components_sx, - "evaluator": _docs_evaluator_sx, - "primitives": _docs_primitives_sx, - "css": _docs_css_sx, - "server-rendering": _docs_server_rendering_sx, - } - builder = builders.get(slug, _docs_introduction_sx) - result = builder() - return await result if inspect.isawaitable(result) else result - - -def _docs_introduction_sx() -> str: - return ( - '(~doc-page :title "Introduction"' - ' (~doc-section :title "What is sx?" :id "what"' - ' (p :class "text-stone-600"' - ' "sx is an s-expression language for building web UIs. ' - 'It combines htmx\'s server-first hypermedia approach with React\'s component model. ' - 'The server sends s-expression source code over the wire. The client parses, evaluates, and renders it to DOM.")' - ' (p :class "text-stone-600"' - ' "The same evaluator runs on both server (Python) and client (JavaScript). ' - 'Components defined once render identically in both environments."))' - ' (~doc-section :title "Design decisions" :id "design"' - ' (p :class "text-stone-600"' - ' "HTML elements are first-class: (div :class \\"card\\" (p \\"hello\\")) renders exactly what you\'d expect. ' - 'Components use defcomp with keyword parameters and optional children. ' - 'The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")' - ' (p :class "text-stone-600"' - ' "sx replaces the pattern of ' - 'shipping a JS framework + build step + client-side router + state management library ' - 'just to render some server data. For most applications, sx eliminates the need for ' - 'JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, ' - 'and the server handles everything else."))' - ' (~doc-section :title "What sx is not" :id "not"' - ' (ul :class "space-y-2 text-stone-600"' - ' (li "Not a general-purpose programming language — it\'s a UI rendering language")' - ' (li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")' - ' (li "Not production-hardened at scale — it runs one website"))))' - ) - - -def _docs_getting_started_sx() -> str: - c1 = _code('(div :class "p-4 bg-white rounded"\n (h1 :class "text-2xl font-bold" "Hello, world!")\n (p "This is rendered from an s-expression."))') - c2 = _code('(button\n :sx-get "/api/data"\n :sx-target "#result"\n :sx-swap "innerHTML"\n "Load data")') - return ( - f'(~doc-page :title "Getting Started"' - f' (~doc-section :title "Minimal example" :id "minimal"' - f' (p :class "text-stone-600"' - f' "An sx response is s-expression source code with content type text/sx:")' - f' {c1}' - f' (p :class "text-stone-600"' - f' "Add sx-get to any element to make it fetch and render sx:"))' - f' (~doc-section :title "Hypermedia attributes" :id "attrs"' - f' (p :class "text-stone-600"' - f' "Like htmx, sx adds attributes to HTML elements to trigger HTTP requests:")' - f' {c2}' - f' (p :class "text-stone-600"' - f' "sx-get, sx-post, sx-put, sx-delete, sx-patch — all work the same way. ' - f'The response is parsed as sx and rendered into the target element.")))' - ) - - -def _docs_components_sx() -> str: - c1 = _code('(defcomp ~card (&key title subtitle &rest children)\n' - ' (div :class "border rounded p-4"\n' - ' (h2 :class "font-bold" title)\n' - ' (when subtitle (p :class "text-stone-500" subtitle))\n' - ' (div :class "mt-3" children)))') - c2 = _code('(~card :title "My Card" :subtitle "A description"\n' - ' (p "First child")\n' - ' (p "Second child"))') - return ( - f'(~doc-page :title "Components"' - f' (~doc-section :title "defcomp" :id "defcomp"' - f' (p :class "text-stone-600"' - f' "Components are defined with defcomp. They take keyword parameters and optional children:")' - f' {c1}' - f' (p :class "text-stone-600"' - f' "Use components with the ~ prefix:")' - f' {c2})' - f' (~doc-section :title "Component caching" :id "caching"' - f' (p :class "text-stone-600"' - f' "Component definitions are sent in a