The core evaluator (spec/evaluator.sx) is now the irreducible computational core with zero web, rendering, or type-system knowledge. 2531 → 2313 lines. - Add extensible special form registry (*custom-special-forms* + register-special-form!) - Add render dispatch hooks (*render-check* / *render-fn*) replacing hardcoded render-active?/is-render-expr?/render-expr - Extract freeze scopes → spec/freeze.sx (library, not core) - Extract content addressing → spec/content.sx (library, not core) - Move sf-deftype/sf-defeffect → spec/types.sx (self-registering) - Move sf-defstyle → web/forms.sx (self-registering with all web forms) - Move web tests (defpage, streaming) → web/tests/test-forms.sx - Add is-else-clause? helper (replaces 5 inline patterns) - Make escape-html/escape-attr library functions in render.sx (pure SX, not platform-provided) - Add foundations plan: Step 3.5 (data representations), Step 3.7 (verified components), OCaml for Step 4d - Update all three bootstrappers (JS 957/957, Python 744/744, OCaml 952/952) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
Plaintext
302 lines
12 KiB
Plaintext
;; ==========================================================================
|
|
;; forms.sx — Web-platform definition forms
|
|
;;
|
|
;; Platform-specific special forms for declaring styles, handlers, pages,
|
|
;; queries, and actions. These are NOT part of the core evaluator — they
|
|
;; register themselves via register-special-form! at load time.
|
|
;;
|
|
;; 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-bind! 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-bind! 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-bind! 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-bind! 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-bind! 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))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; defstyle — bind name to evaluated style expression
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define sf-defstyle
|
|
(fn ((args :as list) (env :as dict))
|
|
(let ((name-sym (first args))
|
|
(value (trampoline (eval-expr (nth args 1) env))))
|
|
(env-bind! env (symbol-name name-sym) value)
|
|
value)))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Registration — make these available as special forms in the evaluator
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(register-special-form! "defstyle" sf-defstyle)
|
|
(register-special-form! "defhandler" sf-defhandler)
|
|
(register-special-form! "defpage" sf-defpage)
|
|
(register-special-form! "defquery" sf-defquery)
|
|
(register-special-form! "defaction" sf-defaction)
|