diff --git a/applications/sxtp/spec.sx b/applications/sxtp/spec.sx index afa8938e..78e5f6cc 100644 --- a/applications/sxtp/spec.sx +++ b/applications/sxtp/spec.sx @@ -97,6 +97,42 @@ (:body "Any SX value — event payload (optional)") (:time "Number — unix timestamp (optional)")))) +;; ── patch (DOM fragment patch — borrowed from Datastar) ─────────── +;; A server-driven instruction to morph a region of the client DOM. +;; Subsumes HTMX swap modes; the :body is an SX subtree that the client +;; renders to DOM nodes before applying the mode at the target. +(define + patch-fields + (quote + ((:target "String — CSS selector for the element to patch (required)") + (:mode "Symbol — patch mode (optional, default outer)") + (:body "SX tree — the new content (omitted for mode remove)") + (:transition "Boolean — use a view transition (optional, default false)")))) + +(define + patch-modes + (quote + ((outer "Replace the target's outerHTML (default; the morph target)") + (inner "Replace the target's innerHTML, preserving the wrapper") + (replace "Hard-replace without morphing (no diff, plain swap)") + (prepend "Insert the body as the target's first child") + (append "Insert the body as the target's last child") + (before "Insert the body before the target") + (after "Insert the body after the target") + (remove "Detach the target; :body MUST be absent")))) + +;; ── signals (reactive state patch — borrowed from Datastar) ────── +;; A server-driven update to client-side reactive signals. :values is a +;; dict of signal-name -> new-value; setting a value to nil REMOVES the +;; signal. With :only-if-missing true, existing signals are not touched +;; (use this to lazily initialise signal state without clobbering). +(define + signals-fields + (quote + ((:values "Dict — signal-name -> new-value (required)") + (:only-if-missing + "Boolean — only set signals that don't yet exist (optional, default false)")))) + (define example-navigate (quote @@ -148,6 +184,23 @@ :message "No such post" :retry false))))) +;; A streaming response intermixing patch + signals: the server pushes +;; DOM updates AND signal updates over the same channel. The client +;; dispatches each message by its head symbol; ordering is preserved. +(define + example-patch-stream + (quote + ((request :verb subscribe :path "/cart/live" :capabilities (fetch)) + (response :status ok :stream true) + (signals :values {:cart/count 3 :cart/loading false}) + (patch + :target "#cart-mini" + :mode outer + :body (~cart-mini :count 3 :total 47.50)) + (patch :target "#flash" :mode inner :body (p "Item added.")) + (signals :values {:cart/loading true}) + (patch :target "#cart-loading-spinner" :mode remove)))) + (define example-inspect (quote diff --git a/lib/host/sxtp.sx b/lib/host/sxtp.sx index 8a6a0411..bb651c0b 100644 --- a/lib/host/sxtp.sx +++ b/lib/host/sxtp.sx @@ -4,10 +4,10 @@ ;; at applications/sxtp/spec.sx. ;; ;; Representation: internally a message is a plain dict tagged by :msg ("request" -;; /"response"/"condition"/"event"), with string keys so the keyword==string rule -;; makes construction and access trivial. verb/status/type are stored as SYMBOLS -;; (they ride the wire bare, not quoted). The wire LIST form is produced/consumed -;; only at the serialise/parse boundary: +;; /"response"/"condition"/"event"/"patch"/"signals"), with string keys so the +;; keyword==string rule makes construction and access trivial. verb/status/type/ +;; mode are stored as SYMBOLS (they ride the wire bare, not quoted). The wire +;; LIST form is produced/consumed only at the serialise/parse boundary: ;; sxtp/serialize : msg-dict -> text/sx string ;; sxtp/parse : text/sx string -> msg-dict ;; A Dream HTTP request/response bridges to/from SXTP via sxtp/from-dream and @@ -35,6 +35,21 @@ (fn (etype opts) (merge {:msg "event" :type (sxtp/-sym etype)} opts))) +;; Patch (Datastar-borrowed) — DOM fragment morph. +;; target: CSS selector (required). mode in opts defaults to outer; accepts +;; string OR symbol and is normalised. mode values: outer | inner | replace | +;; prepend | append | before | after | remove. body: SX subtree (omit for remove). +(define sxtp/patch + (fn (target opts) + (let ((mode (or (get opts :mode) "outer"))) + (merge opts {:msg "patch" :target target :mode (sxtp/-sym mode)})))) + +;; Signals (Datastar-borrowed) — reactive state patch. +;; values: dict of signal-name -> new-value (nil removes). only-if-missing: bool. +(define sxtp/signals + (fn (values opts) + (merge {:msg "signals" :values values} opts))) + ;; ── predicates ───────────────────────────────────────────────────── (define sxtp/-is? (fn (m tag) (and (= (type-of m) "dict") (= (get m :msg) tag)))) @@ -42,6 +57,8 @@ (define sxtp/response? (fn (m) (sxtp/-is? m "response"))) (define sxtp/condition? (fn (m) (sxtp/-is? m "condition"))) (define sxtp/event? (fn (m) (sxtp/-is? m "event"))) +(define sxtp/patch? (fn (m) (sxtp/-is? m "patch"))) +(define sxtp/signals? (fn (m) (sxtp/-is? m "signals"))) ;; ── accessors ────────────────────────────────────────────────────── (define sxtp/verb (fn (m) (get m :verb))) @@ -56,6 +73,11 @@ (define sxtp/stream? (fn (m) (= (get m :stream) true))) (define sxtp/cond-type (fn (m) (get m :type))) (define sxtp/cond-message (fn (m) (get m :message))) +(define sxtp/target (fn (m) (get m :target))) +(define sxtp/mode (fn (m) (get m :mode))) +(define sxtp/values (fn (m) (get m :values))) +(define sxtp/only-if-missing? (fn (m) (= (get m :only-if-missing) true))) +(define sxtp/transition? (fn (m) (= (get m :transition) true))) ;; ── status helpers (build responses) ─────────────────────────────── (define sxtp/ok (fn (body) (sxtp/response "ok" {:body body}))) @@ -121,7 +143,9 @@ {:request (list :verb :path :headers :cookies :params :capabilities :body) :response (list :status :headers :set-cookie :body :stream) :condition (list :type :message :path :retry :detail) - :event (list :type :id :body :time)}) + :event (list :type :id :body :time) + :patch (list :target :mode :body :transition) + :signals (list :values :only-if-missing)}) ;; A nested SXTP message (a condition/event in a :body) serialises in its own ;; list form; plain data values go through the serialize primitive. (define sxtp/-emit-value diff --git a/lib/host/tests/sxtp.sx b/lib/host/tests/sxtp.sx index aa4155af..682d6c3d 100644 --- a/lib/host/tests/sxtp.sx +++ b/lib/host/tests/sxtp.sx @@ -24,6 +24,10 @@ (host-sx-test "request not response" (sxtp/response? host-sx-req) false) (host-sx-test "response?" (sxtp/response? host-sx-resp) true) (host-sx-test "condition?" (sxtp/condition? (sxtp/condition "x" {})) true) +(host-sx-test "patch?" (sxtp/patch? (sxtp/patch "#x" {})) true) +(host-sx-test "patch not event" (sxtp/event? (sxtp/patch "#x" {})) false) +(host-sx-test "signals?" (sxtp/signals? (sxtp/signals {:n 3} {})) true) +(host-sx-test "signals not patch" (sxtp/patch? (sxtp/signals {:n 3} {})) false) ;; ── accessors (verb/status are symbols) ──────────────────────────── (host-sx-test "verb" (symbol->string (sxtp/verb host-sx-req)) "navigate") @@ -68,6 +72,68 @@ (contains? (sxtp/serialize host-sx-resp) ":msg") false) +;; ── patch + signals (Datastar-borrowed) ─────────────────────────── +;; Mode defaults to outer; accepts string OR symbol input. +(host-sx-test + "patch default mode is outer symbol" + (symbol->string (sxtp/mode (sxtp/patch "#x" {}))) + "outer") +(host-sx-test + "patch accepts symbol mode" + (symbol->string (sxtp/mode (sxtp/patch "#x" {:mode (string->symbol "inner")}))) + "inner") +(host-sx-test + "patch accepts string mode and normalises" + (symbol->string (sxtp/mode (sxtp/patch "#x" {:mode "append"}))) + "append") +(host-sx-test + "patch target accessor" + (sxtp/target (sxtp/patch "#cart" {})) + "#cart") +(host-sx-test + "patch serialises with target/mode/body in fixed order" + (sxtp/serialize (sxtp/patch "#x" {:body "hi"})) + "(patch :target \"#x\" :mode outer :body \"hi\")") +(host-sx-test + "patch remove mode serialises without :body" + (sxtp/serialize (sxtp/patch "#x" {:mode "remove"})) + "(patch :target \"#x\" :mode remove)") +(host-sx-test + "patch transition? predicate" + (sxtp/transition? (sxtp/patch "#x" {:transition true})) + true) + +(host-sx-test + "signals accessor" + (get (sxtp/values (sxtp/signals {:cart/count 3} {})) :cart/count) + 3) +(host-sx-test + "signals only-if-missing default false" + (sxtp/only-if-missing? (sxtp/signals {:n 1} {})) + false) +(host-sx-test + "signals only-if-missing true round-trips" + (sxtp/only-if-missing? (sxtp/signals {:n 1} {:only-if-missing true})) + true) +(host-sx-test + "signals serialise" + (sxtp/serialize (sxtp/signals {:cart/count 3} {})) + "(signals :values {:cart/count 3})") + +;; ── round-trip ──────────────────────────────────────────────────── +(define host-sx-patch-rt + (sxtp/parse (sxtp/serialize (sxtp/patch "#mini" {:mode "inner" :body "n=3"})))) +(host-sx-test "patch rt msg" (sxtp/patch? host-sx-patch-rt) true) +(host-sx-test "patch rt target" (sxtp/target host-sx-patch-rt) "#mini") +(host-sx-test "patch rt mode" (symbol->string (sxtp/mode host-sx-patch-rt)) "inner") +(define host-sx-signals-rt + (sxtp/parse (sxtp/serialize (sxtp/signals {:a 1 :b "x"} {:only-if-missing true})))) +(host-sx-test "signals rt msg" (sxtp/signals? host-sx-signals-rt) true) +(host-sx-test "signals rt values" + (get (sxtp/values host-sx-signals-rt) :a) 1) +(host-sx-test "signals rt only-if-missing" + (sxtp/only-if-missing? host-sx-signals-rt) true) + ;; ── parse + round-trip ───────────────────────────────────────────── (define host-sx-parsed (sxtp/parse "(request :verb query :path \"/events\" :headers {:host \"h\"})"))