Commitfe6301d1Recorded4 Jul 2026Repositorysigil-web

Add live-DX ergonomics: page helpers, ui-* rename, method defaults, list content

Message

A. http-response/sxml + http-response/page (default template auto-injects <head> + sigil-web-ui-head; template: override) — streamlined full-page responses with zero head plumbing. B. sg-button/sg-form default method: to 'POST and accept method symbols (via method->attr); route method defaults to 'POST with normalize-method so string/case differences still match. sg-link stays GET-only. D. content: in ui-morph / sigil-ui-response now accepts a string, a single SXML node, or a list of nodes (joined); sxml-list->html exported. No more hand-rolled (apply string-append (map sxml->xml ...)). E. Rename the server-driven UI-update family sse- -> ui- (transport-neutral): ui-morph/ui-remove/ui-class/ui-eval/ui-redirect/ui-reload/ui-css-reload/ ui-js/ui-flash, and sse-response-batch -> ui-response. Every old sse- name kept as a deprecated alias through 1.0. Wire events stay sigil: (no client JS change).

Changed
 src/sigil/web/routes.sgl |  46 +++++++++++++++++------
 src/sigil/web/ui.sgl     | 346 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------
 2 files changed, 288 insertions(+), 104 deletions(-)
Diff
src/sigil/web/routes.sglmodified
@@ -6,10 +6,12 @@
6
;;; Example:
7
;;; (define app
8
;;; (router
9
;;; (route GET "/" home-handler)
10
;;; (route GET "/users/:id" user-handler)
11
;;; (route POST "/api/login" login-handler)
12
;;; (route ANY "/static/*path" static-handler)))
+9
;;; (route method: GET pattern: "/" handler: home-handler)
+10
;;; (route method: GET pattern: "/users/:id" handler: user-handler)
+11
;;; (route method: POST pattern: "/api/login" handler: login-handler)
+12
;;; (route method: ANY pattern: "/static/*path" handler: static-handler)))
+13
;;;
+14
;;; `method:` defaults to POST when omitted.
15
16
(define-library (sigil web routes)
17
(import (sigil core)
@@ -44,11 +46,15 @@
46
;; Route Record
47
;; ============================================================
48
47
;;; A route consists of a method, pattern, and handler
+49
;;; A route consists of a method, pattern, and handler.
+50
;;;
+51
;;; `method:` is an HTTP-method symbol (`GET`, `POST`, `ANY`, ...) and
+52
;;; defaults to `POST` when omitted. A `"post"` string is accepted too and
+53
;;; normalized at match time.
54
(define-struct route
49
(method) ; Symbol: 'GET, 'POST, 'ANY, etc.
50
(pattern) ; String: "/users/:id" or "/static/*path"
51
(handler)) ; Procedure: (request) -> response
+55
(method default: 'POST) ; Symbol: 'GET, 'POST, 'ANY, etc. (or string)
+56
(pattern) ; String: "/users/:id" or "/static/*path"
+57
(handler)) ; Procedure: (request) -> response
58
59
;; ============================================================
60
;; Pattern Matching
@@ -125,6 +131,22 @@
131
;; Router
132
;; ============================================================
133
+134
;;; Normalize an HTTP method to an upcased symbol.
+135
;;; Accepts a symbol ('GET, 'post) or string ("get", "POST").
+136
(define (normalize-method m)
+137
(cond
+138
((symbol? m) (string->symbol (string-upcase (symbol->string m))))
+139
((string? m) (string->symbol (string-upcase m)))
+140
(else m)))
+141
+142
;;; Does a route's method match a request's method?
+143
;;; 'ANY matches everything; otherwise compare normalized methods so
+144
;;; symbol/string and case differences don't matter.
+145
(define (method-matches? route-meth req-meth)
+146
(or (eq? route-meth 'ANY)
+147
(eq? (normalize-method route-meth)
+148
(normalize-method req-meth))))
+149
150
;;; Create a router from a list of routes
151
;;; Returns a handler function: (request) -> response or #f
152
(define (make-router routes)
@@ -139,8 +161,7 @@
161
(route-meth (route-method r))
162
(pattern (route-pattern r))
163
(handler (route-handler r)))
142
(if (or (eq? route-meth 'ANY)
143
(eq? route-meth method))
+164
(if (method-matches? route-meth method)
165
(let ((params (match-pattern (parse-pattern pattern) path)))
166
(if params
167
;; Match! Add params to request context and call handler
@@ -155,8 +176,9 @@
176
;; Method didn't match, try next route
177
(loop (cdr routes)))))))))
178
158
;;; Convenience macro-like function for creating routers
159
;;; Usage: (router (route 'GET "/" handler1) (route 'POST "/api" handler2))
+179
;;; Convenience function for creating routers.
+180
;;; Usage: (router (route method: GET pattern: "/" handler: handler1)
+181
;;; (route method: POST pattern: "/api" handler: handler2))
182
(define (router . routes)
183
(: any? ... -> procedure?)
184
(make-router routes))
src/sigil/web/ui.sglmodified
@@ -4,7 +4,8 @@
4
;;; The server is the source of truth; the client is a thin rendering layer.
5
;;;
6
;;; Core concepts:
7
;;; - SSE events for real-time updates (sigil:morph, sigil:eval, sigil:redirect)
+7
;;; - Server-driven UI updates (ui-morph, ui-eval, ui-redirect, ...) that
+8
;;; feed both live SSE broadcasts and per-request responses
9
;;; - HTTP headers for single-target responses (Sigil-UI-Merge-*)
10
;;; - Declarative HTML attributes (data-sg-*) for client interactions
11
;;;
@@ -13,13 +14,13 @@
14
;;; (import (sigil web ui)
15
;;; (sigil http))
16
;;;
16
;;; ;; Send an SSE event to update a target
17
;;; (write-chunk (sse-morph target: "#chat" mode: "append"
18
;;; content: `(div (@ (class "msg")) "Hello!")))
+17
;;; ;; Broadcast a UI update to every connected client
+18
;;; (broadcast-send hub (ui-morph target: "#chat" mode: "append"
+19
;;; content: `(div (@ (class "msg")) "Hello!")))
20
;;;
20
;;; ;; Return HTML with merge headers
+21
;;; ;; Return HTML with merge headers (reaches only the acting request)
22
;;; (sigil-ui-response target: "#results" mode: "inner"
22
;;; content: `(ul ,@(map render-item items)))
+23
;;; content: (map render-item items))
24
;;; ```
25
26
(define-library (sigil web ui)
@@ -31,7 +32,19 @@
32
(sigil resources))
33
34
(export
34
;; SSE event formatters
+35
;; Server-driven UI updates (transport-neutral names)
+36
ui-morph
+37
ui-remove
+38
ui-class
+39
ui-eval
+40
ui-redirect
+41
ui-reload
+42
ui-css-reload
+43
ui-js
+44
ui-flash
+45
ui-response
+46
+47
;; Deprecated aliases (sse-* names, kept through 1.0)
48
sse-morph
49
sse-remove
50
sse-class
@@ -40,12 +53,21 @@
53
sse-reload
54
sse-css-reload
55
sse-js
+56
sse-flash
+57
sse-response-batch
+58
+59
;; SXML rendering
+60
sxml-list->html
+61
+62
;; Page/response helpers
+63
http-response/sxml
+64
http-response/page
+65
default-page-template
66
67
;; Response helpers
68
sigil-ui-headers
69
sigil-ui-response
70
sigil-ui-redirect
48
sse-response-batch
71
72
;; Component helpers
73
sg-button
@@ -66,7 +88,6 @@
88
89
;; Flash messages
90
flash-message
69
sse-flash
91
92
;; Loading indicator
93
sg-loading-indicator
@@ -94,25 +115,62 @@
115
(begin
116
117
;; ============================================================
97
;; SSE Event Formatters
+118
;; SXML Rendering Helpers
119
;; ============================================================
+120
+121
;;; Render a list of SXML nodes to a single HTML string.
+122
;;;
+123
;;; Saves you from hand-rolling `(apply string-append (map sxml->xml xs))`
+124
;;; whenever you have a collection of nodes (e.g. a list of rendered rows)
+125
;;; that needs to become one fragment.
+126
;;;
+127
;;; Example:
+128
;;; ```scheme
+129
;;; (sxml-list->html (map render-row items))
+130
;;; ```
+131
(define (sxml-list->html nodes)
+132
(: list? -> string?)
+133
(apply string-append (map sxml->xml nodes)))
+134
+135
;; Coerce a `content:` value into an HTML string.
+136
;; string -> passed through unchanged
+137
;; #f -> #f (caller omits the content)
+138
;; () -> "" (empty fragment)
+139
;; list of nodes -> each node serialized and concatenated
+140
;; single SXML node-> serialized
+141
;; A single element is `(tag ...)` (car is a symbol); a list of nodes is
+142
;; `((tag ...) (tag ...) ...)` (car is itself a list). This lets callers
+143
;; pass `(map render-item items)` directly with no map/append ceremony.
+144
(define (content->html content)
+145
(cond
+146
((not content) #f)
+147
((string? content) content)
+148
((null? content) "")
+149
((pair? (car content)) (sxml-list->html content))
+150
(else (sxml->xml content))))
+151
+152
+153
;; ============================================================
+154
;; Server-Driven UI Updates
+155
;; ============================================================
+156
;;
+157
;; These produce SSE-formatted strings for the line-based wire protocol
+158
;; (`event: sigil:<type>` / `data: ...`). They are transport-neutral from
+159
;; the author's point of view: the same builder feeds both a live SSE
+160
;; broadcast (reaching every client) and a `ui-response` batch (reaching
+161
;; only the acting request). The client applies whichever arrives.
162
;;
100
;; These produce SSE-formatted strings for the line-based protocol.
101
;; Format:
102
;; event: sigil:<type>
103
;; data: field value
104
;; data: html <line1>
105
;; data: html <line2>
106
;; <blank line>
+163
;; Historical note: these were named `sse-*`. The `ui-*` names are the
+164
;; idiom now; every `sse-*` name remains as a deprecated alias through 1.0.
165
108
;;; Format an SSE morph event.
+166
;;; Format a UI morph update.
167
;;;
110
;;; Morphs HTML content into a target element. The content can be
111
;;; SXML (converted to HTML) or a string.
+168
;;; Morphs HTML content into a target element. `content:` accepts a string,
+169
;;; a single SXML node, or a list of SXML nodes (joined automatically).
170
;;;
171
;;; Parameters:
172
;;; target: CSS selector for the target element (e.g., "#chat")
115
;;; content: SXML or HTML string to morph
+173
;;; content: string, SXML node, or list of SXML nodes to morph
174
;;; mode: Morph mode (default: "morph")
175
;;; settle: Milliseconds to wait after morph (for CSS transitions)
176
;;;
@@ -128,17 +186,14 @@
186
;;;
187
;;; Example:
188
;;; ```scheme
131
;;; (sse-morph target: "#messages" mode: "append"
132
;;; content: `(div (@ (class "msg")) "Hello!"))
+189
;;; (ui-morph target: "#messages" mode: "append"
+190
;;; content: `(div (@ (class "msg")) "Hello!"))
191
;;; ```
134
(define (sse-morph (keys: (target #f)
135
(content #f)
136
(mode "morph")
137
(settle #f)))
138
(let ((html (cond
139
((string? content) content)
140
(content (sxml->xml content))
141
(else #f))))
+192
(define (ui-morph (keys: (target #f)
+193
(content #f)
+194
(mode "morph")
+195
(settle #f)))
+196
(let ((html (content->html content)))
197
(string-append
198
"event: sigil:morph\n"
199
"data: target " target "\n"
@@ -151,19 +206,19 @@
206
"")
207
"\n")))
208
154
;;; Format an SSE remove event.
+209
;;; Format a UI remove update.
210
;;;
211
;;; Removes the target element from the DOM. Shorthand for
157
;;; `(sse-morph target: TARGET mode: "remove")`.
+212
;;; `(ui-morph target: TARGET mode: "remove")`.
213
;;;
214
;;; Example:
215
;;; ```scheme
161
;;; (sse-remove target: "#notification")
+216
;;; (ui-remove target: "#notification")
217
;;; ```
163
(define (sse-remove (keys: (target #f)))
164
(sse-morph target: target mode: "remove"))
+218
(define (ui-remove (keys: (target #f)))
+219
(ui-morph target: target mode: "remove"))
220
166
;;; Format an SSE class event.
+221
;;; Format a UI class update.
222
;;;
223
;;; Adds or removes CSS classes on the target element.
224
;;;
@@ -174,10 +229,10 @@
229
;;;
230
;;; Example:
231
;;; ```scheme
177
;;; (sse-class target: "#panel" add: "visible active")
178
;;; (sse-class target: "#btn" remove: "loading" add: "done")
+232
;;; (ui-class target: "#panel" add: "visible active")
+233
;;; (ui-class target: "#btn" remove: "loading" add: "done")
234
;;; ```
180
(define (sse-class (keys: (target #f) (add #f) (remove #f)))
+235
(define (ui-class (keys: (target #f) (add #f) (remove #f)))
236
(string-append
237
"event: sigil:class\n"
238
"data: target " target "\n"
@@ -193,7 +248,7 @@
248
lines)
249
"\n")))
250
196
;;; Format an SSE eval event.
+251
;;; Format a UI eval update.
252
;;;
253
;;; Executes a limited command on the client. Only use for things
254
;;; that can't be expressed as HTML state (focus, scroll).
@@ -204,10 +259,10 @@
259
;;;
260
;;; Example:
261
;;; ```scheme
207
;;; (sse-eval cmd: "focus" target: "#input")
208
;;; (sse-eval cmd: "scroll-to" target: "#chat" position: "bottom")
+262
;;; (ui-eval cmd: "focus" target: "#input")
+263
;;; (ui-eval cmd: "scroll-to" target: "#chat" position: "bottom")
264
;;; ```
210
(define (sse-eval (keys: (cmd #f)
+265
(define (ui-eval (keys: (cmd #f)
266
(target #f)
267
(position #f)))
268
(string-append
@@ -219,21 +274,21 @@
274
"")
275
"\n"))
276
222
;;; Format an SSE redirect event.
+277
;;; Format a UI redirect update.
278
;;;
279
;;; Redirects the browser to a new URL.
280
;;;
281
;;; Example:
282
;;; ```scheme
228
;;; (sse-redirect url: "/login")
+283
;;; (ui-redirect url: "/login")
284
;;; ```
230
(define (sse-redirect (keys: (url #f)))
+285
(define (ui-redirect (keys: (url #f)))
286
(string-append
287
"event: sigil:redirect\n"
288
"data: url " url "\n"
289
"\n"))
290
236
;;; Format an SSE reload event.
+291
;;; Format a UI reload update.
292
;;;
293
;;; Tells the browser to re-fetch the current page and morph the
294
;;; result into the DOM using Idiomorph. Preserves scroll position,
@@ -245,16 +300,16 @@
300
;;;
301
;;; Example:
302
;;; ```scheme
248
;;; (sse-reload) ; reload full body
249
;;; (sse-reload target: "#sidebar") ; reload specific element
+303
;;; (ui-reload) ; reload full body
+304
;;; (ui-reload target: "#sidebar") ; reload specific element
305
;;; ```
251
(define (sse-reload (keys: (target "body")))
+306
(define (ui-reload (keys: (target "body")))
307
(string-append
308
"event: sigil:reload\n"
309
"data: target " target "\n"
310
"\n"))
311
257
;;; Format an SSE css-reload event.
+312
;;; Format a UI css-reload update.
313
;;;
314
;;; Reloads CSS stylesheets in the browser without a full page reload.
315
;;; If `href:` is provided, only stylesheets matching that substring
@@ -262,10 +317,10 @@
317
;;;
318
;;; Example:
319
;;; ```scheme
265
;;; (sse-css-reload) ; reload all stylesheets
266
;;; (sse-css-reload href: "styles.css") ; reload specific stylesheet
+320
;;; (ui-css-reload) ; reload all stylesheets
+321
;;; (ui-css-reload href: "styles.css") ; reload specific stylesheet
322
;;; ```
268
(define (sse-css-reload (keys: (href #f)))
+323
(define (ui-css-reload (keys: (href #f)))
324
(string-append
325
"event: sigil:css-reload\n"
326
(if href
@@ -273,15 +328,15 @@
328
"")
329
"\n"))
330
276
;;; Format an SSE js event.
+331
;;; Format a UI js update.
332
;;;
333
;;; Executes JavaScript code in all connected browsers.
334
;;;
335
;;; Example:
336
;;; ```scheme
282
;;; (sse-js code: "console.log('hello')")
+337
;;; (ui-js code: "console.log('hello')")
338
;;; ```
284
(define (sse-js (keys: (code #f)))
+339
(define (ui-js (keys: (code #f)))
340
(let ((lines (string-split code "\n")))
341
(string-append
342
"event: sigil:js\n"
@@ -319,7 +374,9 @@
374
375
;;; Create an HTML response with Sigil-UI merge headers.
376
;;;
322
;;; Convenience helper that combines headers and body.
+377
;;; A fragment-over-plain-HTTP response: reaches only the acting request
+378
;;; (not a broadcast). `content:` accepts a string, a single SXML node,
+379
;;; or a list of SXML nodes (joined automatically).
380
;;;
381
;;; Example:
382
;;; ```scheme
@@ -330,7 +387,7 @@
387
(content #f)
388
(mode "morph")
389
(status 200)))
333
(let ((html (if (string? content) content (sxml->xml content))))
+390
(let ((html (or (content->html content) "")))
391
(http-response
392
status: status
393
headers: (dict
@@ -356,25 +413,111 @@
413
Sigil-UI-Redirect: url)
414
body: ""))
415
359
;;; Create a batch SSE response for multi-target updates.
+416
;;; Bundle UI updates into a single response for the acting request.
417
;;;
361
;;; Returns SSE-formatted events in a single response. Useful when
362
;;; one action needs to update multiple targets.
+418
;;; Pairs with `sigil-ui-response` (which carries one HTML fragment via
+419
;;; headers); `ui-response` carries any number of `ui-*` updates in the
+420
;;; body, applied by the client on arrival. Call with no arguments to
+421
;;; return an empty response when the visible effect goes out over a
+422
;;; broadcast instead and the actor needs no direct change.
423
;;;
424
;;; Example:
425
;;; ```scheme
366
;;; (sse-response-batch
367
;;; (sse-morph target: "#sidebar" content: new-sidebar)
368
;;; (sse-morph target: "#main" content: new-main)
369
;;; (sse-eval cmd: "focus" target: "#input"))
+426
;;; (ui-response
+427
;;; (ui-morph target: "#sidebar" content: new-sidebar)
+428
;;; (ui-morph target: "#main" content: new-main)
+429
;;; (ui-eval cmd: "focus" target: "#input"))
+430
;;;
+431
;;; (ui-response) ; nothing to do on the actor's side
432
;;; ```
371
(define (sse-response-batch . events)
+433
(define (ui-response . updates)
434
(http-response
435
status: 200
436
headers: (dict
437
content-type: "text/event-stream"
438
cache-control: "no-cache")
377
body: (string-join events "")))
+439
body: (string-join updates "")))
+440
+441
+442
;; ============================================================
+443
;; Full-Page Response Helpers
+444
;; ============================================================
+445
+446
;;; Serialize a full SXML page to an HTML-document response.
+447
;;;
+448
;;; The low-level escape hatch: you bring the whole page (including
+449
;;; `<head>`), and this prepends the `<!DOCTYPE html>`, serializes with
+450
;;; `sxml->html`, and sets `Content-Type: text/html`. Use `http-response/page`
+451
;;; for the common case where you only have a title and body.
+452
;;;
+453
;;; Example:
+454
;;; ```scheme
+455
;;; (http-response/sxml
+456
;;; `(html (head (title "Hi")) (body (h1 "Hi"))))
+457
;;; ```
+458
(define (http-response/sxml page (keys: (status 200)))
+459
(http-response
+460
status: status
+461
headers: (dict content-type: "text/html; charset=utf-8")
+462
body: (string-append "<!DOCTYPE html>\n" (sxml->html page))))
+463
+464
;;; The default page template used by `http-response/page`.
+465
;;;
+466
;;; A minimal HTML shell: charset + viewport meta, a `<title>` from
+467
;;; `title`, the Sigil Web UI client scripts (so `data-sg-*` actions and
+468
;;; live updates work with zero head plumbing), and `body` spliced into
+469
;;; `<body>`. `body` may be a single SXML node or a list of nodes.
+470
;;;
+471
;;; Write your own `(title body) -> full-page-sxml` function and pass it
+472
;;; as `template:` to `http-response/page` to customize the shell.
+473
(define (default-page-template title body)
+474
`(html
+475
(head
+476
(meta (@ (charset "utf-8")))
+477
(meta (@ (name "viewport") (content "width=device-width, initial-scale=1")))
+478
(title ,title)
+479
,@(sigil-web-ui-head))
+480
(body
+481
,@(if (and (pair? body) (pair? (car body))) body (list body)))))
+482
+483
;;; Respond with a full HTML page from a title and body.
+484
;;;
+485
;;; The streamlined common case. Wraps `body` in `template` (default:
+486
;;; `default-page-template`, a working script-wired shell) and returns an
+487
;;; HTML-document response. A beginner writes `(http-response/page "To-Do" body)`
+488
;;; and gets a complete page with head, viewport, and the UI client wired
+489
;;; up. Override the shell with `template:` (a `(title body) -> sxml`
+490
;;; function), or drop to `http-response/sxml` to supply the whole page.
+491
;;;
+492
;;; `body` may be a single SXML node or a list of nodes.
+493
;;;
+494
;;; Example:
+495
;;; ```scheme
+496
;;; (http-response/page "To-Do" (todo-page items))
+497
;;; (http-response/page "Not found" '(p "No such item.") status: 404)
+498
;;; ```
+499
(define (http-response/page title body (keys: (status 200)
+500
(template default-page-template)))
+501
(http-response/sxml (template title body) status: status))
+502
+503
+504
;; ============================================================
+505
;; Deprecated aliases — `sse-*` UI-update names
+506
;; ============================================================
+507
;;
+508
;; The UI-update family was renamed `sse-*` -> `ui-*` (the updates are
+509
;; transport-neutral, not SSE-specific). These aliases keep existing code
+510
;; working through 1.0; prefer the `ui-*` names in new code.
+511
+512
(define sse-morph ui-morph)
+513
(define sse-remove ui-remove)
+514
(define sse-class ui-class)
+515
(define sse-eval ui-eval)
+516
(define sse-redirect ui-redirect)
+517
(define sse-reload ui-reload)
+518
(define sse-css-reload ui-css-reload)
+519
(define sse-js ui-js)
+520
(define sse-response-batch ui-response)
521
522
523
;; ============================================================
@@ -406,6 +549,15 @@
549
(cons (list name val) acc)
550
acc))))))
551
+552
;; Coerce an HTTP-method value to the string used in a data-sg-method
+553
;; attribute. Accepts a symbol (GET/POST/...) or a string ("post"); the
+554
;; client upcases it before fetching, so either case works on the wire.
+555
(define (method->attr m)
+556
(cond
+557
((symbol? m) (symbol->string m))
+558
((string? m) m)
+559
(else "POST")))
+560
561
;; Coerce a value for use in an HTML value attribute.
562
;; #f -> #f (omit attribute), numbers -> string, strings pass through.
563
(define (coerce-field-value val)
@@ -427,13 +579,17 @@
579
580
;;; Create a button that triggers an action.
581
;;;
+582
;;; `method:` accepts an HTTP-method symbol (`POST`, `GET`, ...) and
+583
;;; defaults to `POST` (actions are almost always POST); a `"post"` string
+584
;;; still works.
+585
;;;
586
;;; Example:
587
;;; ```scheme
432
;;; (sg-button "Like" action: "/api/like" method: "post"
+588
;;; (sg-button "Like" action: "/api/like" ; POST by default
589
;;; loading: "opacity-50")
590
;;; ```
591
(define (sg-button label (keys: (action #f)
436
(method "post")
+592
(method 'POST)
593
(target #f)
594
(mode #f)

Showing the first 500 of 591 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.