;; ========================================================================== ;; 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"))) ;; -------------------------------------------------------------------------- ;; Platform interface — Engine ;; -------------------------------------------------------------------------- ;; ;; Browser/Network: ;; (browser-location-href) → current URL string ;; (browser-same-origin? url) → boolean ;; (browser-push-state url) → void (history.pushState) ;; (browser-replace-state url) → void (history.replaceState) ;; (browser-navigate url) → void (location.assign) ;; (browser-reload) → void (location.reload) ;; (browser-scroll-to x y) → void ;; (browser-media-matches? query) → boolean (matchMedia) ;; (browser-confirm msg) → boolean ;; (browser-prompt msg) → string or nil ;; (now-ms) → current timestamp in milliseconds ;; ;; Fetch: ;; (browser-fetch url opts) → Promise-like ;; (browser-abort-controller) → AbortController ;; ;; DOM query: ;; (dom-query sel) → Element or nil ;; (dom-query-all root sel) → list of Elements ;; (dom-id el) → string id or nil ;; (dom-parent el) → parent Element ;; (dom-first-child el) → first child node ;; (dom-next-sibling el) → next sibling node ;; (dom-child-list el) → list of child nodes ;; (dom-tag-name el) → uppercase tag name ;; ;; DOM mutation: ;; (dom-create-element tag ns) → Element ;; (dom-append parent child) → void ;; (dom-prepend parent child) → void ;; (dom-insert-before parent node ref) → void ;; (dom-insert-after ref node) → void ;; (dom-remove-child parent child) → void ;; (dom-replace-child parent new old) → void ;; (dom-clone node) → deep clone ;; ;; DOM attributes: ;; (dom-get-attr el name) → string or nil ;; (dom-set-attr el name val) → void ;; (dom-remove-attr el name) → void ;; (dom-has-attr? el name) → boolean ;; (dom-attr-list el) → list of (name value) pairs ;; ;; DOM properties: ;; (dom-get-prop el name) → value ;; (dom-set-prop el name val) → void ;; (dom-get-style el prop) → string ;; (dom-set-style el prop val) → void ;; (dom-add-class el cls) → void ;; (dom-remove-class el cls) → void ;; ;; DOM inspection: ;; (dom-node-type el) → number (1=element, 3=text, 8=comment, 11=fragment) ;; (dom-node-name el) → string (uppercase tag or #text/#comment) ;; (dom-text-content el) → string ;; (dom-set-text-content el s) → void ;; (dom-is-fragment? el) → boolean (nodeType === 11) ;; (dom-is-child-of? child parent) → boolean ;; (dom-is-active-element? el) → boolean (el === document.activeElement) ;; (dom-is-input-element? el) → boolean (INPUT/TEXTAREA/SELECT) ;; ;; DOM content: ;; (dom-set-inner-html el html) → void ;; (dom-insert-adjacent-html el pos html) → void ;; (dom-parse-html-string text) → parsed document ;; ;; Events: ;; (dom-dispatch el name detail) → boolean (dispatchEvent) ;; (dom-add-listener el event fn opts) → void ;; (dom-remove-listener el event fn) → void ;; ;; Parsing: ;; (parse-header-value s) → dict (parse JSON or SX dict from string) ;; ;; Misc: ;; (dict-has? d key) → boolean ;; (dict-delete! d key) → void ;; --------------------------------------------------------------------------