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 test
README.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-Clause
dev-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!))))