Commit337dd087Recorded7 Jul 2026Repositorysigil-web-client
Initial release: (sigil web client) client-side WASM UI framework
Message
Extracted from the sigil-web wip/wasm-client-model branch into its own repo. A react-like rendering layer for browser apps compiled to WASM, orthogonal to the server-side sigil-web framework: imports only sigil-wasm-dom. Module (sigil web client) unchanged.
Changed
.gitignore | 2 ++
.woodpecker.yml | 29 ++++++++++++++++
README.md | 46 +++++++++++++++++++++++++
dev-redirects.sgl | 7 ++++
package.sgl | 41 ++++++++++++++++++++++
src/sigil/web/client.sgl | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-client.sgl | 24 +++++++++++++
7 files changed, 462 insertions(+)Diff
.gitignoreadded
@@ -0,0 +1,2 @@
+1
build/+2
.sigil/.woodpecker.ymladded
@@ -0,0 +1,29 @@
+1
# Woodpecker CI configuration for Codeberg+2
# https://docs.codeberg.org/ci/+3
#+4
# Library CI: fetch the pinned sigil release binary, install deps via+5
# from-git pins, run the test suite. Pinned to a specific monorepo+6
# release (rather than the rolling `dev`) so a flaky monorepo doesn't+7
# cascade across all extracted-repo CIs. Bump via search/replace when+8
# a new release is cut.+9
#+10
# Single-step on purpose: $HOME-rooted state from `sigil deps install`+11
# does not survive a Woodpecker step boundary, so fetch + deps + test+12
# must all run in the same container.+13
+14
when:+15
- event: [push, pull_request, manual]+16
+17
variables:+18
- &alpine_image "alpine:3.20"+19
+20
steps:+21
test:+22
image: *alpine_image+23
commands:+24
- apk add --no-cache curl ca-certificates git+25
- curl -fLo ./sigil https://codeberg.org/sigil/sigil/releases/download/v0.17.4/sigil-linux-amd64-static+26
- chmod +x ./sigil+27
- ./sigil --version+28
- ./sigil deps install+29
- ./sigil testREADME.mdadded
@@ -0,0 +1,46 @@
+1
# sigil-web-client+2
+3
Client-side WASM UI framework for [Sigil](https://codeberg.org/sigil/sigil). A react-like rendering layer for browser applications compiled to WebAssembly: it renders Sigil SXML into the browser DOM and keeps application code independent of the underlying WASM DOM bridge.+4
+5
`(sigil web client)` is the browser-side counterpart to the server-side [sigil-web](https://codeberg.org/sigil/sigil-web) framework. The two are orthogonal: this library imports only `sigil-wasm-dom` and shares no code with the server-side modules.+6
+7
## Usage+8
+9
```scheme+10
(import (sigil web client))+11
+12
;; Look up a DOM root by selector+13
(define root (web-client-root "#app"))+14
+15
;; Mount a view: SXML, or a nullary procedure returning SXML+16
(web-client-mount root (lambda () `(div "Hello, world!")))+17
+18
;; Update the mounted view in place+19
(web-client-update root (lambda () `(div "Updated!")))+20
+21
;; Clear a root+22
(web-client-clear root)+23
```+24
+25
### Exports+26
+27
- `web-client-root` — look up a DOM root node by selector+28
- `web-client-root?` — validate a root node handle+29
- `web-client-render` — render a view (an SXML value, or a nullary procedure returning one)+30
- `web-client-mount` — mount a view into a root+31
- `web-client-update` — update a mounted view in place+32
- `web-client-clear` — clear a root+33
- `web-client-reset!` — reset retained client state+34
+35
## Building+36
+37
This is a browser/WASM library. Build it into a web application with the `web` config:+38
+39
```sh+40
sigil deps install+41
sigil build --config web+42
```+43
+44
## License+45
+46
BSD-3-Clausedev-redirects.sgladded
@@ -0,0 +1,7 @@
+1
;; Development redirects — point dependencies at local checkouts.+2
;; sigil-wasm-dom lives in the sigil monorepo, so redirect the whole repo.+3
(redirects+4
repos: (list+5
(for-repo+6
url: "codeberg:sigil/sigil"+7
use: (from-path dir: "../sigil"))))package.sgladded
@@ -0,0 +1,41 @@
+1
;;; sigil-web-client - Client-side WASM UI framework for Sigil+2
;;;+3
;;; A react-like rendering layer for browser applications compiled to+4
;;; WebAssembly. Provides root lookup and validation plus render, mount,+5
;;; update, and clear helpers over the WASM DOM SXML renderer, so browser+6
;;; application code does not import the WASM DOM backend directly.+7
;;;+8
;;; Module:+9
;;; (sigil web client) - Browser client rendering helpers+10
;;;+11
;;; Orthogonal to the server-side (sigil web ...) framework in sigil-web:+12
;;; this library imports only sigil-wasm-dom and shares no code with the+13
;;; server-side modules.+14
+15
(package+16
name: "sigil-web-client"+17
version: "0.15.0"+18
sigil: "^0.17"+19
description: "Client-side WASM UI framework for Sigil: react-like browser app rendering"+20
url: "https://codeberg.org/sigil/sigil-web-client"+21
license: "BSD-3-Clause"+22
authors: (list "David Wilson <[email protected]>")+23
+24
configs: (list+25
(config+26
name: 'dev+27
output-dir: "build/dev"+28
debug?: #t+29
optimize: 0)+30
(config+31
name: 'release+32
output-dir: "build/release"+33
debug?: #f+34
optimize: 2))+35
+36
dependencies: (list+37
(from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-dom" version: "^0.17"))+38
+39
dev-dependencies: (list+40
(from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: "^0.17")+41
(from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: "^0.17")))src/sigil/web/client.sgladded
@@ -0,0 +1,313 @@
+1
;;; (sigil web client) - Browser client UI helpers+2
;;;+3
;;; This module is the public client-side web UI layer. It renders Sigil SXML+4
;;; into the browser DOM while keeping application code independent of the+5
;;; current WASM DOM bridge implementation.+6
+7
(define-library (sigil web client)+8
(import (sigil core)+9
(sigil wasm dom)+10
(sigil wasm dom sxml))+11
+12
(export+13
web-client-root+14
web-client-root?+15
web-client-render+16
web-client-mount+17
web-client-update+18
web-client-clear+19
web-client-reset!)+20
+21
(begin+22
(define web-client-roots '())+23
+24
(define (web-client-root? value)+25
(and (number? value) (not (= value 0))))+26
+27
(define (web-client-root selector)+28
(let ((node (wasm-dom-query-selector selector)))+29
(if (web-client-root? node) node #f)))+30
+31
(define (web-client-render view)+32
(if (procedure? view)+33
(view)+34
view))+35
+36
(define (web-client-sxml-element? value)+37
(and (pair? value) (symbol? (car value))))+38
+39
(define (web-client-sxml-tag elem)+40
(car elem))+41
+42
(define (web-client-sxml-has-attributes? elem)+43
(and (pair? (cdr elem))+44
(pair? (cadr elem))+45
(eq? '@ (car (cadr elem)))))+46
+47
(define (web-client-sxml-attributes elem)+48
(if (web-client-sxml-has-attributes? elem)+49
(cdr (cadr elem))+50
'()))+51
+52
(define (web-client-sxml-content elem)+53
(if (web-client-sxml-has-attributes? elem)+54
(cddr elem)+55
(cdr elem)))+56
+57
(define (web-client-attribute-value attr)+58
(if (pair? (cdr attr))+59
(cadr attr)+60
#t))+61
+62
(define (web-client-value->string value)+63
(cond+64
((string? value) value)+65
((number? value) (number->string value))+66
((symbol? value) (symbol->string value))+67
((boolean? value) (if value "true" "false"))+68
(else "")))+69
+70
(define (web-client-dom-attribute? attr)+71
(and (pair? attr)+72
(symbol? (car attr))+73
(not (eq? (car attr) 'key))))+74
+75
(define (web-client-find-attr attrs name)+76
(let loop ((rest attrs))+77
(cond+78
((null? rest) #f)+79
((and (pair? (car rest))+80
(eq? (car (car rest)) name))+81
(car rest))+82
(else (loop (cdr rest))))))+83
+84
(define (web-client-attr-equal? a b)+85
(cond+86
((and (not a) (not b)) #t)+87
((or (not a) (not b)) #f)+88
(else (equal? (web-client-attribute-value a)+89
(web-client-attribute-value b)))))+90
+91
(define (web-client-key-from-attrs attrs)+92
(let ((key-attr (web-client-find-attr attrs 'key)))+93
(if key-attr+94
(web-client-value->string (web-client-attribute-value key-attr))+95
#f)))+96
+97
(define (web-client-key value)+98
(if (web-client-sxml-element? value)+99
(web-client-key-from-attrs (web-client-sxml-attributes value))+100
#f))+101
+102
(define (web-client-record-kind record)+103
(if (pair? record) (car record) #f))+104
+105
(define (web-client-record-node record)+106
(cadr record))+107
+108
(define (web-client-record-tag record)+109
(caddr record))+110
+111
(define (web-client-record-attrs record)+112
(cadddr record))+113
+114
(define (web-client-record-children record)+115
(car (cdr (cdr (cdr (cdr record))))))+116
+117
(define (web-client-record-key record)+118
(if (and record (eq? (web-client-record-kind record) 'element))+119
(web-client-key-from-attrs (web-client-record-attrs record))+120
#f))+121
+122
(define (web-client-root-record-node record)+123
(cadr record))+124
+125
(define (web-client-root-record-children record)+126
(caddr record))+127
+128
(define (web-client-find-root selector)+129
(let loop ((rest web-client-roots))+130
(cond+131
((null? rest) #f)+132
((string=? selector (car (car rest))) (car rest))+133
(else (loop (cdr rest))))))+134
+135
(define (web-client-store-root! selector record)+136
(let ((entry (web-client-find-root selector)))+137
(if entry+138
(set-cdr! entry record)+139
(set! web-client-roots+140
(cons (cons selector record) web-client-roots)))))+141
+142
(define (web-client-reset!)+143
(set! web-client-roots '())+144
#t)+145
+146
(define (web-client-normalize-children value)+147
(cond+148
((not value) '())+149
((and (web-client-sxml-element? value)+150
(or (eq? (web-client-sxml-tag value) '*TOP*)+151
(eq? (web-client-sxml-tag value) 'document)))+152
(web-client-sxml-content value))+153
((and (pair? value) (not (web-client-sxml-element? value)))+154
value)+155
(else (list value))))+156
+157
(define (web-client-apply-attrs node old-attrs new-attrs)+158
(for-each+159
(lambda (attr)+160
(when (web-client-dom-attribute? attr)+161
(let* ((name-symbol (car attr))+162
(new-attr (web-client-find-attr new-attrs name-symbol)))+163
(when (not new-attr)+164
(wasm-dom-remove-attribute node (symbol->string name-symbol))))))+165
old-attrs)+166
(for-each+167
(lambda (attr)+168
(when (web-client-dom-attribute? attr)+169
(let* ((name-symbol (car attr))+170
(name (symbol->string name-symbol))+171
(value (web-client-attribute-value attr))+172
(old-attr (web-client-find-attr old-attrs name-symbol)))+173
(when (not (web-client-attr-equal? old-attr attr))+174
(cond+175
((string=? name "class")+176
(wasm-dom-set-class node (web-client-value->string value)))+177
((not value)+178
(wasm-dom-remove-attribute node name))+179
(else+180
(wasm-dom-set-attribute node name (web-client-value->string value))))))))+181
new-attrs))+182
+183
(define (web-client-text-value value)+184
(web-client-value->string value))+185
+186
(define (web-client-text-value? value)+187
(or (string? value) (number? value) (symbol? value)))+188
+189
(define (web-client-create-node value)+190
(cond+191
((web-client-text-value? value)+192
(let* ((text (web-client-text-value value))+193
(node (wasm-dom-create-text-node text)))+194
(list 'text node text)))+195
((web-client-sxml-element? value)+196
(let* ((tag (web-client-sxml-tag value))+197
(attrs (web-client-sxml-attributes value))+198
(node (wasm-dom-create-element (symbol->string tag)))+199
(children (web-client-create-children+200
node+201
(web-client-sxml-content value))))+202
(web-client-apply-attrs node '() attrs)+203
(list 'element node tag attrs children)))+204
(else+205
(let ((node (wasm-dom-create-text-node "")))+206
(list 'text node "")))))+207
+208
(define (web-client-create-children parent values)+209
(let loop ((rest (web-client-normalize-children values)) (records '()))+210
(if (null? rest)+211
(reverse records)+212
(let ((record (web-client-create-node (car rest))))+213
(wasm-dom-append-child parent (web-client-record-node record))+214
(loop (cdr rest) (cons record records))))))+215
+216
(define (web-client-nth values index)+217
(let loop ((rest values) (i index))+218
(cond+219
((null? rest) #f)+220
((= i 0) (car rest))+221
(else (loop (cdr rest) (- i 1))))))+222
+223
(define (web-client-find-record-by-key records key)+224
(let loop ((rest records))+225
(cond+226
((null? rest) #f)+227
((let ((record-key (web-client-record-key (car rest))))+228
(and record-key key (string=? record-key key)))+229
(car rest))+230
(else (loop (cdr rest))))))+231
+232
(define (web-client-compatible? record value)+233
(cond+234
((not record) #f)+235
((web-client-text-value? value)+236
(eq? (web-client-record-kind record) 'text))+237
((web-client-sxml-element? value)+238
(and (eq? (web-client-record-kind record) 'element)+239
(eq? (web-client-record-tag record)+240
(web-client-sxml-tag value))))+241
(else #f)))+242
+243
(define (web-client-patch-node parent old-record value)+244
(if (web-client-compatible? old-record value)+245
(cond+246
((web-client-text-value? value)+247
(let ((text (web-client-text-value value))+248
(node (web-client-record-node old-record)))+249
(when (not (string=? text (caddr old-record)))+250
(wasm-dom-set-text node text))+251
(list 'text node text)))+252
((web-client-sxml-element? value)+253
(let* ((node (web-client-record-node old-record))+254
(tag (web-client-sxml-tag value))+255
(old-attrs (web-client-record-attrs old-record))+256
(new-attrs (web-client-sxml-attributes value))+257
(children (web-client-patch-children+258
node+259
(web-client-record-children old-record)+260
(web-client-sxml-content value))))+261
(web-client-apply-attrs node old-attrs new-attrs)+262
(list 'element node tag new-attrs children)))+263
(else (web-client-create-node value)))+264
(web-client-create-node value)))+265
+266
(define (web-client-patch-children parent old-records new-values)+267
(let loop ((rest (web-client-normalize-children new-values))+268
(index 0)+269
(records '()))+270
(if (null? rest)+271
(let ((ordered (reverse records)))+272
(wasm-dom-replace-children parent)+273
(for-each+274
(lambda (record)+275
(wasm-dom-append-child parent (web-client-record-node record)))+276
ordered)+277
ordered)+278
(let* ((value (car rest))+279
(key (web-client-key value))+280
(old-record (if key+281
(web-client-find-record-by-key old-records key)+282
(web-client-nth old-records index)))+283
(record (web-client-patch-node parent old-record value)))+284
(loop (cdr rest) (+ index 1) (cons record records))))))+285
+286
(define (web-client-mount root view)+287
(if (web-client-root? root)+288
(wasm-dom-mount-sxml root (web-client-render view))+289
#f))+290
+291
(define (web-client-update selector view)+292
(let ((root (web-client-root selector)))+293
(if root+294
(let* ((value (web-client-render view))+295
(entry (web-client-find-root selector))+296
(old-root-record (if entry (cdr entry) #f))+297
(old-children (if (and old-root-record+298
(= root (web-client-root-record-node old-root-record)))+299
(web-client-root-record-children old-root-record)+300
'()))+301
(children (web-client-patch-children root old-children value)))+302
(web-client-store-root! selector (list 'root root children))+303
#t)+304
#f)))+305
+306
(define (web-client-clear selector)+307
(let ((root (web-client-root selector)))+308
(if root+309
(begin+310
(wasm-dom-replace-children root)+311
(web-client-store-root! selector (list 'root root '()))+312
#t)+313
#f)))))test/test-client.sgladded
@@ -0,0 +1,24 @@
+1
(import (sigil test)+2
(sigil web client))+3
+4
(test-group "web-client-root?"+5
(test "accepts non-zero node handles"+6
(assert-true (web-client-root? 1))+7
(assert-true (web-client-root? 42)))+8
+9
(test "rejects missing or invalid handles"+10
(assert-false (web-client-root? 0))+11
(assert-false (web-client-root? #f))+12
(assert-false (web-client-root? "1"))))+13
+14
(test-group "web-client-render"+15
(test "returns SXML values directly"+16
(assert-equal '(div "Hello") (web-client-render '(div "Hello"))))+17
+18
(test "evaluates nullary view procedures"+19
(assert-equal '(span "Hi")+20
(web-client-render (lambda () '(span "Hi"))))))+21
+22
(test-group "web-client-reset!"+23
(test "clears retained root state"+24
(assert-true (web-client-reset!))))