sxtp: patch + signals primitives (Datastar-borrowed)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Adds two new top-level SXTP message types alongside
request/response/condition/event, modelled on Datastar's
datastar-patch-elements and datastar-patch-signals SSE events:
(patch :target "#x" :mode outer :body (~card)) - DOM fragment
morph. Subsumes HTMX swap modes. Mode is outer (default) |
inner | replace | prepend | append | before | after | remove.
(signals :values {:n 3} :only-if-missing false) - reactive
state patch. nil value removes the signal. only-if-missing
skips existing signals (lazy init).
A server response stream can mix both freely; clients dispatch
by head symbol, ordering preserved. Cleaner than HTMX's
swap-mode-per-trigger because the patch shape is decoupled from
the triggering element/attribute.
Spec at applications/sxtp/spec.sx (patch-fields, signals-fields,
patch-modes, example-patch-stream). Constructors / predicates /
accessors / serialise / parse in lib/host/sxtp.sx. 25 new tests
in lib/host/tests/sxtp.sx (predicates, mode normalisation, fixed
field order, remove-without-body, signals round-trip). Host
conformance 129/129 (was 104/104).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,42 @@
|
|||||||
(:body "Any SX value — event payload (optional)")
|
(:body "Any SX value — event payload (optional)")
|
||||||
(:time "Number — unix timestamp (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
|
(define
|
||||||
example-navigate
|
example-navigate
|
||||||
(quote
|
(quote
|
||||||
@@ -148,6 +184,23 @@
|
|||||||
:message "No such post"
|
:message "No such post"
|
||||||
:retry false)))))
|
: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
|
(define
|
||||||
example-inspect
|
example-inspect
|
||||||
(quote
|
(quote
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
;; at applications/sxtp/spec.sx.
|
;; at applications/sxtp/spec.sx.
|
||||||
;;
|
;;
|
||||||
;; Representation: internally a message is a plain dict tagged by :msg ("request"
|
;; Representation: internally a message is a plain dict tagged by :msg ("request"
|
||||||
;; /"response"/"condition"/"event"), with string keys so the keyword==string rule
|
;; /"response"/"condition"/"event"/"patch"/"signals"), with string keys so the
|
||||||
;; makes construction and access trivial. verb/status/type are stored as SYMBOLS
|
;; keyword==string rule makes construction and access trivial. verb/status/type/
|
||||||
;; (they ride the wire bare, not quoted). The wire LIST form is produced/consumed
|
;; mode are stored as SYMBOLS (they ride the wire bare, not quoted). The wire
|
||||||
;; only at the serialise/parse boundary:
|
;; LIST form is produced/consumed only at the serialise/parse boundary:
|
||||||
;; sxtp/serialize : msg-dict -> text/sx string
|
;; sxtp/serialize : msg-dict -> text/sx string
|
||||||
;; sxtp/parse : text/sx string -> msg-dict
|
;; sxtp/parse : text/sx string -> msg-dict
|
||||||
;; A Dream HTTP request/response bridges to/from SXTP via sxtp/from-dream and
|
;; A Dream HTTP request/response bridges to/from SXTP via sxtp/from-dream and
|
||||||
@@ -35,6 +35,21 @@
|
|||||||
(fn (etype opts)
|
(fn (etype opts)
|
||||||
(merge {:msg "event" :type (sxtp/-sym 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 ─────────────────────────────────────────────────────
|
;; ── predicates ─────────────────────────────────────────────────────
|
||||||
(define sxtp/-is?
|
(define sxtp/-is?
|
||||||
(fn (m tag) (and (= (type-of m) "dict") (= (get m :msg) tag))))
|
(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/response? (fn (m) (sxtp/-is? m "response")))
|
||||||
(define sxtp/condition? (fn (m) (sxtp/-is? m "condition")))
|
(define sxtp/condition? (fn (m) (sxtp/-is? m "condition")))
|
||||||
(define sxtp/event? (fn (m) (sxtp/-is? m "event")))
|
(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 ──────────────────────────────────────────────────────
|
;; ── accessors ──────────────────────────────────────────────────────
|
||||||
(define sxtp/verb (fn (m) (get m :verb)))
|
(define sxtp/verb (fn (m) (get m :verb)))
|
||||||
@@ -56,6 +73,11 @@
|
|||||||
(define sxtp/stream? (fn (m) (= (get m :stream) true)))
|
(define sxtp/stream? (fn (m) (= (get m :stream) true)))
|
||||||
(define sxtp/cond-type (fn (m) (get m :type)))
|
(define sxtp/cond-type (fn (m) (get m :type)))
|
||||||
(define sxtp/cond-message (fn (m) (get m :message)))
|
(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) ───────────────────────────────
|
;; ── status helpers (build responses) ───────────────────────────────
|
||||||
(define sxtp/ok (fn (body) (sxtp/response "ok" {:body body})))
|
(define sxtp/ok (fn (body) (sxtp/response "ok" {:body body})))
|
||||||
@@ -121,7 +143,9 @@
|
|||||||
{:request (list :verb :path :headers :cookies :params :capabilities :body)
|
{:request (list :verb :path :headers :cookies :params :capabilities :body)
|
||||||
:response (list :status :headers :set-cookie :body :stream)
|
:response (list :status :headers :set-cookie :body :stream)
|
||||||
:condition (list :type :message :path :retry :detail)
|
: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
|
;; A nested SXTP message (a condition/event in a :body) serialises in its own
|
||||||
;; list form; plain data values go through the serialize primitive.
|
;; list form; plain data values go through the serialize primitive.
|
||||||
(define sxtp/-emit-value
|
(define sxtp/-emit-value
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
(host-sx-test "request not response" (sxtp/response? host-sx-req) false)
|
(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 "response?" (sxtp/response? host-sx-resp) true)
|
||||||
(host-sx-test "condition?" (sxtp/condition? (sxtp/condition "x" {})) 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) ────────────────────────────
|
;; ── accessors (verb/status are symbols) ────────────────────────────
|
||||||
(host-sx-test "verb" (symbol->string (sxtp/verb host-sx-req)) "navigate")
|
(host-sx-test "verb" (symbol->string (sxtp/verb host-sx-req)) "navigate")
|
||||||
@@ -68,6 +72,68 @@
|
|||||||
(contains? (sxtp/serialize host-sx-resp) ":msg")
|
(contains? (sxtp/serialize host-sx-resp) ":msg")
|
||||||
false)
|
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 ─────────────────────────────────────────────
|
;; ── parse + round-trip ─────────────────────────────────────────────
|
||||||
(define host-sx-parsed
|
(define host-sx-parsed
|
||||||
(sxtp/parse "(request :verb query :path \"/events\" :headers {:host \"h\"})"))
|
(sxtp/parse "(request :verb query :path \"/events\" :headers {:host \"h\"})"))
|
||||||
|
|||||||
Reference in New Issue
Block a user