Tutorial
Build a small, complete site — data, components, a store, a form, a theme, a test — and put it on the web.
In this chapter you build Shelf, a small site that lists books worth reading and lets a reader keep the ones they want. Along the way you use most of what a real site needs: typed data read at build time, a reusable component, a page per book, a store the reader's choices are kept in, a validated form, a theme with a dark mode, a test that clicks, and a deploy.
Each step says what to write and why, and links the chapter that covers the idea in depth. It takes about half an hour.
You need wf installed (Getting started).
Step 1: a project
bash
The static template pre-renders every page to HTML — right for a site people find through search. Delete what the template put in src/; you will write every file yourself. Replace webfluent.app.json with:
json
{
"name": "shelf",
"build": { "ssg": true, "csp": true },
"theme": { "name": "Paper", "dark": "Ink" },
"meta": {
"title": "Shelf",
"description": "Books worth reading, and the ones you want to.",
"lang": "en"
}
}
ssg pre-renders; csp ships a Content-Security-Policy and makes the build check its own output against it; theme names the themes you will declare in step 7. Configuration lists every key.
Step 2: the data
The books are data, not code. Put them in src/books.json:
json
[
{ "slug": "notes-on-the-analytical-engine", "title": "Notes on the Analytical Engine",
"author": "Ada Lovelace", "year": 1843, "pages": 66,
"summary": "The first published algorithm, and a view of what a machine might do beyond arithmetic.",
"tags": ["computing", "history"] },
{ "slug": "the-design-of-everyday-things", "title": "The Design of Everyday Things",
"author": "Don Norman", "year": 1988, "pages": 368,
"summary": "Why doors confuse us, and what that says about everything else we make.",
"tags": ["design"] },
{ "slug": "a-pattern-language", "title": "A Pattern Language",
"author": "Christopher Alexander", "year": 1977, "pages": 1171,
"summary": "Two hundred and fifty-three patterns for towns, buildings and rooms people want to live in.",
"tags": ["design", "architecture"] }
]
Then describe it, in src/types.wf:
wf
/// One book on the list.
type Book {
slug: String
title: String
author: String
year: Number
pages: Number
summary: String
tags: [String] = []
}
data books: [Book] = "books.json"
type Book is a record the type checker holds every use to: book.titel is an error where you write it. data books reads the file at build time and makes it a constant every page can read — nothing is fetched when the page loads. (Types, Data.)
Step 3: a component
Every book is shown the same way, so it is a component. src/components/BookCard.wf:
wf
// … reads what the other steps declare
/// A book at a glance, with the button that puts it on the shelf.
component BookCard(_ book: Book) {
use Shelf
Card.outlined {
Stack(gap: .sm) {
Link(book.title, to: "/books/{book.slug}")
Text("{book.author} · {book.year}").muted.sm
Text(book.summary)
Row(gap: .xs) {
for tag in book.tags { Tag(tag) }
}
Button(if Shelf.saved.includes(book.slug) { "On your shelf" } else { "Want to read" }).sm {
on click { Shelf.toggle(book.slug) }
}
}
}
}
_ book: Bookis the one prop a caller may pass without its name:BookCard(book).Card.outlined,Stack(gap: .sm),Text(…).muted.smare built-ins with flags; the compiler knows each one's props and refuses a flag it does not take.use Shelfreads a store you write next. The button's label is anifused as a value, and it follows the store: when the reader clicks, the label changes without anything else re-rendering.
(Components, Elements.)
Step 4: the reader's shelf
What the reader keeps must survive a reload and be the same on every page — a store with a persisted value. src/stores/Shelf.wf:
wf
/// The books the reader wants to read, kept across visits.
store Shelf {
persist saved: [String] = []
derived count = saved.length
action toggle(slug: String) {
if saved.includes(slug) {
saved = saved.filter(s => s != slug)
} else {
saved = [...saved, slug]
}
}
}
persist is a state kept in the browser's storage and read back on the next visit; a change in another tab arrives here too. derived count recomputes whenever saved changes. (Stores.)
Step 5: pages
The shell every page shares, src/App.wf:
wf
// … reads what the other steps declare
app {
Navbar {
Navbar.Brand { Link("Shelf", to: "/") }
Navbar.Links {
Link("Books", to: "/")
Link("Your shelf ({Shelf.count})", to: "/shelf")
Link("Suggest a book", to: "/suggest")
}
}
Router
Footer { Text("Built with WebFluent").muted.sm }
}
The home page lists the books with a search box, src/pages/Home.wf:
wf
// … reads what the other steps declare
page Home(path: "/", title: "Shelf", description: "Books worth reading, and the ones you want to.") {
state q = ""
derived needle = q.trim().toLowerCase()
derived found = books.filter(b => b.title.toLowerCase().includes(needle) || b.author.toLowerCase().includes(needle))
Container {
Stack(gap: .lg) {
Heading("Books worth reading").h1
Input(bind: q, label: "Search by title or author").search
Text("{found.length} of {books.length} books").muted.sm
Grid(columns: { base: 1, md: 2 }, gap: .md) {
for book in found by book.slug { BookCard(book) }
}
if found.length == 0 { Text("Nothing matches “{q}”.").muted }
}
}
}
bind: qkeeps the input and the state in step both ways.foundis derived fromq, so typing narrows the list as you go.for … by book.slugis a keyed loop: a card that stays in the list keeps its element, rather than being rebuilt.Grid(columns: { base: 1, md: 2 })is one column on a phone and two from themdbreakpoint — written in the first paint, no script needed.
A page per book, src/pages/BookPage.wf:
wf
// … reads what the other steps declare
page BookPage(path: "/books/:slug", slug: String, title: "Book", description: "One book on the shelf.", type: "article", paths: books.map(b => b.slug)) {
derived book = books.find(b => b.slug == slug)
head {
meta(property: "og:title", content: book?.title ?? "Book")
}
Container {
if let b = book {
Breadcrumb {
Breadcrumb.Item(to: "/") { Text("Books") }
Breadcrumb.Item { Text(b.title) }
}
Heading(b.title).h1
Text("{b.author}, {b.year} · {b.pages} pages").muted
Paragraph(b.summary)
} else {
Heading("No such book").h1
Link("Back to the list", to: "/")
}
}
}
:slugin the path is a typed parameter of the page.paths:tells the static build which values to render: one HTML file per book,books/a-pattern-language/index.htmland so on.books.find(…)may find nothing, sobookis aBook?.if let b = bookbinds it only when it is there — readingbook.titlewithout it is a compile error (T04).head { … }adds tags to this page's<head>, here the title a shared link previews with.
The shelf itself, src/pages/ShelfPage.wf:
wf
// … reads what the other steps declare
page ShelfPage(path: "/shelf", title: "Your shelf", description: "The books you want to read.") {
use Shelf
derived mine = books.filter(b => Shelf.saved.includes(b.slug))
Container {
Stack(gap: .lg) {
Heading("Your shelf").h1
if mine.length == 0 {
Text("Nothing yet. Pick something from the list.").muted
Link("Browse the books", to: "/")
} else {
for book in mine by book.slug { BookCard(book) }
}
}
}
}
And a page for addresses nobody claims, src/pages/NotFound.wf:
wf
page NotFound(path: "*", title: "Not found", description: "Nothing lives at this address.", noindex: true) {
Container {
Heading("Nothing lives here").h1
Link("Back to the books", to: "/")
}
}
Run wf serve and open http://localhost:3000: search, open a book, put two on your shelf, reload — they are still there. (Pages and routing.)
Step 6: a form
Readers suggest books. src/pages/Suggest.wf:
wf
page Suggest(path: "/suggest", title: "Suggest a book", description: "Tell us what belongs on the shelf.") {
state title = ""
state author = ""
state why = ""
state sent = false
validate title { required "Which book?" }
validate author { required "Who wrote it?" }
validate why { maxLength(280) "Keep it under 280 characters" }
Container {
Stack(gap: .lg) {
Heading("Suggest a book").h1
if sent { Alert("Thank you — we will take a look.").success }
Form(bind: form) {
on submit {
sent = true
form.reset()
}
Input(bind: title, label: "Title")
Input(bind: author, label: "Author")
Textarea(bind: why, label: "Why it belongs here", rows: 4, maxLength: 280)
Button("Send the suggestion", type: .submit).primary
}
}
}
}
A validate block sits beside the state it guards, and the control bound to that state shows its messages — accessibly, with aria-invalid and the message announced. A submit that fails moves focus to the first field with a problem and does not run on submit. (Forms.)
A real site would send the suggestion somewhere. With a backend, describe it once and call it:
wf
api Suggestions(base: env.PUBLIC_API ?? "/api") {
post suggest(body: Map)
}
…
on submit {
await Suggestions.suggest(body: { title: title, author: author, why: why })
sent = true
form.reset()
}
Step 7: a look of its own
A theme changes the design tokens every built-in is drawn with. src/theme.wf:
wf
theme Paper {
color-primary: #6D28D9
font-family: Charter, Georgia, serif
radius-md: 10px
}
theme Ink {
color-background: #111014
color-surface: #1A1820
color-text: #ECEAF2
color-text-muted: #A8A3B5
color-border: #2B2833
}
The config named Paper as the theme and Ink as the dark one, so a reader whose system is dark gets Ink — before the first paint, with no flash. A token you do not name keeps its baseline value. (Styling.)
Step 8: a test
tests/BookCard.wf:
wf
// … reads what the other steps declare
test "a card shows who wrote the book, and when" {
BookCard(Book(slug: "x", title: "X", author: "Ada Lovelace", year: 1843, pages: 66, summary: "Notes."))
expect "Ada Lovelace · 1843"
expect "Want to read"
}
test "a book goes on the shelf, and comes off again" {
BookCard(Book(slug: "x", title: "X", author: "Ada Lovelace", year: 1843, pages: 66, summary: "Notes."))
click "Want to read"
expect "On your shelf"
click "On your shelf"
expect "Want to read"
}
bash
The first test renders the card and reads what it shows; it also writes a snapshot the next run compares against. The second clicks, so it runs in a headless Chrome — the same compiler and runtime a reader gets. Buttons are found by their text, as a reader finds them. (Testing.)
Step 9: build and check
bash
wf build writes build/ — a folder of HTML, CSS and JavaScript any static host can serve — and prints every warning: a missing alt, a page without a description, a state nothing reads. wf verify loads every page in a real browser and fails on a script error, a failed request or a blank page.
Step 10: deploy
Any static host serves build/. For GitHub Pages, a project site lives under https://<you>.github.io/shelf/, so tell the build its sub-path and its address — add to webfluent.app.json:
json
{
"build": { "ssg": true, "csp": true, "base_path": "/shelf" },
"meta": { "site_url": "https://you.github.io/shelf" }
}
base_path prefixes every link and asset; site_url turns on canonical links, the sitemap and absolute Open Graph URLs. Then add .github/workflows/pages.yml:
yaml
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- run: curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | bash
- run: ~/.webfluent/bin/wf build
- uses: actions/upload-pages-artifact@v3
with:
path: build
- id: deployment
uses: actions/deploy-pages@v4
Push, and in the repository's Settings → Pages choose GitHub Actions as the source. Deploying covers Netlify, Vercel, Cloudflare Pages, nginx and the rest.
The whole program
Every file above, as one listing — this is exactly what the steps built:
wf
type Book {
slug: String
title: String
author: String
year: Number
pages: Number
summary: String
tags: [String] = []
}
data books: [Book] = "books.json"
store Shelf {
persist saved: [String] = []
derived count = saved.length
action toggle(slug: String) {
if saved.includes(slug) {
saved = saved.filter(s => s != slug)
} else {
saved = [...saved, slug]
}
}
}
component BookCard(_ book: Book) {
use Shelf
Card.outlined {
Stack(gap: .sm) {
Link(book.title, to: "/books/{book.slug}")
Text("{book.author} · {book.year}").muted.sm
Text(book.summary)
Row(gap: .xs) {
for tag in book.tags { Tag(tag) }
}
Button(if Shelf.saved.includes(book.slug) { "On your shelf" } else { "Want to read" }).sm {
on click { Shelf.toggle(book.slug) }
}
}
}
}
app {
Navbar {
Navbar.Brand { Link("Shelf", to: "/") }
Navbar.Links {
Link("Books", to: "/")
Link("Your shelf ({Shelf.count})", to: "/shelf")
Link("Suggest a book", to: "/suggest")
}
}
Router
Footer { Text("Built with WebFluent").muted.sm }
}
page Home(path: "/", title: "Shelf", description: "Books worth reading, and the ones you want to.") {
state q = ""
derived needle = q.trim().toLowerCase()
derived found = books.filter(b => b.title.toLowerCase().includes(needle) || b.author.toLowerCase().includes(needle))
Container {
Stack(gap: .lg) {
Heading("Books worth reading").h1
Input(bind: q, label: "Search by title or author").search
Text("{found.length} of {books.length} books").muted.sm
Grid(columns: { base: 1, md: 2 }, gap: .md) {
for book in found by book.slug { BookCard(book) }
}
if found.length == 0 { Text("Nothing matches “{q}”.").muted }
}
}
}
page BookPage(path: "/books/:slug", slug: String, title: "Book", description: "One book on the shelf.", type: "article", paths: books.map(b => b.slug)) {
derived book = books.find(b => b.slug == slug)
head {
meta(property: "og:title", content: book?.title ?? "Book")
}
Container {
if let b = book {
Breadcrumb {
Breadcrumb.Item(to: "/") { Text("Books") }
Breadcrumb.Item { Text(b.title) }
}
Heading(b.title).h1
Text("{b.author}, {b.year} · {b.pages} pages").muted
Paragraph(b.summary)
} else {
Heading("No such book").h1
Link("Back to the list", to: "/")
}
}
}
page ShelfPage(path: "/shelf", title: "Your shelf", description: "The books you want to read.") {
use Shelf
derived mine = books.filter(b => Shelf.saved.includes(b.slug))
Container {
Stack(gap: .lg) {
Heading("Your shelf").h1
if mine.length == 0 {
Text("Nothing yet. Pick something from the list.").muted
Link("Browse the books", to: "/")
} else {
for book in mine by book.slug { BookCard(book) }
}
}
}
}
page Suggest(path: "/suggest", title: "Suggest a book", description: "Tell us what belongs on the shelf.") {
state title = ""
state author = ""
state why = ""
state sent = false
validate title { required "Which book?" }
validate author { required "Who wrote it?" }
validate why { maxLength(280) "Keep it under 280 characters" }
Container {
Stack(gap: .lg) {
Heading("Suggest a book").h1
if sent { Alert("Thank you — we will take a look.").success }
Form(bind: form) {
on submit {
sent = true
form.reset()
}
Input(bind: title, label: "Title")
Input(bind: author, label: "Author")
Textarea(bind: why, label: "Why it belongs here", rows: 4, maxLength: 280)
Button("Send the suggestion", type: .submit).primary
}
}
}
}
page NotFound(path: "*", title: "Not found", description: "Nothing lives at this address.", noindex: true) {
Container {
Heading("Nothing lives here").h1
Link("Back to the books", to: "/")
}
}
theme Paper {
color-primary: #6D28D9
font-family: Charter, Georgia, serif
radius-md: 10px
}
theme Ink {
color-background: #111014
color-surface: #1A1820
color-text: #ECEAF2
color-text-muted: #A8A3B5
color-border: #2B2833
}
test "a card shows who wrote the book, and when" {
BookCard(Book(slug: "x", title: "X", author: "Ada Lovelace", year: 1843, pages: 66, summary: "Notes."))
expect "Ada Lovelace · 1843"
expect "Want to read"
}
Where to go from here
- Read the language properly, from Language basics.
- Talk to a real server: Data and APIs.
- Make it work offline: Offline.
- Translate it: Internationalisation.
On this page
Step 1: a project Step 2: the data Step 3: a component Step 4: the reader's shelf Step 5: pages Step 6: a form Step 7: a look of its own Step 8: a test Step 9: build and check Step 10: deploy The whole program Where to go from hereChecked by the test suite
Every code block in the guide is parsed, checked and type-checked on each release.