;; ========================================================================== ;; engine.sx — SxEngine specification ;; ;; Fetch/swap/history engine for browser-side SX. Like HTMX but native ;; to the SX rendering pipeline. ;; ;; This file specifies the LOGIC of the engine in s-expressions. ;; Browser-specific APIs (fetch, DOM, history, events) are declared as ;; platform interface at the bottom. ;; ;; The engine processes elements with sx-* attributes: ;; sx-get, sx-post, sx-put, sx-delete, sx-patch — HTTP verb + URL ;; sx-trigger — when to fire (click, submit, change, every 5s, ...) ;; sx-target — where to swap response (#selector, "this", "closest") ;; sx-swap — how to swap (innerHTML, outerHTML, afterend, ...) ;; sx-select — filter response (CSS selector) ;; sx-confirm — confirmation dialog before request ;; sx-prompt — prompt dialog, sends result as SX-Prompt header ;; sx-validate — form validation before request ;; sx-encoding — "json" for JSON body instead of form-encoded ;; sx-params — filter form fields (include, exclude, none) ;; sx-include — include extra inputs from other elements ;; sx-vals — extra key-value pairs to send ;; sx-headers — extra request headers ;; sx-indicator — show/hide loading indicator ;; sx-disabled-elt — disable elements during request ;; sx-push-url — push to browser history ;; sx-replace-url — replace browser history ;; sx-sync — abort previous request ("replace") ;; sx-media — only fire if media query matches ;; sx-preload — preload on mousedown/mouseover ;; sx-boost — auto-boost links and forms in container ;; sx-sse — connect to Server-Sent Events ;; sx-retry — retry on failure (exponential:startMs:capMs) ;; sx-optimistic — optimistic update (remove, disable, add-class:name) ;; sx-preserve — don't morph this element during swap ;; sx-ignore — skip morphing entirely ;; sx-on:* — inline event handlers (beforeRequest, afterSwap, ...) ;; ;; Depends on: ;; adapter-dom.sx — render-to-dom (for SX response rendering) ;; render.sx — shared registries ;; ========================================================================== ;; -------------------------------------------------------------------------- ;; Constants ;; -------------------------------------------------------------------------- (define ENGINE_VERBS (list "get" "post" "put" "delete" "patch")) (define DEFAULT_SWAP "outerHTML") ;; -------------------------------------------------------------------------- ;; Trigger parsing ;; -------------------------------------------------------------------------- ;; Parses the sx-trigger attribute value into a list of trigger descriptors. ;; Each descriptor is a dict with "event" and "modifiers" keys. (define parse-time (fn (s) ;; Parse time string: "2s" → 2000, "500ms" → 500 (cond (nil? s) 0 (ends-with? s "ms") (parse-int s 0) (ends-with? s "s") (* (parse-int (replace s "s" "") 0) 1000) :else (parse-int s 0)))) (define parse-trigger-spec (fn (spec) ;; Parse "click delay:500ms once,change" → list of trigger descriptors (if (nil? spec) nil (let ((raw-parts (split spec ","))) (filter (fn (x) (not (nil? x))) (map (fn (part) (let ((tokens (split (trim part) " "))) (if (empty? tokens) nil (if (and (= (first tokens) "every") (>= (len tokens) 2)) ;; Polling trigger (dict "event" "every" "modifiers" (dict "interval" (parse-time (nth tokens 1)))) ;; Normal trigger with optional modifiers (let ((mods (dict))) (for-each (fn (tok) (cond (= tok "once") (dict-set! mods "once" true) (= tok "changed") (dict-set! mods "changed" true) (starts-with? tok "delay:") (dict-set! mods "delay" (parse-time (slice tok 6))) (starts-with? tok "from:") (dict-set! mods "from" (slice tok 5)))) (rest tokens)) (dict "event" (first tokens) "modifiers" mods)))))) raw-parts)))))) (define default-trigger (fn (tag-name) ;; Default trigger for element type (cond (= tag-name "FORM") (list (dict "event" "submit" "modifiers" (dict))) (or (= tag-name "INPUT") (= tag-name "SELECT") (= tag-name "TEXTAREA")) (list (dict "event" "change" "modifiers" (dict))) :else (list (dict "event" "click" "modifiers" (dict)))))) ;; -------------------------------------------------------------------------- ;; Verb extraction ;; -------------------------------------------------------------------------- (define get-verb-info (fn (el) ;; Check element for sx-get, sx-post, etc. Returns (dict "method" "url") or nil. (some (fn (verb) (let ((url (dom-get-attr el (str "sx-" verb)))) (if url (dict "method" (upper verb) "url" url) nil))) ENGINE_VERBS))) ;; -------------------------------------------------------------------------- ;; Request header building ;; -------------------------------------------------------------------------- (define build-request-headers (fn (el loaded-components css-hash) ;; Build the SX request headers dict (let ((headers (dict "SX-Request" "true" "SX-Current-URL" (browser-location-href)))) ;; Target selector (let ((target-sel (dom-get-attr el "sx-target"))) (when target-sel (dict-set! headers "SX-Target" target-sel))) ;; Loaded component names (when (not (empty? loaded-components)) (dict-set! headers "SX-Components" (join "," loaded-components))) ;; CSS class hash (when css-hash (dict-set! headers "SX-Css" css-hash)) ;; Extra headers from sx-headers attribute (let ((extra-h (dom-get-attr el "sx-headers"))) (when extra-h (let ((parsed (parse-header-value extra-h))) (when parsed (for-each (fn (key) (dict-set! headers key (str (get parsed key)))) (keys parsed)))))) headers))) ;; -------------------------------------------------------------------------- ;; Response header processing ;; -------------------------------------------------------------------------- (define process-response-headers (fn (get-header) ;; Extract all SX response header directives into a dict. ;; get-header is (fn (name) → string or nil). (dict "redirect" (get-header "SX-Redirect") "refresh" (get-header "SX-Refresh") "trigger" (get-header "SX-Trigger") "retarget" (get-header "SX-Retarget") "reswap" (get-header "SX-Reswap") "location" (get-header "SX-Location") "replace-url" (get-header "SX-Replace-Url") "css-hash" (get-header "SX-Css-Hash") "trigger-swap" (get-header "SX-Trigger-After-Swap") "trigger-settle" (get-header "SX-Trigger-After-Settle") "content-type" (get-header "Content-Type")))) ;; -------------------------------------------------------------------------- ;; Swap specification parsing ;; -------------------------------------------------------------------------- (define parse-swap-spec (fn (raw-swap global-transitions?) ;; Parse "innerHTML transition:true" → dict with style + transition flag (let ((parts (split (or raw-swap DEFAULT_SWAP) " ")) (style (first parts)) (use-transition global-transitions?)) (for-each (fn (p) (cond (= p "transition:true") (set! use-transition true) (= p "transition:false") (set! use-transition false))) (rest parts)) (dict "style" style "transition" use-transition)))) ;; -------------------------------------------------------------------------- ;; Retry logic ;; -------------------------------------------------------------------------- (define parse-retry-spec (fn (retry-attr) ;; Parse "exponential:1000:30000" → spec dict or nil (if (nil? retry-attr) nil (let ((parts (split retry-attr ":"))) (dict "strategy" (first parts) "start-ms" (parse-int (nth parts 1) 1000) "cap-ms" (parse-int (nth parts 2) 30000)))))) (define next-retry-ms (fn (current-ms cap-ms) ;; Exponential backoff: double current, cap at max (min (* current-ms 2) cap-ms))) ;; -------------------------------------------------------------------------- ;; Form parameter filtering ;; -------------------------------------------------------------------------- (define filter-params (fn (params-spec all-params) ;; Filter form parameters by sx-params spec. ;; all-params is a list of (key value) pairs. ;; Returns filtered list of (key value) pairs. (cond (nil? params-spec) all-params (= params-spec "none") (list) (= params-spec "*") all-params (starts-with? params-spec "not ") (let ((excluded (map trim (split (slice params-spec 4) ",")))) (filter (fn (p) (not (contains? excluded (first p)))) all-params)) :else (let ((allowed (map trim (split params-spec ",")))) (filter (fn (p) (contains? allowed (first p))) all-params))))) ;; -------------------------------------------------------------------------- ;; Target resolution ;; -------------------------------------------------------------------------- (define resolve-target (fn (el) ;; Resolve the swap target for an element (let ((sel (dom-get-attr el "sx-target"))) (cond (or (nil? sel) (= sel "this")) el (= sel "closest") (dom-parent el) :else (dom-query sel))))) ;; -------------------------------------------------------------------------- ;; Optimistic updates ;; -------------------------------------------------------------------------- (define apply-optimistic (fn (el) ;; Apply optimistic update preview. Returns state for reverting, or nil. (let ((directive (dom-get-attr el "sx-optimistic"))) (if (nil? directive) nil (let ((target (or (resolve-target el) el)) (state (dict "target" target "directive" directive))) (cond (= directive "remove") (do (dict-set! state "opacity" (dom-get-style target "opacity")) (dom-set-style target "opacity" "0") (dom-set-style target "pointer-events" "none")) (= directive "disable") (do (dict-set! state "disabled" (dom-get-prop target "disabled")) (dom-set-prop target "disabled" true)) (starts-with? directive "add-class:") (let ((cls (slice directive 10))) (dict-set! state "add-class" cls) (dom-add-class target cls))) state))))) (define revert-optimistic (fn (state) ;; Revert an optimistic update (when state (let ((target (get state "target")) (directive (get state "directive"))) (cond (= directive "remove") (do (dom-set-style target "opacity" (or (get state "opacity") "")) (dom-set-style target "pointer-events" "")) (= directive "disable") (dom-set-prop target "disabled" (or (get state "disabled") false)) (get state "add-class") (dom-remove-class target (get state "add-class"))))))) ;; -------------------------------------------------------------------------- ;; Out-of-band swap identification ;; -------------------------------------------------------------------------- (define find-oob-swaps (fn (container) ;; Find elements marked for out-of-band swapping. ;; Returns list of (dict "element" el "swap-type" type "target-id" id). (let ((results (list))) (for-each (fn (attr) (let ((oob-els (dom-query-all container (str "[" attr "]")))) (for-each (fn (oob) (let ((swap-type (or (dom-get-attr oob attr) "outerHTML")) (target-id (dom-id oob))) (dom-remove-attr oob attr) (when target-id (append! results (dict "element" oob "swap-type" swap-type "target-id" target-id))))) oob-els))) (list "sx-swap-oob" "hx-swap-oob")) results))) ;; -------------------------------------------------------------------------- ;; DOM morph algorithm ;; -------------------------------------------------------------------------- ;; Lightweight reconciler: patches oldNode to match newNode in-place, ;; preserving event listeners, focus, scroll position, and form state ;; on keyed (id) elements. (define morph-node (fn (old-node new-node) ;; Morph old-node to match new-node, preserving listeners/state. (cond ;; sx-preserve / sx-ignore → skip (or (dom-has-attr? old-node "sx-preserve") (dom-has-attr? old-node "sx-ignore")) nil ;; Different node type or tag → replace wholesale (or (not (= (dom-node-type old-node) (dom-node-type new-node))) (not (= (dom-node-name old-node) (dom-node-name new-node)))) (dom-replace-child (dom-parent old-node) (dom-clone new-node) old-node) ;; Text/comment nodes → update content (or (= (dom-node-type old-node) 3) (= (dom-node-type old-node) 8)) (when (not (= (dom-text-content old-node) (dom-text-content new-node))) (dom-set-text-content old-node (dom-text-content new-node))) ;; Element nodes → sync attributes, then recurse children (= (dom-node-type old-node) 1) (do (sync-attrs old-node new-node) ;; Skip morphing focused input to preserve user's in-progress edits (when (not (and (dom-is-active-element? old-node) (dom-is-input-element? old-node))) (morph-children old-node new-node)))))) (define sync-attrs (fn (old-el new-el) ;; Add/update attributes from new, remove those not in new (for-each (fn (attr) (let ((name (first attr)) (val (nth attr 1))) (when (not (= (dom-get-attr old-el name) val)) (dom-set-attr old-el name val)))) (dom-attr-list new-el)) (for-each (fn (attr) (when (not (dom-has-attr? new-el (first attr))) (dom-remove-attr old-el (first attr)))) (dom-attr-list old-el)))) (define morph-children (fn (old-parent new-parent) ;; Reconcile children of old-parent to match new-parent. ;; Keyed elements (with id) are matched and moved in-place. (let ((old-kids (dom-child-list old-parent)) (new-kids (dom-child-list new-parent)) ;; Build ID map of old children for keyed matching (old-by-id (reduce (fn (acc kid) (let ((id (dom-id kid))) (if id (do (dict-set! acc id kid) acc) acc))) (dict) old-kids)) (oi 0)) ;; Walk new children, morph/insert/append (for-each (fn (new-child) (let ((match-id (dom-id new-child)) (match-by-id (if match-id (dict-get old-by-id match-id) nil))) (cond ;; Keyed match — move into position if needed, then morph (and match-by-id (not (nil? match-by-id))) (do (when (and (< oi (len old-kids)) (not (= match-by-id (nth old-kids oi)))) (dom-insert-before old-parent match-by-id (if (< oi (len old-kids)) (nth old-kids oi) nil))) (morph-node match-by-id new-child) (set! oi (inc oi))) ;; Positional match (< oi (len old-kids)) (let ((old-child (nth old-kids oi))) (if (and (dom-id old-child) (not match-id)) ;; Old has ID, new doesn't — insert new before old (dom-insert-before old-parent (dom-clone new-child) old-child) ;; Normal positional morph (do (morph-node old-child new-child) (set! oi (inc oi))))) ;; Extra new children — append :else (dom-append old-parent (dom-clone new-child))))) new-kids) ;; Remove leftover old children (for-each (fn (i) (when (>= i oi) (let ((leftover (nth old-kids i))) (when (and (dom-is-child-of? leftover old-parent) (not (dom-has-attr? leftover "sx-preserve")) (not (dom-has-attr? leftover "sx-ignore"))) (dom-remove-child old-parent leftover))))) (range oi (len old-kids)))))) ;; -------------------------------------------------------------------------- ;; Swap dispatch ;; -------------------------------------------------------------------------- (define swap-dom-nodes (fn (target new-nodes strategy) ;; Execute a swap strategy on live DOM nodes. ;; new-nodes is typically a DocumentFragment or Element. (case strategy "innerHTML" (if (dom-is-fragment? new-nodes) (morph-children target new-nodes) (let ((wrapper (dom-create-element "div" nil))) (dom-append wrapper new-nodes) (morph-children target wrapper))) "outerHTML" (let ((parent (dom-parent target))) (if (dom-is-fragment? new-nodes) ;; Fragment — morph first child, insert rest (let ((fc (dom-first-child new-nodes))) (if fc (do (morph-node target fc) ;; Insert remaining siblings after morphed element (let ((sib (dom-next-sibling fc))) (insert-remaining-siblings parent target sib))) (dom-remove-child parent target))) (morph-node target new-nodes)) parent) "afterend" (dom-insert-after target new-nodes) "beforeend" (dom-append target new-nodes) "afterbegin" (dom-prepend target new-nodes) "beforebegin" (dom-insert-before (dom-parent target) new-nodes target) "delete" (dom-remove-child (dom-parent target) target) "none" nil ;; Default = innerHTML :else (if (dom-is-fragment? new-nodes) (morph-children target new-nodes) (let ((wrapper (dom-create-element "div" nil))) (dom-append wrapper new-nodes) (morph-children target wrapper)))))) (define insert-remaining-siblings (fn (parent ref-node sib) ;; Insert sibling chain after ref-node (when sib (let ((next (dom-next-sibling sib))) (dom-insert-after ref-node sib) (insert-remaining-siblings parent sib next))))) ;; -------------------------------------------------------------------------- ;; String-based swap (fallback for HTML responses) ;; -------------------------------------------------------------------------- (define swap-html-string (fn (target html strategy) ;; Execute a swap strategy using an HTML string (DOMParser pipeline). (case strategy "innerHTML" (dom-set-inner-html target html) "outerHTML" (let ((parent (dom-parent target))) (dom-insert-adjacent-html target "afterend" html) (dom-remove-child parent target) parent) "afterend" (dom-insert-adjacent-html target "afterend" html) "beforeend" (dom-insert-adjacent-html target "beforeend" html) "afterbegin" (dom-insert-adjacent-html target "afterbegin" html) "beforebegin" (dom-insert-adjacent-html target "beforebegin" html) "delete" (dom-remove-child (dom-parent target) target) "none" nil :else (dom-set-inner-html target html)))) ;; -------------------------------------------------------------------------- ;; History management ;; -------------------------------------------------------------------------- (define handle-history (fn (el url resp-headers) ;; Process history push/replace based on element attrs and response headers (let ((push-url (dom-get-attr el "sx-push-url")) (replace-url (dom-get-attr el "sx-replace-url")) (hdr-replace (get resp-headers "replace-url"))) (cond ;; Server override hdr-replace (browser-replace-state hdr-replace) ;; Client push (and push-url (not (= push-url "false"))) (browser-push-state (if (= push-url "true") url push-url)) ;; Client replace (and replace-url (not (= replace-url "false"))) (browser-replace-state (if (= replace-url "true") url replace-url)))))) ;; -------------------------------------------------------------------------- ;; Preload cache ;; -------------------------------------------------------------------------- (define PRELOAD_TTL 30000) ;; 30 seconds (define preload-cache-get (fn (cache url) ;; Get and consume a cached preload response. ;; Returns (dict "text" ... "content-type" ...) or nil. (let ((entry (dict-get cache url))) (if (nil? entry) nil (if (> (- (now-ms) (get entry "timestamp")) PRELOAD_TTL) (do (dict-delete! cache url) nil) (do (dict-delete! cache url) entry)))))) (define preload-cache-set (fn (cache url text content-type) ;; Store a preloaded response (dict-set! cache url (dict "text" text "content-type" content-type "timestamp" (now-ms))))) ;; -------------------------------------------------------------------------- ;; Trigger dispatch table ;; -------------------------------------------------------------------------- ;; Maps trigger event names to binding strategies. ;; This is the logic; actual browser event binding is platform interface. (define classify-trigger (fn (trigger) ;; Classify a parsed trigger descriptor for binding. ;; Returns one of: "poll", "intersect", "load", "revealed", "event" (let ((event (get trigger "event"))) (cond (= event "every") "poll" (= event "intersect") "intersect" (= event "load") "load" (= event "revealed") "revealed" :else "event")))) ;; -------------------------------------------------------------------------- ;; Boost logic ;; -------------------------------------------------------------------------- (define should-boost-link? (fn (link) ;; Whether a link inside an sx-boost container should be boosted (let ((href (dom-get-attr link "href"))) (and href (not (starts-with? href "#")) (not (starts-with? href "javascript:")) (not (starts-with? href "mailto:")) (browser-same-origin? href) (not (dom-has-attr? link "sx-get")) (not (dom-has-attr? link "sx-post")) (not (dom-has-attr? link "sx-disable")))))) (define should-boost-form? (fn (form) ;; Whether a form inside an sx-boost container should be boosted (and (not (dom-has-attr? form "sx-get")) (not (dom-has-attr? form "sx-post")) (not (dom-has-attr? form "sx-disable"))))) ;; -------------------------------------------------------------------------- ;; SSE event classification ;; -------------------------------------------------------------------------- (define parse-sse-swap (fn (el) ;; Parse sx-sse-swap attribute ;; Returns event name to listen for (default "message") (or (dom-get-attr el "sx-sse-swap") "message"))) ;; ========================================================================== ;; Engine orchestration ;; ;; The following functions define the runtime behavior of the engine: ;; request execution, trigger binding, post-swap lifecycle, boost, SSE, ;; and main processing. Browser-specific mechanics (fetch, addEventListener, ;; IntersectionObserver, EventSource, etc.) are declared as platform ;; interface at the bottom. ;; ========================================================================== ;; -------------------------------------------------------------------------- ;; Engine state ;; -------------------------------------------------------------------------- (define _preload-cache (dict)) (define _css-hash "") ;; -------------------------------------------------------------------------- ;; Event dispatch helpers ;; -------------------------------------------------------------------------- (define dispatch-trigger-events (fn (el header-val) ;; Parse and dispatch SX-Trigger header events. ;; Value: JSON object, JSON string, or comma-separated names. (when header-val (let ((parsed (try-parse-json header-val))) (if (and parsed (dict? parsed)) (for-each (fn (key) (dom-dispatch el key (dict-get parsed key))) (keys parsed)) (for-each (fn (name) (let ((n (trim name))) (when (not (= n "")) (dom-dispatch el n (dict))))) (split header-val ","))))))) ;; -------------------------------------------------------------------------- ;; CSS tracking ;; -------------------------------------------------------------------------- (define init-css-tracking (fn () ;; Read CSS hash from (let ((meta (dom-query "meta[name=\"sx-css-classes\"]"))) (when meta (let ((content (dom-get-attr meta "content"))) (when content (set! _css-hash content))))))) ;; -------------------------------------------------------------------------- ;; Request execution ;; -------------------------------------------------------------------------- (define execute-request (fn (el verb-info extra-params) ;; Pre-flight gate logic: media, confirm, prompt. ;; Returns a promise. (let ((current-verb (get-verb-info el)) (verb (if current-verb current-verb verb-info)) (method (get verb "method")) (url (get verb "url"))) ;; Reset retry backoff on fresh requests (when (not (dom-has-class? el "sx-error")) (dom-remove-attr el "data-sx-retry-ms")) ;; Gate: media query (if (let ((media (dom-get-attr el "sx-media"))) (and media (not (browser-media-matches? media)))) (promise-resolve nil) ;; Gate: confirm dialog (if (let ((msg (dom-get-attr el "sx-confirm"))) (and msg (not (browser-confirm msg)))) (promise-resolve nil) ;; Gate: prompt dialog (let ((prompt-msg (dom-get-attr el "sx-prompt")) (params extra-params)) (if prompt-msg (let ((prompt-val (browser-prompt prompt-msg))) (if (nil? prompt-val) (promise-resolve nil) (do (set! params (or params (dict))) (dict-set! params "promptValue" prompt-val) (do-fetch el verb method url params)))) (do-fetch el verb method url params)))))))) ;; -------------------------------------------------------------------------- ;; Fetch pipeline ;; -------------------------------------------------------------------------- (define do-fetch (fn (el verb method url extra-params) ;; Build request, execute fetch, handle response. ;; Returns a promise. (let ((sync-attr (dom-get-attr el "sx-sync"))) (when (and sync-attr (contains? sync-attr "replace")) (abort-previous el)) (let ((ctrl (new-abort-controller))) (track-controller el ctrl) (let ((headers (build-request-headers el (loaded-component-names) _css-hash))) ;; Prompt header (when (and extra-params (dict-has? extra-params "promptValue")) (dict-set! headers "SX-Prompt" (get extra-params "promptValue"))) ;; CSRF for mutating same-origin (when (and (not (= method "GET")) (browser-same-origin? url)) (let ((csrf (csrf-token))) (when csrf (dict-set! headers "X-CSRFToken" csrf)))) ;; Build request body (let ((body-info (build-request-body el method url))) (let ((body (get body-info "body")) (final-url (get body-info "url")) (ct (get body-info "content-type"))) (when ct (dict-set! headers "Content-Type" ct)) ;; Lifecycle: beforeRequest (if (not (dom-dispatch el "sx:beforeRequest" (dict "method" method "url" final-url))) (promise-resolve nil) (do ;; Loading state (dom-add-class el "sx-request") (dom-set-attr el "aria-busy" "true") (let ((indicator (show-indicator el)) (disabled-elts (disable-elements el)) (preloaded (if (= method "GET") (preload-cache-get _preload-cache final-url) nil))) ;; Platform fetch with callbacks (fetch-request (dict "url" final-url "method" method "headers" headers "body" body "signal" (controller-signal ctrl) "preloaded" preloaded "cross-origin" (cross-origin? final-url)) ;; Success: (fn (resp-ok status get-header text) ...) (fn (resp-ok status get-header text) (do (clear-loading-state el indicator disabled-elts) (if (not resp-ok) (do (dom-dispatch el "sx:responseError" (dict "status" status)) (handle-retry el verb extra-params)) (do (dom-dispatch el "sx:afterRequest" (dict)) (handle-fetch-success el final-url verb extra-params get-header text))))) ;; Error: (fn (err) ...) (fn (err) (do (clear-loading-state el indicator disabled-elts) (when (not (abort-error? err)) (do (dom-dispatch el "sx:sendError" (dict "error" err)) (handle-retry el verb extra-params)))))))))))))))) ;; -------------------------------------------------------------------------- ;; Response handling ;; -------------------------------------------------------------------------- (define handle-fetch-success (fn (el url verb extra-params get-header text) ;; Process a successful fetch response. (let ((headers (process-response-headers get-header))) ;; Redirect — skip swap (if (get headers "redirect") (browser-navigate (get headers "redirect")) ;; Refresh — skip swap (if (= (get headers "refresh") "true") (browser-reload) (do ;; Trigger events from header (dispatch-trigger-events el (get headers "trigger")) ;; Determine swap target and strategy (let ((raw-swap (or (dom-get-attr el "sx-swap") DEFAULT_SWAP)) (target (resolve-target el)) (select-sel (dom-get-attr el "sx-select"))) ;; Server overrides (when (get headers "retarget") (set! target (or (dom-query (get headers "retarget")) target))) (when (get headers "reswap") (set! raw-swap (get headers "reswap"))) ;; Parse swap spec (let ((swap (parse-swap-spec raw-swap false)) (ct (or (get headers "content-type") ""))) ;; Dispatch by content type (if (contains? ct "text/sx") (handle-sx-response el target swap select-sel text) (handle-html-response el target swap select-sel text)) ;; SX-Location (when (get headers "location") (fetch-location (get headers "location"))) ;; History (handle-history el url headers) ;; After-swap lifecycle (dom-dispatch el "sx:afterSwap" (dict "target" target)) (dispatch-trigger-events el (get headers "trigger-swap")) (request-animation-frame (fn () (do (dom-dispatch el "sx:afterSettle" (dict "target" target)) (dispatch-trigger-events el (get headers "trigger-settle"))))))))))))) ;; -------------------------------------------------------------------------- ;; SX response handler ;; -------------------------------------------------------------------------- (define handle-sx-response (fn (el target swap select-sel text) ;; Process text/sx response: extract components, CSS, render, swap. (let ((cleaned (strip-component-scripts text)) (cleaned2 (extract-response-css cleaned))) (let ((source (trim cleaned2))) (when (and source (not (= source ""))) (let ((dom (sx-render source)) (container (dom-create-element "div" nil))) (dom-append container dom) ;; OOB processing on live DOM nodes (process-oob-swaps container (fn (t oob s) (swap-dom-nodes t oob s))) ;; Select filtering (let ((selected (if select-sel (select-from-container container select-sel) (children-to-fragment container)))) ;; Main swap (when (and (not (= (get swap "style") "none")) target) (with-transition (get swap "transition") (fn () (do (swap-dom-nodes target selected (get swap "style")) (hoist-head-elements target)))))))))))) ;; -------------------------------------------------------------------------- ;; HTML response handler ;; -------------------------------------------------------------------------- (define handle-html-response (fn (el target swap select-sel text) ;; Process HTML response: parse, scripts, OOB, swap. (let ((doc (dom-parse-html-document text))) ;; Process sx scripts (sx-process-scripts doc) ;; OOB processing (process-oob-swaps doc (fn (t oob s) (swap-html-string t (dom-outer-html oob) s))) ;; Build content (let ((content (if select-sel (select-html-from-doc doc select-sel) (or (dom-body-inner-html doc) text)))) ;; Main swap (when (and (not (= (get swap "style") "none")) target) (with-transition (get swap "transition") (fn () (do (swap-html-string target content (get swap "style")) (hoist-head-elements target))))))))) ;; -------------------------------------------------------------------------- ;; Retry handling ;; -------------------------------------------------------------------------- (define handle-retry (fn (el verb-info extra-params) ;; Retry failed request with exponential backoff. (let ((retry-attr (dom-get-attr el "sx-retry"))) (when retry-attr (let ((spec (parse-retry-spec retry-attr)) (current-ms (or (parse-int (dom-get-attr el "data-sx-retry-ms") 0) (get spec "start-ms")))) (dom-add-class el "sx-error") (dom-remove-class el "sx-loading") (set-timeout (fn () (do (dom-remove-class el "sx-error") (dom-add-class el "sx-loading") (dom-set-attr el "data-sx-retry-ms" (str (next-retry-ms current-ms (get spec "cap-ms")))) (execute-request el verb-info extra-params))) current-ms)))))) ;; -------------------------------------------------------------------------- ;; Trigger binding ;; -------------------------------------------------------------------------- (define bind-triggers (fn (el verb-info) ;; Parse triggers and bind event handlers. (let ((trigger-spec (dom-get-attr el "sx-trigger")) (triggers (if trigger-spec (parse-trigger-spec trigger-spec) (default-trigger (dom-tag-name el))))) (for-each (fn (trig) (let ((kind (classify-trigger trig))) (cond (= kind "poll") (set-interval (fn () (execute-request el verb-info nil)) (or (get (get trig "modifiers") "interval") 1000)) (= kind "intersect") (observe-intersection el (fn () (execute-request el verb-info nil)) (get (get trig "modifiers") "once") (get (get trig "modifiers") "delay")) (= kind "load") (set-timeout (fn () (execute-request el verb-info nil)) 0) (= kind "revealed") (observe-intersection el (fn () (execute-request el verb-info nil)) true nil) :else (bind-event el verb-info trig)))) triggers)))) ;; -------------------------------------------------------------------------- ;; Event binding with modifiers ;; -------------------------------------------------------------------------- (define bind-event (fn (el verb-info trig) ;; Bind a single event with modifiers (once, delay, changed, from). (let ((event-name (get trig "event")) (mods (get trig "modifiers")) (listen-target (if (get mods "from") (or (dom-query (get mods "from")) el) el)) (timer nil) (last-val nil)) (dom-add-listener listen-target event-name (fn (e) (do ;; Prevent defaults (when (= event-name "submit") (prevent-default e)) (when (and (= event-name "click") (= (dom-tag-name el) "A")) (prevent-default e)) ;; Validation gate (if (not (validate-for-request el)) (dom-dispatch el "sx:validationFailed" (dict)) ;; Changed modifier gate (if (and (get mods "changed") (not (nil? (element-value el))) (= (element-value el) last-val)) nil (do (when (get mods "changed") (set! last-val (element-value el))) ;; Apply optimistic update (let ((opt-state (apply-optimistic el)) (exec-fn (fn () (let ((p (execute-request el verb-info nil))) (when (and opt-state p) (promise-catch p (fn (_) (revert-optimistic opt-state)))))))) ;; Delay modifier (if (get mods "delay") (do (clear-timeout timer) (set! timer (set-timeout exec-fn (get mods "delay")))) (exec-fn)))))))) (dict "once" (get mods "once")))))) ;; -------------------------------------------------------------------------- ;; Post-swap lifecycle ;; -------------------------------------------------------------------------- (define post-swap (fn (root) ;; Post-swap: activate scripts, load components, hydrate, bind engine. (do (activate-scripts root) (sx-process-scripts root) (sx-hydrate root) (process-elements root)))) (define activate-scripts (fn (root) ;; Scripts inserted via innerHTML don't execute. ;; Replace dead scripts with live clones so the browser runs them. (let ((dead (dom-query-all root "script:not([type]), script[type='text/javascript']"))) (for-each (fn (d) (let ((live (create-script-clone d))) (dom-replace-child (dom-parent d) live d))) dead)))) ;; -------------------------------------------------------------------------- ;; Out-of-band swap processing (orchestration variant) ;; -------------------------------------------------------------------------- (define process-oob-swaps (fn (container swap-fn) ;; Find elements with sx-swap-oob/hx-swap-oob and swap to targets. (for-each (fn (attr) (let ((oob-els (dom-query-all container (str "[" attr "]")))) (for-each (fn (oob) (let ((swap-type (or (dom-get-attr oob attr) "outerHTML")) (target-id (dom-id oob))) (dom-remove-attr oob attr) (when (dom-parent oob) (dom-remove-child (dom-parent oob) oob)) (when target-id (let ((target (dom-query-by-id target-id))) (when target (swap-fn target oob swap-type)))))) oob-els))) (list "sx-swap-oob" "hx-swap-oob")))) ;; -------------------------------------------------------------------------- ;; Head element hoisting ;; -------------------------------------------------------------------------- (define hoist-head-elements (fn (root) ;; Move