sigildocs

Building Web Applications

This guide covers building web applications with (sigil web) and (sigil http). You'll build a small live to-do list from an empty scaffold: create, read, update, and delete items; a live item count; an inline edit that swaps a single row over a plain HTTP response; and an activity log that streams change events to every connected browser. Every visitor stays in sync, and you write no client-side JavaScript.

The finished app is about 110 lines in one file. For a larger worked example afterward, study the Vinyl Vault demo (sigil-web-demo), a record-collection CRUD app that uses the same patterns with a SQLite store.

Overview

A Sigil web app is a plain request handler: a function from an HTTP request to a response. (sigil web) gives you routing, views, and live-update helpers; (sigil http) serves the app and provides response constructors. HTML is written as SXML (s-expression markup), and http-response/page turns a page of SXML into a response, so views are ordinary functions that return data.

Every update to the browser is a ui-update: it targets an element by CSS selector and morphs, appends, or removes it. What changes is who receives it, and this guide shows both:

  • The reply to a request. A handler returns one or more ui-updates, and only the user who acted sees them. Use this for updates that are theirs alone, like opening an inline edit form or clearing a form they just submitted.
  • A broadcast to everyone. When shared state changes, ui-updates go out over a live connection to every open browser at once. Use this for updates everyone needs, like a new row in a shared list or a line in an activity feed.

Same ui-update either way. A live-collection wraps a list so changes to its rows broadcast automatically; a live-stream is the bare push channel underneath, for events that belong to no collection at all.

Scaffolding a project

Create the project from the built-in web-app template, then run it:

sigil init todo --template web-app
cd todo
sigil deps install
sigil run

sigil run builds the dev configuration and launches the entry point declared in package.sgl (entry: '(todo main)). Open http://localhost:8080 to confirm it runs, then we'll replace the example routes.

Everything in this guide lives in one module, src/todo/main.sgl. The scaffold's package.sgl already declares the sigil-http, sigil-web, and sigil-sxml dependencies and the entry:. The module header:

;; src/todo/main.sgl
(define-library (todo main)
  (import (sigil core)
          (sigil async)
          (sigil http)
          (sigil http request)
          (sigil http response)
          (sigil web)
          (sigil web ui)
          (sigil web live))
  (export main)
  (begin
    ;; everything below goes here
    ))

Views

Views are plain functions that return SXML: HTML written as Sigil data. A tag is a list headed by the tag name, an (@ ...) head holds its attributes, and quasiquote (` `) with unquote (,) splices your values in. item-row` renders one to-do as a list item:

;; src/todo/main.sgl
(define (item-row item)
  (let ((ids (number->string (dict-ref item id:)))
        (done? (dict-ref item done?:)))
    `(li (@ (id ,(string-append "item-" ids))
            (class ,(if done? "done" "")))
       ,(sg-button (if done? "Undo" "Done") action: (string-append "/items/" ids "/toggle"))
       (span (@ (class "text")) ,(dict-ref item text:))
       ,(sg-link "Edit" action: (string-append "/items/" ids "/edit")
                 target: (string-append "#item-" ids))
       ,(sg-button "Delete" action: (string-append "/items/" ids "/delete")))))

Two things about the data. Items are dicts, read with keyword access ((dict-ref item id:), not a quoted symbol). And each row carries a stable id (item-<n>), which is how the live layer finds it to update or remove it later. For SXML itself, see the (sigil sxml) reference.

The interactive pieces come from (sigil web ui). sg-button and sg-link take a label and an action: (the route to hit); sg-button defaults to POST, a sg-link stays a GET. sg-form groups fields and submits them:

;; src/todo/main.sgl
(define (add-form)
  (sg-form
    (list (sg-text-field name: "text" placeholder: "What needs doing?" autofocus: #t)
          (sg-submit-button label: "Add"))
    action: "/items" id: "add-form"))

You never write client-side code to wire these up. For the full set of components and form fields, see the (sigil web) UI reference.

The "live collection"

live-collection is the heart of the app. It owns a store of items, a broadcast stream, and the rendering, so mutating it pushes the right patch to every connected browser. You give it a render: function for one item, the container: selector its rows live in, and a path: for its live stream:

;; src/todo/main.sgl
(define todos
  (live-collection render: item-row container: "#todo-list" path: "/events"))

Read it with live-all / live-get, and mutate it with live-add! / live-update! / live-remove!, each taking the collection as its first argument. A mutation does two things: it changes the store, and it broadcasts the matching patch (append a new row, morph a changed row by id, remove a deleted row) to everyone. It returns the affected item, not a response.

Render the list into a page with live-list-view, which emits the <ul> of current rows plus the hidden element that subscribes the page to the stream:

;; src/todo/main.sgl
(define (home-handler req)
  (http-response/page "To-Do"
    `(div (@ (class "app"))
       (h1 "To-Do")
       ,(add-form)
       ,(live-list-view todos))))

http-response/page takes a title and a body of SXML, wraps them in a full HTML document, injects the client script, and returns a text/html response. You never hand-write a doctype or a <head>. When you outgrow the default (a custom template:, or http-response/sxml for a page you assemble yourself), the (sigil web) reference has the details.

Items are immutable dicts, so a mutation builds a new dict and swaps it in. The handlers stay tiny, because the collection does the broadcasting:

;; src/todo/main.sgl
(define (item-id req) (string->number (path-param req "id")))
(define (form-text req) (assoc-ref 'text (parse-form-data req)))

(define (toggle-handler req)
  (live-update! todos (item-id req)
    (lambda (i) (dict-set i done?: (not (dict-ref i done?:))))))

(define (delete-handler req)
  (live-remove! todos (item-id req)))

Neither handler returns a response. Under ui-routes (see Wiring, below), a handler that returns a non-response value answers with an empty acknowledgement, because the visible change already went out over the stream. The mental model is: a mutation is what everyone sees; the return value is what only the acting user sees.

Deriving UI from state: a live count

Some UI is a pure function of the whole list, like a count or a "3 of 5 done" summary. For that, give the collection an on-change: hook. It receives the current items after every mutation and returns a ui-update the collection broadcasts:

;; src/todo/main.sgl
(define (count-label n)
  (cond ((= n 0) "no items")
        ((= n 1) "1 item")
        (else (string-append (number->string n) " items"))))

(define todos
  (live-collection render: item-row container: "#todo-list" path: "/events"
    on-change: (lambda (items)
                 (ui-update target: "#count" mode: "inner"
                            content: (count-label (length items))))))

Put a #count element on the page and it updates itself on every change, with no handler code:

;; src/todo/main.sgl  (in home-handler's body)
(h1 "To-Do "
  (span (@ (id "count") (class "count")) ,(count-label (length (live-all todos)))))

on-change: sees the list, not the action, so it's the right tool for state-derived UI. For an event that carries meaning the state alone can't (which item, what happened), push it yourself. That's next.

Editing in place with an HTML fragment

Inline editing is the clearest use of a plain-HTTP fragment. Clicking Edit returns a fragment that swaps just that row for an edit form, and only the acting user sees it (there's no reason to broadcast a half-finished edit). You build the response with ui-response wrapping a ui-update:

;; src/todo/main.sgl
(define (edit-row item)
  (let ((ids (number->string (dict-ref item id:))))
    `(li (@ (id ,(string-append "item-" ids)))
       ,(sg-form
          (list (sg-text-field name: "text" value: (dict-ref item text:) autofocus: #t)
                (sg-submit-button label: "Save"))
          action: (string-append "/items/" ids) target: (string-append "#item-" ids)))))

(define (edit-handler req)
  (let ((item (live-get todos (item-id req))))
    (if item
        (ui-response (ui-update target: (string-append "#item-" (number->string (dict-ref item id:)))
                                content: (edit-row item)))
        (ui-response status: 404))))

ui-response is the single actor-response builder: it carries one or more ui-updates back to the requester over the same HTTP request. A ui-update's default mode: "morph" morphs the target element itself, so the edit form replaces the row in place. Saving posts to /items/:id, which runs live-update! and broadcasts the updated row to everyone.

Arbitrary live events: an activity log

The list stays in sync because it's a collection. But plenty of live UI has no collection behind it: a chat feed, a news ticker, or a notification toast. For those, use the layer the collection is built on: a live-stream, a bare push channel you send ui-updates to.

Give it a path: and drop its subscription element on the page. Then live-push! any ui-update at any element, any time:

;; src/todo/main.sgl
(define log (live-stream path: "/log"))

(define (log-line action text)
  `(li (span (@ (class "log-action")) ,action) " " ,text))

(define (note! action text)
  (live-push! log (ui-update target: "#log" mode: "append"
                             content: (log-line action text))))

Now each handler records what it did. The collection broadcasts the row change; the stream broadcasts the activity line. The handler knows the human action (Completed vs Reopened), which the state alone could not tell you, so this belongs here rather than in on-change::

;; src/todo/main.sgl
(define (create-handler req)
  (let ((text (form-text req)))
    (when (and text (> (string-length text) 0))
      (live-add! todos (dict text: text done?: #f))    ; collection: append the row
      (note! "Added" text))                            ; stream: append a log line
    (ui-response (ui-update target: "#add-form" content: (add-form)))))  ; you: reset the form

(define (toggle-handler req)
  (let ((item (live-update! todos (item-id req)
                (lambda (i) (dict-set i done?: (not (dict-ref i done?:)))))))
    (when item
      (note! (if (dict-ref item done?:) "Completed" "Reopened") (dict-ref item text:)))))

(define (delete-handler req)
  (let ((item (live-remove! todos (item-id req))))
    (when item (note! "Deleted" (dict-ref item text:)))))

(define (update-handler req)
  (let ((item (live-update! todos (item-id req)
                (lambda (i) (dict-set i text: (form-text req))))))
    (when item (note! "Edited" (dict-ref item text:)))))

Add the feed and its subscription to the page:

;; src/todo/main.sgl  (in home-handler's body, after the list)
(h2 "Activity")
(ul (@ (id "log")))
,(live-subscribe log)

live-subscribe returns the hidden element that connects the page to the stream, exactly like live-list-view does for a collection. This is the fully general pattern: (live-stream path: "/room") plus live-push! is all a chat room or a MUD's output pane needs. live-collection is just a live-stream with a store and a renderer.

Wiring it together

Three helpers finish the app. ui-routes is a routes analog for UI handlers: a handler that returns a non-response value gets an empty acknowledgement, which is why the mutating handlers above needed no explicit return. JSON or webhook routes stay under plain routes/router, never coerced. live-routes registers a stream's or collection's endpoint. with-sigil-ui serves the client script that http-response/page references.

;; src/todo/main.sgl
(define app
  (-> (ui-routes
        (router
          (route method: GET  pattern: "/"                 handler: home-handler)
          (route method: POST pattern: "/items"            handler: create-handler)
          (route method: POST pattern: "/items/:id/toggle" handler: toggle-handler)
          (route method: POST pattern: "/items/:id/delete" handler: delete-handler)
          (route method: GET  pattern: "/items/:id/edit"   handler: edit-handler)
          (route method: POST pattern: "/items/:id"        handler: update-handler))
        (live-routes todos)
        (live-routes log))
      (with-sigil-ui)
      (with-logging)
      (with-not-found)))

(define (main)
  (display "To-Do running at http://localhost:8080\n")
  (with-async
    (http-serve app port: 8080)))

main just serves. The stream's heartbeat starts itself on the first connection, so there's nothing to wire by hand.

Run it with sigil run, open two browser windows at http://localhost:8080, and add an item in one. The row, the count, and the activity line all appear in both.

Complete example

src/todo/main.sgl:

;;; (todo main) - A live shared to-do list

(define-library (todo main)
  (import (sigil core)
          (sigil async)
          (sigil http)
          (sigil http request)
          (sigil http response)
          (sigil web)
          (sigil web ui)
          (sigil web live))
  (export main)

  (begin
    ;; Views
    (define (count-label n)
      (cond ((= n 0) "no items")
            ((= n 1) "1 item")
            (else (string-append (number->string n) " items"))))

    (define (log-line action text)
      `(li (span (@ (class "log-action")) ,action) " " ,text))

    (define (add-form)
      (sg-form
        (list (sg-text-field name: "text" placeholder: "What needs doing?" autofocus: #t)
              (sg-submit-button label: "Add"))
        action: "/items" id: "add-form"))

    (define (item-row item)
      (let ((ids (number->string (dict-ref item id:)))
            (done? (dict-ref item done?:)))
        `(li (@ (id ,(string-append "item-" ids))
                (class ,(if done? "done" "")))
           ,(sg-button (if done? "Undo" "Done") action: (string-append "/items/" ids "/toggle"))
           (span (@ (class "text")) ,(dict-ref item text:))
           ,(sg-link "Edit" action: (string-append "/items/" ids "/edit")
                     target: (string-append "#item-" ids))
           ,(sg-button "Delete" action: (string-append "/items/" ids "/delete")))))

    (define (edit-row item)
      (let ((ids (number->string (dict-ref item id:))))
        `(li (@ (id ,(string-append "item-" ids)))
           ,(sg-form
              (list (sg-text-field name: "text" value: (dict-ref item text:) autofocus: #t)
                    (sg-submit-button label: "Save"))
              action: (string-append "/items/" ids) target: (string-append "#item-" ids)))))

    ;; Live state: a collection (list + derived count) and a bare stream (log)
    (define todos
      (live-collection render: item-row container: "#todo-list" path: "/events"
        on-change: (lambda (items)
                     (ui-update target: "#count" mode: "inner"
                                content: (count-label (length items))))))

    (define log (live-stream path: "/log"))

    (define (note! action text)
      (live-push! log (ui-update target: "#log" mode: "append"
                                 content: (log-line action text))))

    ;; Handlers
    (define (item-id req) (string->number (path-param req "id")))
    (define (form-text req) (assoc-ref 'text (parse-form-data req)))

    (define (home-handler req)
      (http-response/page "To-Do"
        `(div (@ (class "app"))
           (h1 "To-Do "
             (span (@ (id "count") (class "count")) ,(count-label (length (live-all todos)))))
           ,(add-form)
           ,(live-list-view todos)
           (h2 "Activity")
           (ul (@ (id "log")))
           ,(live-subscribe log))))

    (define (create-handler req)
      (let ((text (form-text req)))
        (when (and text (> (string-length text) 0))
          (live-add! todos (dict text: text done?: #f))
          (note! "Added" text))
        (ui-response (ui-update target: "#add-form" content: (add-form)))))

    (define (toggle-handler req)
      (let ((item (live-update! todos (item-id req)
                    (lambda (i) (dict-set i done?: (not (dict-ref i done?:)))))))
        (when item
          (note! (if (dict-ref item done?:) "Completed" "Reopened") (dict-ref item text:)))))

    (define (delete-handler req)
      (let ((item (live-remove! todos (item-id req))))
        (when item (note! "Deleted" (dict-ref item text:)))))

    (define (update-handler req)
      (let ((item (live-update! todos (item-id req)
                    (lambda (i) (dict-set i text: (form-text req))))))
        (when item (note! "Edited" (dict-ref item text:)))))

    (define (edit-handler req)
      (let ((item (live-get todos (item-id req))))
        (if item
            (ui-response (ui-update target: (string-append "#item-" (number->string (dict-ref item id:)))
                                    content: (edit-row item)))
            (ui-response status: 404))))

    ;; Wiring
    (define app
      (-> (ui-routes
            (router
              (route method: GET  pattern: "/"                 handler: home-handler)
              (route method: POST pattern: "/items"            handler: create-handler)
              (route method: POST pattern: "/items/:id/toggle" handler: toggle-handler)
              (route method: POST pattern: "/items/:id/delete" handler: delete-handler)
              (route method: GET  pattern: "/items/:id/edit"   handler: edit-handler)
              (route method: POST pattern: "/items/:id"        handler: update-handler))
            (live-routes todos)
            (live-routes log))
          (with-sigil-ui)
          (with-logging)
          (with-not-found)))

    (define (main)
      (display "To-Do running at http://localhost:8080\n")
      (with-async
        (http-serve app port: 8080)))))

Where to go next

  • Vinyl Vault (sigil-web-demo): a fuller CRUD app with search, pagination, data tables, modals, and a SQLite store, using these same patterns.
  • Chat, rooms, notifications: a live-stream plus live-push! is the whole mechanism. Anywhere you'd append to a feed or update a badge from a background event, reach for a bare stream.
  • Reference: (sigil http) for the server and response constructors, (sigil web) and (sigil web live) for routing, UI helpers, collections, and streams.

Notes

  • ui-update modes. The default "morph" diffs the target element in place from a full element with a matching id (idiomorph preserves focus and scroll). "append" / "prepend" add a child; "inner" replaces children; "remove" deletes the target; "replace" swaps the whole element without diffing.
  • The log is ephemeral. A newly connected tab sees only future lines, which is the right default for an activity feed. To seed history on connect, pass an on-connect: to live-stream, the same way a collection sends its current list.
  • The store is in memory. Each connection runs on its own goroutine, so a production app would back the collection with a database rather than the default in-memory store. Vinyl Vault shows the SQLite path.
  • The client loads idiomorph from a CDN by default. Vendor and self-serve it (and the sigil-web client script) for offline or air-gapped deployments.