Files
rose-ash/web/forms.sx
giles 1a3d7b3d77
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m49s
Separate core spec from web framework
Three-layer architecture:
  spec/  — Core language (19 files): evaluator, parser, primitives,
           CEK machine, types, continuations. Host-independent.
  web/   — Web framework (20 files): signals, adapters, engine,
           orchestration, boot, router, CSSX. Built on core spec.
  sx/    — Application (sx-docs website). Built on web framework.

Split boundary.sx into boundary-core.sx (type-of, make-env, identical?)
and boundary-web.sx (IO primitives, signals, spreads, page helpers).

Bootstrappers search spec/ → web/ → shared/sx/ref/ for .sx files.
Original files remain in shared/sx/ref/ as fallback during transition.
All 63 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 01:42:19 +00:00

279 lines
11 KiB
Plaintext

;; ==========================================================================
;; forms.sx — Server-side definition forms
;;
;; Platform-specific special forms for declaring handlers, pages, queries,
;; and actions. These parse &key parameter lists and create typed definition
;; objects that the server runtime uses for routing and execution.
;;
;; When SX moves to isomorphic execution, these forms will have different
;; platform bindings on client vs server. The spec stays the same — only
;; the constructors (make-handler-def, make-query-def, etc.) change.
;;
;; Platform functions required:
;; make-handler-def(name, params, body, env) → HandlerDef
;; make-query-def(name, params, doc, body, env) → QueryDef
;; make-action-def(name, params, doc, body, env) → ActionDef
;; make-page-def(name, slots, env) → PageDef
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Shared: parse (&key param1 param2 ...) → list of param name strings
;; --------------------------------------------------------------------------
(define parse-key-params
(fn ((params-expr :as list))
(let ((params (list))
(in-key false))
(for-each
(fn (p)
(when (= (type-of p) "symbol")
(let ((name (symbol-name p)))
(cond
(= name "&key") (set! in-key true)
in-key (append! params name)
:else (append! params name)))))
params-expr)
params)))
;; --------------------------------------------------------------------------
;; defhandler — (defhandler name [:path "..." :method :get :csrf false :returns "element"] (&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.
;; :returns — return type annotation (types.sx vocabulary). Default "element".
;; --------------------------------------------------------------------------
(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))
(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))))
;; --------------------------------------------------------------------------
;; defquery — (defquery name (&key param...) "docstring" body)
;; --------------------------------------------------------------------------
(define sf-defquery
(fn ((args :as list) (env :as dict))
(let ((name-sym (first args))
(params-raw (nth args 1))
(name (symbol-name name-sym))
(params (parse-key-params params-raw))
;; Optional docstring before body
(has-doc (and (>= (len args) 4) (= (type-of (nth args 2)) "string")))
(doc (if has-doc (nth args 2) ""))
(body (if has-doc (nth args 3) (nth args 2))))
(let ((qdef (make-query-def name params doc body env)))
(env-set! env (str "query:" name) qdef)
qdef))))
;; --------------------------------------------------------------------------
;; defaction — (defaction name (&key param...) "docstring" body)
;; --------------------------------------------------------------------------
(define sf-defaction
(fn ((args :as list) (env :as dict))
(let ((name-sym (first args))
(params-raw (nth args 1))
(name (symbol-name name-sym))
(params (parse-key-params params-raw))
(has-doc (and (>= (len args) 4) (= (type-of (nth args 2)) "string")))
(doc (if has-doc (nth args 2) ""))
(body (if has-doc (nth args 3) (nth args 2))))
(let ((adef (make-action-def name params doc body env)))
(env-set! env (str "action:" name) adef)
adef))))
;; --------------------------------------------------------------------------
;; defpage — (defpage name :path "/..." :auth :public :content expr ...)
;;
;; Keyword-slot form: all values after the name are :key value pairs.
;; Values are stored as unevaluated AST — resolved at request time.
;; --------------------------------------------------------------------------
(define sf-defpage
(fn ((args :as list) (env :as dict))
(let ((name-sym (first args))
(name (symbol-name name-sym))
(slots {}))
;; Parse keyword slots from remaining args
(let ((i 1)
(max-i (len args)))
(for-each
(fn ((idx :as number))
(when (and (< idx max-i)
(= (type-of (nth args idx)) "keyword"))
(when (< (+ idx 1) max-i)
(dict-set! slots (keyword-name (nth args idx))
(nth args (+ idx 1))))))
(range 1 max-i 2)))
(let ((pdef (make-page-def name slots env)))
(env-set! env (str "page:" name) pdef)
pdef))))
;; ==========================================================================
;; Page Execution Semantics
;; ==========================================================================
;;
;; A PageDef describes what to render for a route. The host evaluates slots
;; at request time. This section specifies the data → content protocol that
;; every host must implement identically.
;;
;; Slots (all unevaluated AST):
;; :path — route pattern (string)
;; :auth — "public" | "login" | "admin"
;; :layout — layout reference + kwargs
;; :stream — boolean, opt into chunked transfer
;; :shell — immediate content (contains ~suspense placeholders)
;; :fallback — loading skeleton for single-stream mode
;; :data — IO expression producing bindings
;; :content — template expression evaluated with data bindings
;; :filter, :aside, :menu — additional content slots
;;
;; --------------------------------------------------------------------------
;; Data Protocol
;; --------------------------------------------------------------------------
;;
;; The :data expression is evaluated at request time. It returns one of:
;;
;; 1. A dict — single-stream mode (default).
;; Each key becomes an env binding (underscores → hyphens).
;; Then :content is evaluated once with those bindings.
;; Result resolves the "stream-content" suspense slot.
;;
;; 2. A sequence of dicts — multi-stream mode.
;; The host delivers items over time (async generator, channel, etc.).
;; Each dict:
;; - MUST contain "stream-id" → string matching a ~suspense :id
;; - Remaining keys become env bindings (underscores → hyphens)
;; - :content is re-evaluated with those bindings
;; - Result resolves the ~suspense slot matching "stream-id"
;; If "stream-id" is absent, defaults to "stream-content".
;;
;; The host is free to choose the timing mechanism:
;; Python — async generator (yield dicts at intervals)
;; Go — channel of dicts
;; Haskell — conduit / streaming
;; JS — async iterator
;;
;; The spec requires:
;; (a) Each item's bindings are isolated (fresh env per item)
;; (b) :content is evaluated independently for each item
;; (c) Resolution is incremental — each item resolves as it arrives
;; (d) "stream-id" routes to the correct ~suspense slot
;;
;; --------------------------------------------------------------------------
;; Streaming Execution Order
;; --------------------------------------------------------------------------
;;
;; When :stream is true:
;;
;; 1. Evaluate :shell (if present) → HTML for immediate content slot
;; :shell typically contains ~suspense placeholders with :fallback
;; 2. Render HTML shell with suspense placeholders → send to client
;; 3. Start :data evaluation concurrently with header resolution
;; 4. As each data item arrives:
;; a. Bind item keys into fresh env
;; b. Evaluate :content with those bindings → SX wire format
;; c. Send resolve script: __sxResolve(stream-id, sx)
;; 5. Close response when all items + headers have resolved
;;
;; Non-streaming pages evaluate :data then :content sequentially and
;; return the complete page in a single response.
;;
;; --------------------------------------------------------------------------
;; Spec helpers for multi-stream data protocol
;; --------------------------------------------------------------------------
;; Extract stream-id from a data chunk dict, defaulting to "stream-content"
(define stream-chunk-id
(fn ((chunk :as dict))
(if (has-key? chunk "stream-id")
(get chunk "stream-id")
"stream-content")))
;; Remove stream-id from chunk, returning only the bindings
(define stream-chunk-bindings
(fn ((chunk :as dict))
(dissoc chunk "stream-id")))
;; Normalize binding keys: underscore → hyphen
(define normalize-binding-key
(fn ((key :as string))
(replace key "_" "-")))
;; Bind a data chunk's keys into a fresh env (isolated per chunk)
(define bind-stream-chunk
(fn ((chunk :as dict) (base-env :as dict))
(let ((env (merge {} base-env))
(bindings (stream-chunk-bindings chunk)))
(for-each
(fn ((key :as string))
(env-set! env (normalize-binding-key key)
(get bindings key)))
(keys bindings))
env)))
;; Validate a multi-stream data result: must be a list of dicts
(define validate-stream-data
(fn (data)
(and (= (type-of data) "list")
(every? (fn (item) (= (type-of item) "dict")) data))))