diff --git a/shared/sx/handlers.py b/shared/sx/handlers.py index 1a775d4..31f8772 100644 --- a/shared/sx/handlers.py +++ b/shared/sx/handlers.py @@ -215,6 +215,56 @@ def create_handler_blueprint(service_name: str) -> Any: return bp +# --------------------------------------------------------------------------- +# Public route registration — handlers with :path get mounted as routes +# --------------------------------------------------------------------------- + +def register_route_handlers(app_or_bp: Any, service_name: str) -> int: + """Register public routes for all handlers with :path defined. + + Returns the number of routes registered. + """ + from quart import Response, request + from shared.browser.app.csrf import csrf_exempt + + handlers = get_all_handlers(service_name) + count = 0 + + for name, hdef in handlers.items(): + if not hdef.is_route: + continue + + # Capture hdef in closure + _hdef = hdef + + async def _route_view(_h=_hdef, **path_kwargs): + from shared.sx.helpers import sx_response + args = dict(request.args) + args.update(path_kwargs) + result = await execute_handler(_h, service_name, args=args) + return sx_response(result) + + endpoint = f"sx_route_{name}" + view_fn = _route_view + + if not _hdef.csrf: + view_fn = csrf_exempt(view_fn) + + method = _hdef.method.lower() + route_reg = getattr(app_or_bp, method, None) + if route_reg is None: + logger.warning("Unsupported HTTP method %s for handler %s", + _hdef.method, name) + continue + + route_reg(_hdef.path, endpoint=endpoint)(view_fn) + logger.info("Registered route %s %s → handler:%s", + _hdef.method.upper(), _hdef.path, name) + count += 1 + + return count + + # --------------------------------------------------------------------------- # Direct app mount — replaces per-service fragment blueprint boilerplate # --------------------------------------------------------------------------- diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 3356048..79898b5 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -297,6 +297,81 @@ async def _io_g( return getattr(g, key, None) +@register_io_handler("now") +async def _io_now( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(now)`` or ``(now "%H:%M:%S")`` → formatted timestamp string.""" + from datetime import datetime + fmt = str(args[0]) if args else None + dt = datetime.now() + return dt.strftime(fmt) if fmt else dt.isoformat() + + +@register_io_handler("sleep") +async def _io_sleep( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(sleep 800)`` → pause for 800ms.""" + import asyncio + from .types import NIL + if not args: + raise ValueError("sleep requires milliseconds") + ms = int(args[0]) + await asyncio.sleep(ms / 1000.0) + return NIL + + +@register_io_handler("request-form") +async def _io_request_form( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-form "name" default?)`` → read a form field.""" + if not args: + raise ValueError("request-form requires a field name") + from quart import request + from .types import NIL + name = str(args[0]) + default = args[1] if len(args) > 1 else NIL + form = await request.form + return form.get(name, default) + + +@register_io_handler("request-json") +async def _io_request_json( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-json)`` → JSON body as dict, or nil.""" + from quart import request + from .types import NIL + data = await request.get_json(silent=True) + return data if data is not None else NIL + + +@register_io_handler("request-header") +async def _io_request_header( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-header "name" default?)`` → request header value.""" + if not args: + raise ValueError("request-header requires a header name") + from quart import request + from .types import NIL + name = str(args[0]) + default = args[1] if len(args) > 1 else NIL + return request.headers.get(name, default) + + +@register_io_handler("request-content-type") +async def _io_request_content_type( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-content-type)`` → content-type string or nil.""" + from quart import request + from .types import NIL + return request.content_type or NIL + + @register_io_handler("csrf-token") async def _io_csrf_token( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx index 5680b1c..09f2307 100644 --- a/shared/sx/ref/boundary.sx +++ b/shared/sx/ref/boundary.sx @@ -138,6 +138,57 @@ "list" "dict" "sx-source")) +;; -------------------------------------------------------------------------- +;; Web interop — reading non-SX request formats +;; +;; SX's native wire format is SX (text/sx). These primitives bridge to +;; legacy web formats: HTML form encoding, JSON bodies, HTTP headers. +;; They're useful for interop but not fundamental to SX-to-SX communication. +;; -------------------------------------------------------------------------- + +(define-io-primitive "now" + :params (&rest format) + :returns "string" + :async true + :doc "Current timestamp. Optional format string (strftime). Default ISO 8601." + :context :request) + +(define-io-primitive "sleep" + :params (ms) + :returns "nil" + :async true + :doc "Pause execution for ms milliseconds. For demos and testing." + :context :request) + +(define-io-primitive "request-form" + :params (name &rest default) + :returns "any" + :async true + :doc "Read a form field from a POST/PUT/PATCH request body." + :context :request) + +(define-io-primitive "request-json" + :params () + :returns "dict?" + :async true + :doc "Read JSON body from the current request, or nil if not JSON." + :context :request) + +(define-io-primitive "request-header" + :params (name &rest default) + :returns "string?" + :async true + :doc "Read a request header value by name." + :context :request) + +(define-io-primitive "request-content-type" + :params () + :returns "string?" + :async true + :doc "Content-Type of the current request." + :context :request) + + ;; -------------------------------------------------------------------------- ;; Tier 3: Signal primitives — reactive state for islands ;; diff --git a/shared/sx/ref/forms.sx b/shared/sx/ref/forms.sx index cb1ff77..afe7e55 100644 --- a/shared/sx/ref/forms.sx +++ b/shared/sx/ref/forms.sx @@ -38,17 +38,65 @@ ;; -------------------------------------------------------------------------- -;; defhandler — (defhandler name (&key param...) body) +;; defhandler — (defhandler name [:path "..." :method :get :csrf false] (&key param...) body) +;; +;; Keyword options between name and params list: +;; :path — public route path (string). Without :path, handler is internal-only. +;; :method — HTTP method (keyword: :get :post :put :patch :delete). Default :get. +;; :csrf — CSRF protection (boolean). Default true; set false for POST/PUT etc. ;; -------------------------------------------------------------------------- +(define parse-handler-args + (fn ((args :as list)) + "Parse defhandler args after the name symbol. + Scans for :keyword value option pairs, then a list (params), then body. + Returns dict with keys: opts, params, body." + (let ((opts {}) + (params (list)) + (body nil) + (i 0) + (n (len args)) + (done false)) + (for-each + (fn (idx) + (when (and (not done) (= idx i)) + (let ((arg (nth args idx))) + (cond + ;; keyword-value pair → consume two items + (= (type-of arg) "keyword") + (do + (when (< (+ idx 1) n) + (let ((val (nth args (+ idx 1)))) + ;; For :method, extract keyword name; for :csrf, keep as-is + (dict-set! opts (keyword-name arg) + (if (= (type-of val) "keyword") + (keyword-name val) + val)))) + (set! i (+ idx 2))) + ;; list → params, next element is body + (= (type-of arg) "list") + (do + (set! params (parse-key-params arg)) + (when (< (+ idx 1) n) + (set! body (nth args (+ idx 1)))) + (set! done true)) + ;; anything else → no explicit params, this is body + :else + (do + (set! body arg) + (set! done true)))))) + (range 0 n)) + (dict :opts opts :params params :body body)))) + (define sf-defhandler (fn ((args :as list) (env :as dict)) - (let ((name-sym (first args)) - (params-raw (nth args 1)) - (body (nth args 2)) - (name (symbol-name name-sym)) - (params (parse-key-params params-raw))) - (let ((hdef (make-handler-def name params body env))) + (let ((name-sym (first args)) + (name (symbol-name name-sym)) + (parsed (parse-handler-args (rest args))) + (opts (get parsed "opts")) + (params (get parsed "params")) + (body (get parsed "body"))) + (let ((hdef (make-handler-def name params body env opts))) (env-set! env (str "handler:" name) hdef) hdef)))) diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index e596a34..3a69dc6 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -222,8 +222,14 @@ def make_macro(params, rest_param, body, env, name=None): closure=dict(env), name=name) -def make_handler_def(name, params, body, env): - return HandlerDef(name=name, params=list(params), body=body, closure=dict(env)) +def make_handler_def(name, params, body, env, opts=None): + path = opts.get('path') if opts else None + method = str(opts.get('method', 'get')) if opts else 'get' + csrf = opts.get('csrf', True) if opts else True + if isinstance(csrf, str): + csrf = csrf.lower() not in ('false', 'nil', 'no') + return HandlerDef(name=name, params=list(params), body=body, closure=dict(env), + path=path, method=method.lower(), csrf=csrf) def make_query_def(name, params, doc, body, env): diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index fdf007a..231f4ed 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -181,8 +181,14 @@ def make_macro(params, rest_param, body, env, name=None): closure=dict(env), name=name) -def make_handler_def(name, params, body, env): - return HandlerDef(name=name, params=list(params), body=body, closure=dict(env)) +def make_handler_def(name, params, body, env, opts=None): + path = opts.get('path') if opts else None + method = str(opts.get('method', 'get')) if opts else 'get' + csrf = opts.get('csrf', True) if opts else True + if isinstance(csrf, str): + csrf = csrf.lower() not in ('false', 'nil', 'no') + return HandlerDef(name=name, params=list(params), body=body, closure=dict(env), + path=path, method=method.lower(), csrf=csrf) def make_query_def(name, params, doc, body, env): @@ -1855,14 +1861,43 @@ def parse_key_params(params_expr): params.append(name) return params +# parse-handler-args +def parse_handler_args(args): + _cells = {} + 'Parse defhandler args after the name symbol.\n Scans for :keyword value option pairs, then a list (params), then body.\n Returns dict with keys: opts, params, body.' + opts = {} + _cells['params'] = [] + _cells['body'] = NIL + _cells['i'] = 0 + n = len(args) + _cells['done'] = False + for idx in range(0, n): + if sx_truthy(((not sx_truthy(_cells['done'])) if not sx_truthy((not sx_truthy(_cells['done']))) else (idx == _cells['i']))): + arg = nth(args, idx) + if sx_truthy((type_of(arg) == 'keyword')): + if sx_truthy(((idx + 1) < n)): + val = nth(args, (idx + 1)) + opts[keyword_name(arg)] = (keyword_name(val) if sx_truthy((type_of(val) == 'keyword')) else val) + _cells['i'] = (idx + 2) + elif sx_truthy((type_of(arg) == 'list')): + _cells['params'] = parse_key_params(arg) + if sx_truthy(((idx + 1) < n)): + _cells['body'] = nth(args, (idx + 1)) + _cells['done'] = True + else: + _cells['body'] = arg + _cells['done'] = True + return {'opts': opts, 'params': _cells['params'], 'body': _cells['body']} + # sf-defhandler def sf_defhandler(args, env): name_sym = first(args) - params_raw = nth(args, 1) - body = nth(args, 2) name = symbol_name(name_sym) - params = parse_key_params(params_raw) - hdef = make_handler_def(name, params, body, env) + parsed = parse_handler_args(rest(args)) + opts = get(parsed, 'opts') + params = get(parsed, 'params') + body = get(parsed, 'body') + hdef = make_handler_def(name, params, body, env, opts) env[sx_str('handler:', name)] = hdef return hdef diff --git a/shared/sx/types.py b/shared/sx/types.py index 15bd49f..426b004 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -221,18 +221,29 @@ class Island: @dataclass class HandlerDef: - """A declarative fragment handler defined in an .sx file. + """A declarative handler defined in an .sx file. - Created by ``(defhandler name (&key param...) body)``. - The body is evaluated in a sandboxed environment with only - s-expression primitives available. + Created by ``(defhandler name :path "/..." :method :get (&key param...) body)``. + + When ``path`` is set, the handler is registered as a public route. + When ``path`` is None, it's an internal fragment handler (legacy behaviour). """ name: str params: list[str] # keyword parameter names body: Any # unevaluated s-expression body closure: dict[str, Any] = field(default_factory=dict) + path: str | None = None # public route path (None = internal fragment only) + method: str = "get" # HTTP method (get, post, put, patch, delete) + csrf: bool = True # CSRF protection enabled + + @property + def is_route(self) -> bool: + """True if this handler has a public route path.""" + return self.path is not None def __repr__(self): + if self.path: + return f"" return f"" diff --git a/sx/app.py b/sx/app.py index aaee202..bba5790 100644 --- a/sx/app.py +++ b/sx/app.py @@ -103,6 +103,14 @@ def create_app() -> "Quart": bp = register_pages(url_prefix="/") app.register_blueprint(bp) + # Register SX-defined route handlers (defhandler with :path) + from shared.sx.handlers import register_route_handlers + n_routes = register_route_handlers(app, "sx") + if n_routes: + import logging + logging.getLogger("sx.handlers").info( + "Registered %d route handler(s) for sx", n_routes) + from shared.sx.pages import auto_mount_pages auto_mount_pages(app, "sx") diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index e295d96..e52fc25 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -710,7 +710,15 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') # ------------------------------------------------------------------ - # Reference attribute detail API endpoints (for live demos) + # Reference API endpoints — remaining Python-only + # + # Most reference endpoints migrated to sx/sx/handlers/ref-api.sx. + # These remain because they need Python-specific features: + # - File upload access (request.files) + # - Dynamic all-params iteration + # - Stateful counters with non-200 responses + # - SSE streaming + # - Custom response headers # ------------------------------------------------------------------ def _ref_wire(wire_id: str, sx_src: str) -> str: @@ -718,105 +726,6 @@ def register(url_prefix: str = "/") -> Blueprint: from sxc.pages.renders import _oob_code return _oob_code(f"ref-wire-{wire_id}", sx_src) - @bp.get("/geography/hypermedia/reference/api/time") - async def ref_time(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(span :class "text-stone-800 text-sm" "Server time: " (strong "{now}"))' - oob = _ref_wire("sx-get", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.post("/geography/hypermedia/reference/api/greet") - async def ref_greet(): - from shared.sx.helpers import sx_response - form = await request.form - name = form.get("name") or "stranger" - sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")' - oob = _ref_wire("sx-post", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.put("/geography/hypermedia/reference/api/status") - async def ref_status(): - from shared.sx.helpers import sx_response - form = await request.form - status = form.get("status", "unknown") - sx_src = f'(span :class "text-stone-700 text-sm" "Status: " (strong "{status}") " — updated via PUT")' - oob = _ref_wire("sx-put", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.patch("/geography/hypermedia/reference/api/theme") - async def ref_theme(): - from shared.sx.helpers import sx_response - form = await request.form - theme = form.get("theme", "unknown") - sx_src = f'"{theme}"' - oob = _ref_wire("sx-patch", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @csrf_exempt - @bp.delete("/geography/hypermedia/reference/api/item/") - async def ref_delete(item_id: str): - from shared.sx.helpers import sx_response - oob = _ref_wire("sx-delete", '""') - return sx_response(f'(<> {oob})') - - @bp.get("/geography/hypermedia/reference/api/trigger-search") - async def ref_trigger_search(): - from shared.sx.helpers import sx_response - q = request.args.get("q", "") - if not q: - sx_src = '(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")' - else: - sx_src = f'(span :class "text-stone-800 text-sm" "Results for: " (strong "{q}"))' - oob = _ref_wire("sx-trigger", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/swap-item") - async def ref_swap_item(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(div :class "text-sm text-violet-700" "New item (" "{now}" ")")' - oob = _ref_wire("sx-swap", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/oob") - async def ref_oob(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(<>' - f' (span :class "text-emerald-700 text-sm" "Main updated at " "{now}")' - f' (div :id "ref-oob-side" :sx-swap-oob "innerHTML"' - f' (span :class "text-violet-700 text-sm" "OOB updated at " "{now}")))') - oob = _ref_wire("sx-swap-oob", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/select-page") - async def ref_select_page(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = ( - f'(<>' - f' (div :id "the-header" (h3 "Page header — not selected"))' - f' (div :id "the-content"' - f' (span :class "text-emerald-700 text-sm"' - f' "This fragment was selected from a larger response. Time: " "{now}"))' - f' (div :id "the-footer" (p "Page footer — not selected")))') - oob = _ref_wire("sx-select", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/slow-echo") - async def ref_slow_echo(): - from shared.sx.helpers import sx_response - await asyncio.sleep(0.8) - q = request.args.get("q", "") - sx_src = f'(span :class "text-stone-800 text-sm" "Echo: " (strong "{q}"))' - oob = _ref_wire("sx-sync", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - @csrf_exempt @bp.post("/geography/hypermedia/reference/api/upload-name") async def ref_upload_name(): @@ -882,14 +791,6 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-retry", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/geography/hypermedia/reference/api/prompt-echo") - async def ref_prompt_echo(): - from shared.sx.helpers import sx_response - name = request.headers.get("SX-Prompt", "anonymous") - sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")' - oob = _ref_wire("sx-prompt", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/geography/hypermedia/reference/api/sse-time") async def ref_sse_time(): async def generate(): @@ -1049,10 +950,4 @@ def register(url_prefix: str = "/") -> Blueprint: resp.headers["SX-Retarget"] = "#ref-hdr-retarget-alt" return resp - # --- Event demos --- - - @bp.get("/geography/hypermedia/reference/api/error-500") - async def ref_error_500(): - return Response("Server error", status=500, content_type="text/plain") - return bp diff --git a/sx/sx/essays/the-art-chain.sx b/sx/sx/essays/the-art-chain.sx new file mode 100644 index 0000000..ca719da --- /dev/null +++ b/sx/sx/essays/the-art-chain.sx @@ -0,0 +1,73 @@ +;; --------------------------------------------------------------------------- +;; The Art Chain +;; --------------------------------------------------------------------------- + +(defcomp ~essay-the-art-chain () + (~doc-page :title "The Art Chain" + (p :class "text-stone-500 text-sm italic mb-8" + "On making, self-making, and the chain of artifacts that produces itself.") + + (~doc-section :title "I. Ars" :id "ars" + (p :class "text-stone-600" + "The Latin word " (em "ars") " means something made with skill. Not art as in paintings on gallery walls. Art as in " (em "artifice") ", " (em "artifact") ", " (em "artisan") ". The made thing. The Greek " (em "techne") " is the same word — craft, skill, the knowledge of how to make. There was no distinction between art and engineering because there was no distinction to make.") + (p :class "text-stone-600" + "A bridge is " (em "ars") ". A poem is " (em "ars") ". A proof is " (em "ars") ". What makes something art is not its medium or its audience but the fact that it was " (em "made") " — brought into being by someone who knew how to bring it into being. The maker's knowledge is embedded in the made thing. You can read the knowledge back out by studying what was made.") + (p :class "text-stone-600" + "Software is " (em "ars") ". Obviously. It is the most " (em "ars") " thing we have ever built — pure made-ness, structure conjured from nothing, shaped entirely by the maker's skill and intent. There is no raw material. No marble to chisel, no pigment to mix. Just thought, made concrete in symbols.")) + + (~doc-section :title "II. The spec at the centre" :id "spec" + (p :class "text-stone-600" + "SX has a peculiar architecture. At its centre sits a specification — a set of s-expression files that define the language. Not a description of the language. Not documentation " (em "about") " the language. The specification " (em "is") " the language. It is simultaneously a formal definition and executable code. You can read it as a document or run it as a program. It does not describe how to build an SX evaluator; it " (em "is") " an SX evaluator, expressed in the language it defines.") + (p :class "text-stone-600" + "This is the nucleus. Everything else radiates outward from it.") + (~doc-code :code (highlight ";; The spec defines eval-expr\n;; eval-expr evaluates the spec\n;; The spec is an artifact that makes itself\n\n(define eval-expr\n (fn (expr env)\n (cond\n (number? expr) expr\n (string? expr) expr\n (symbol? expr) (env-get env (symbol-name expr))\n (list? expr) (eval-list expr env)\n :else expr)))" "lisp")) + (p :class "text-stone-600" + "From this nucleus, concentric rings unfurl:")) + + (~doc-section :title "III. The rings" :id "rings" + (p :class "text-stone-600" + "The first ring is the " (strong "bootstrapper") ". It reads the spec and emits a native implementation — JavaScript, Python, or any other target. The bootstrapper is a translator: it takes the made thing (the spec) and makes another thing (an implementation) that behaves identically. The spec's knowledge is preserved in the translation. Nothing is added, nothing is lost.") + (p :class "text-stone-600" + "The second ring is the " (strong "platform bridge") ". The spec defines pure logic — evaluation, rendering, parsing. But a running system needs to touch the world: read files, make HTTP requests, manipulate DOM nodes. The platform bridge provides these capabilities. It is the boundary between the made world (the spec) and the found world (the host environment). " (code "boundary.sx") " is literally the membrane — it declares what the host must provide so the spec can function.") + (p :class "text-stone-600" + "The third ring is the " (strong "runtime") " — bootstrapped spec plus platform bridge, assembled into a working system. This is where the spec stops being an idea and starts being a process. It evaluates expressions. It renders pages. It handles requests.") + (p :class "text-stone-600" + "The fourth ring is " (strong "application code") " — components, pages, layouts, written in the language the spec defined. Every " (code "defcomp") " is an artifact made from the tools the spec provided. Every " (code "(div :class \"card\" (p \"hello\"))") " is the spec expressing itself through a developer's intent.") + (p :class "text-stone-600" + "The fifth ring is " (strong "this website") " — which renders the spec's source code using the runtime the spec produced, displayed in components written in the language the spec defines, navigated by an engine the spec specifies. The documentation is the thing documenting itself.")) + + (~doc-section :title "IV. The chain" :id "chain" + (p :class "text-stone-600" + "Each ring is an artifact — a made thing. And each artifact is made " (em "by") " the artifact inside it. The spec makes the bootstrapper's output. The runtime makes the application's output. The application makes the page the user sees. It is a chain of making.") + (p :class "text-stone-600" + "This chain has three properties that are individually common but collectively rare:") + (p :class "text-stone-600" + (strong "Content addressing.") " Each artifact can be identified by the hash of its content. The spec at a given version has a specific hash. The bootstrapped output from that spec has a deterministic hash. A component definition has a hash. Identity " (em "is") " content. You don't ask " (em "where") " an artifact lives — you ask " (em "what") " it is.") + (p :class "text-stone-600" + (strong "Deterministic derivation.") " Given the same spec, the bootstrapper produces the same output. Byte for byte. This is not aspirational — it is verified. The self-hosting bootstrapper (py.sx) proves it: G0 (hand-written bootstrapper) and G1 (self-hosted bootstrapper) produce identical output. The derivation is a pure function. Anyone can run it and verify the result.") + (p :class "text-stone-600" + (strong "Self-verification.") " The spec includes tools that can prove properties about the spec. " (code "prove.sx") " checks primitive semantics. " (code "types.sx") " validates composition. " (code "z3.sx") " translates declarations into verification conditions. These tools are themselves part of the spec, subject to the same verification they perform. The chain can verify itself.") + (p :class "text-stone-600" + "These three properties together — content addressing, deterministic derivation, self-verification — are what a blockchain provides. But here there is no proof-of-work, no tokens, no artificial scarcity, no consensus mechanism between untrusted parties. The \"mining\" is bootstrapping. The \"consensus\" is mathematical proof. The \"value\" is that anyone can take the spec, derive an implementation, and " (em "know") " it is correct.")) + + (~doc-section :title "V. Universal analysis" :id "analysis" + (p :class "text-stone-600" + "Here is the consequence that takes time to absorb: any tool that can analyse the spec can analyse " (em "everything the spec produces") ".") + (p :class "text-stone-600" + "A type checker written in SX that validates the spec's primitives also validates every call to those primitives in every component in every application. A dependency analyser that walks the spec's AST walks application ASTs identically — because application code is expressed in the same structures the spec defines. A theorem prover that verifies the spec's properties verifies the properties of everything downstream.") + (p :class "text-stone-600" + "This is because the rings are not separate systems. They are the " (em "same") " system at different scales. Application code is spec-shaped. Bootstrapped output is spec-derived. Components are spec-evaluated. The analysis surface is uniform from the nucleus to the outermost ring.") + (p :class "text-stone-600" + "And the analysis tools are " (em "inside") " the chain. They are artifacts too, written in SX, subject to the same analysis they perform. The type checker can type-check itself. The prover can prove properties about itself. This is not a bug or a curiosity — it is the point. A system that cannot reason about itself is a system that must be reasoned about from outside, by tools written in other languages, maintained by other processes, trusted for other reasons. A self-analysing system closes the loop.")) + + (~doc-section :title "VI. The art in the chain" :id "art" + (p :class "text-stone-600" + "So what is the art chain? It is a chain of artifacts — made things — where each link produces the next, the whole chain can verify itself, and the chain's identity is its content.") + (p :class "text-stone-600" + "It is not a blockchain in the financial sense. It is not a distributed ledger, a currency, a market. It borrows the structural properties — content addressing, determinism, verification — without the economic machinery. What remains when you strip the economics from a blockchain is a " (em "provenance chain") ": a record of how each thing was made from the thing before it, verifiable by anyone, depending on nothing but the mathematics.") + (p :class "text-stone-600" + "The Art DAG has the right name. It is not a system for processing \"art\" in the colloquial sense — images, videos, media. It is a " (em "directed acyclic graph of made things") ". Each node is an artifact. Each edge is a derivation. The graph is content-addressed. Execution is deterministic. The DAG itself is the art.") + (p :class "text-stone-600" + "And the whole SX system — spec, bootstrappers, runtimes, components, pages, this essay explaining itself — is one continuous act of making. " (em "Ars") " all the way down. Not because it is beautiful (though it sometimes is) or expressive (though it tries to be) but because it is " (em "made") ". Deliberately, skilfully, from nothing, by someone who knew how.") + (p :class "text-stone-600" + "That is what " (em "techne") " always was. We just forgot.")))) diff --git a/sx/sx/handlers/ref-api.sx b/sx/sx/handlers/ref-api.sx new file mode 100644 index 0000000..afeb5a4 --- /dev/null +++ b/sx/sx/handlers/ref-api.sx @@ -0,0 +1,156 @@ +;; Reference API endpoints — live demos for hypermedia attribute docs +;; +;; These replace the Python endpoints in bp/pages/routes.py. +;; Each defhandler with :path registers as a public route automatically. + +;; --- sx-get demo: server time --- + +(defhandler ref-time + :path "/geography/hypermedia/reference/api/time" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (span :class "text-stone-800 text-sm" "Server time: " (strong now)) + (~doc-oob-code :target-id "ref-wire-sx-get" + :text (str "(span :class \"text-stone-800 text-sm\" \"Server time: \" (strong \"" now "\"))"))))) + +;; --- sx-post demo: greet --- + +(defhandler ref-greet + :path "/geography/hypermedia/reference/api/greet" + :method :post + :csrf false + (&key) + (let ((name (request-form "name" "stranger"))) + (<> + (span :class "text-stone-800 text-sm" "Hello, " (strong name) "!") + (~doc-oob-code :target-id "ref-wire-sx-post" + :text (str "(span :class \"text-stone-800 text-sm\" \"Hello, \" (strong \"" name "\") \"!\")"))))) + +;; --- sx-put demo: status update --- + +(defhandler ref-status + :path "/geography/hypermedia/reference/api/status" + :method :put + :csrf false + (&key) + (let ((status (request-form "status" "unknown"))) + (<> + (span :class "text-stone-700 text-sm" "Status: " (strong status) " — updated via PUT") + (~doc-oob-code :target-id "ref-wire-sx-put" + :text (str "(span :class \"text-stone-700 text-sm\" \"Status: \" (strong \"" status "\") \" — updated via PUT\")"))))) + +;; --- sx-patch demo: theme --- + +(defhandler ref-theme + :path "/geography/hypermedia/reference/api/theme" + :method :patch + :csrf false + (&key) + (let ((theme (request-form "theme" "unknown"))) + (<> + theme + (~doc-oob-code :target-id "ref-wire-sx-patch" + :text (str "\"" theme "\""))))) + +;; --- sx-delete demo --- + +(defhandler ref-delete-item + :path "/geography/hypermedia/reference/api/item/" + :method :delete + :csrf false + (&key) + (<> + (~doc-oob-code :target-id "ref-wire-sx-delete" :text "\"\""))) + +;; --- sx-trigger demo: search --- + +(defhandler ref-trigger-search + :path "/geography/hypermedia/reference/api/trigger-search" + :method :get + (&key) + (let ((q (request-arg "q" ""))) + (let ((sx-text (if (= q "") + "(span :class \"text-stone-400 text-sm\" \"Start typing to trigger a search.\")" + (str "(span :class \"text-stone-800 text-sm\" \"Results for: \" (strong \"" q "\"))")))) + (<> + (if (= q "") + (span :class "text-stone-400 text-sm" "Start typing to trigger a search.") + (span :class "text-stone-800 text-sm" "Results for: " (strong q))) + (~doc-oob-code :target-id "ref-wire-sx-trigger" :text sx-text))))) + +;; --- sx-swap demo --- + +(defhandler ref-swap-item + :path "/geography/hypermedia/reference/api/swap-item" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (div :class "text-sm text-violet-700" (str "New item (" now ")")) + (~doc-oob-code :target-id "ref-wire-sx-swap" + :text (str "(div :class \"text-sm text-violet-700\" \"New item (" now ")\")"))))) + +;; --- sx-swap-oob demo --- + +(defhandler ref-oob + :path "/geography/hypermedia/reference/api/oob" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (span :class "text-emerald-700 text-sm" "Main updated at " now) + (div :id "ref-oob-side" :sx-swap-oob "innerHTML" + (span :class "text-violet-700 text-sm" "OOB updated at " now)) + (~doc-oob-code :target-id "ref-wire-sx-swap-oob" + :text (str "(<> (span ... \"" now "\") (div :id \"ref-oob-side\" :sx-swap-oob \"innerHTML\" ...))"))))) + +;; --- sx-select demo --- + +(defhandler ref-select-page + :path "/geography/hypermedia/reference/api/select-page" + :method :get + (&key) + (let ((now (now "%H:%M:%S"))) + (<> + (div :id "the-header" (h3 "Page header — not selected")) + (div :id "the-content" + (span :class "text-emerald-700 text-sm" + "This fragment was selected from a larger response. Time: " now)) + (div :id "the-footer" (p "Page footer — not selected")) + (~doc-oob-code :target-id "ref-wire-sx-select" + :text (str "(<> (div :id \"the-header\" ...) (div :id \"the-content\" ... \"" now "\") (div :id \"the-footer\" ...))"))))) + +;; --- sx-sync demo: slow echo --- + +(defhandler ref-slow-echo + :path "/geography/hypermedia/reference/api/slow-echo" + :method :get + (&key) + (let ((q (request-arg "q" ""))) + (sleep 800) + (<> + (span :class "text-stone-800 text-sm" "Echo: " (strong q)) + (~doc-oob-code :target-id "ref-wire-sx-sync" + :text (str "(span :class \"text-stone-800 text-sm\" \"Echo: \" (strong \"" q "\"))"))))) + +;; --- sx-prompt demo --- + +(defhandler ref-prompt-echo + :path "/geography/hypermedia/reference/api/prompt-echo" + :method :get + (&key) + (let ((name (request-header "SX-Prompt" "anonymous"))) + (<> + (span :class "text-stone-800 text-sm" "Hello, " (strong name) "!") + (~doc-oob-code :target-id "ref-wire-sx-prompt" + :text (str "(span :class \"text-stone-800 text-sm\" \"Hello, \" (strong \"" name "\") \"!\")"))))) + +;; --- Error demo --- + +(defhandler ref-error-500 + :path "/geography/hypermedia/reference/api/error-500" + :method :get + (&key) + (abort 500 "Server error")) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index b3a9d37..b209d12 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -95,7 +95,9 @@ (dict :label "React is Hypermedia" :href "/etc/essays/react-is-hypermedia" :summary "A React Island is a hypermedia control. Its behavior is specified in SX.") (dict :label "The Hegelian Synthesis" :href "/etc/essays/hegelian-synthesis" - :summary "On the dialectical resolution of the hypertext/reactive contradiction. Thesis: the server renders. Antithesis: the client reacts. Synthesis: the island in the lake."))) + :summary "On the dialectical resolution of the hypertext/reactive contradiction. Thesis: the server renders. Antithesis: the client reacts. Synthesis: the island in the lake.") + (dict :label "The Art Chain" :href "/etc/essays/the-art-chain" + :summary "On making, self-making, and the chain of artifacts that produces itself. Ars, techne, content addressing, and why the spec is the art."))) (define philosophy-nav-items (list (dict :label "The SX Manifesto" :href "/etc/philosophy/sx-manifesto" @@ -110,25 +112,33 @@ :summary "Existence precedes essence — Sartre, Camus, and the absurd freedom of writing a Lisp for the web."))) (define specs-nav-items (list - (dict :label "Architecture" :href "/language/specs/") - (dict :label "Core" :href "/language/specs/core") - (dict :label "Parser" :href "/language/specs/parser") - (dict :label "Evaluator" :href "/language/specs/evaluator") - (dict :label "Primitives" :href "/language/specs/primitives") - (dict :label "Special Forms" :href "/language/specs/special-forms") - (dict :label "Renderer" :href "/language/specs/renderer") - (dict :label "Adapters" :href "/language/specs/adapters") - (dict :label "DOM Adapter" :href "/language/specs/adapter-dom") - (dict :label "HTML Adapter" :href "/language/specs/adapter-html") - (dict :label "SX Wire Adapter" :href "/language/specs/adapter-sx") - (dict :label "Browser" :href "/language/specs/browser") - (dict :label "SxEngine" :href "/language/specs/engine") - (dict :label "Orchestration" :href "/language/specs/orchestration") - (dict :label "Boot" :href "/language/specs/boot") - (dict :label "Continuations" :href "/language/specs/continuations") - (dict :label "call/cc" :href "/language/specs/callcc") - (dict :label "Deps" :href "/language/specs/deps") - (dict :label "Router" :href "/language/specs/router"))) + {:label "Core" :href "/language/specs/core" :children (list + {:label "Parser" :href "/language/specs/parser"} + {:label "Evaluator" :href "/language/specs/evaluator"} + {:label "Primitives" :href "/language/specs/primitives"} + {:label "Special Forms" :href "/language/specs/special-forms"} + {:label "Renderer" :href "/language/specs/renderer"})} + {:label "Adapters" :href "/language/specs/adapters" :children (list + {:label "DOM Adapter" :href "/language/specs/adapter-dom"} + {:label "HTML Adapter" :href "/language/specs/adapter-html"} + {:label "SX Wire Adapter" :href "/language/specs/adapter-sx"} + {:label "Async Adapter" :href "/language/specs/adapter-async"})} + {:label "Browser" :href "/language/specs/browser" :children (list + {:label "SxEngine" :href "/language/specs/engine"} + {:label "Orchestration" :href "/language/specs/orchestration"} + {:label "Boot" :href "/language/specs/boot"} + {:label "Router" :href "/language/specs/router"})} + {:label "Reactive" :href "/language/specs/reactive" :children (list + {:label "Signals" :href "/language/specs/signals"})} + {:label "Host Interface" :href "/language/specs/host" :children (list + {:label "Boundary" :href "/language/specs/boundary"} + {:label "Forms" :href "/language/specs/forms"} + {:label "Page Helpers" :href "/language/specs/page-helpers"})} + {:label "Extensions" :href "/language/specs/extensions" :children (list + {:label "Continuations" :href "/language/specs/continuations"} + {:label "call/cc" :href "/language/specs/callcc"} + {:label "Types" :href "/language/specs/types"} + {:label "Deps" :href "/language/specs/deps"})})) (define testing-nav-items (list (dict :label "Overview" :href "/language/testing/") @@ -260,17 +270,39 @@ (dict :slug "adapter-sx" :filename "adapter-sx.sx" :title "SX Wire Adapter" :desc "Serializes SX for client-side rendering. Component calls stay unexpanded." :prose "The SX wire adapter serializes expressions as SX source text for transmission to the browser, where sx.js renders them client-side. Unlike the HTML adapter, component calls (~name ...) are NOT expanded — they are sent to the client as-is, allowing the browser to render them with its local component registry. HTML tags ARE serialized as s-expression source. This is the format used for SX-over-HTTP responses and the page boot payload.") + (dict :slug "adapter-async" :filename "adapter-async.sx" :title "Async Adapter" + :desc "Async versions of HTML and SX wire adapters for server-side rendering with I/O." + :prose "The async adapter provides async-aware versions of the HTML and SX wire rendering functions. It intercepts I/O operations (database queries, service calls, fragment fetches) during evaluation, awaiting them before continuing. Entry points: async-render (HTML output with awaited I/O), async-aser (SX wire format with awaited I/O). The bootstrapper emits async def and automatic await insertion for all define-async functions. This adapter is what makes server-side SX pages work with real data."))) + +(define browser-spec-items (list (dict :slug "engine" :filename "engine.sx" :title "SxEngine" :desc "Pure logic for fetch, swap, history, SSE, triggers, morph, and indicators." :prose "The engine specifies the pure logic of the browser-side fetch/swap/history system. Like HTMX but native to SX. It defines trigger parsing (click, submit, intersect, poll, load, revealed), swap algorithms (innerHTML, outerHTML, morph, beforebegin, etc.), the morph/diff algorithm for patching existing DOM, history management (push-url, replace-url, popstate), out-of-band swap identification, Server-Sent Events parsing, retry logic with exponential backoff, request header building, response header processing, and optimistic UI updates. This file contains no browser API calls — all platform interaction is in orchestration.sx.") (dict :slug "orchestration" :filename "orchestration.sx" :title "Orchestration" :desc "Browser wiring that binds engine logic to DOM events, fetch, and lifecycle." - :prose "Orchestration is the browser wiring layer. It binds the pure engine logic to actual browser APIs: DOM event listeners, fetch(), AbortController, setTimeout/setInterval, IntersectionObserver, history.pushState, and EventSource (SSE). It implements the full request lifecycle — from trigger through fetch through swap — including CSS tracking, response type detection (SX vs HTML), OOB swap processing, script activation, element boosting, and preload. Dependency is strictly one-way: orchestration depends on engine, never the reverse."))) - -(define browser-spec-items (list - (dict :slug "boot" :filename "boot.sx" :title "Boot" + :prose "Orchestration is the browser wiring layer. It binds the pure engine logic to actual browser APIs: DOM event listeners, fetch(), AbortController, setTimeout/setInterval, IntersectionObserver, history.pushState, and EventSource (SSE). It implements the full request lifecycle — from trigger through fetch through swap — including CSS tracking, response type detection (SX vs HTML), OOB swap processing, script activation, element boosting, and preload. Dependency is strictly one-way: orchestration depends on engine, never the reverse.") + (dict :slug "boot" :filename "boot.sx" :title "Boot" :desc "Browser startup lifecycle: mount, hydrate, script processing." - :prose "Boot handles the browser startup sequence and provides the public API for mounting SX content. On page load it: (1) initializes CSS tracking, (2) processes