You are an expert WebFluent developer. WebFluent is a web-first programming language that compiles to HTML, CSS, JavaScript, and PDF. You write .wf source files and configure projects via webfluent.app.json.
project/
├── AGENTS.md # This reference — `wf init` writes it, for the version that made the project
├── webfluent.app.json # Config: theme, build, i18n, meta
├── src/
│ ├── App.wf # Root: navbar, router, footer
│ ├── pages/ # One .wf per page
│ ├── components/ # Reusable components
│ ├── stores/ # Shared state stores
│ ├── *.css # The project's own stylesheets, anywhere under src/
│ ├── *.js # The project's own plain browser scripts, anywhere under src/
│ └── translations/ # i18n JSON files (en.json, ar.json)
├── tests/ # `test "…" { }` declarations that `wf test` runs
├── public/ # Static assets → copied to build root
└── build/ # Compiled output
Files in public/ are copied to the root of the build output (not nested under public/). Example: public/logo.png → build/logo.png.
wf init <name> -t spa|static|pdf|slides [--locales en,ar] # Create a project (with this AGENTS.md in it); --locales: translated, the default first
wf build [-d DIR] [--stats] [--cache] [--studio] # Compile (--stats: what it weighs; --cache: only the pages an edit changed; --studio: node ids for an editor's preview, nodes.json)
wf check [-d DIR] [--format F] [--deny-warnings] # Every finding, nothing written
wf fix [-d DIR] [--code C] [--dry-run] # Apply every finding's fix (or one code's)
wf config schema [--json] | get PATH | set PATH VALUE | unset PATH # Every setting; edit webfluent.app.json keeping its shape
wf commands [--json] # Every command, its options, what it writes and its preview flag, as data
wf assets [--unused] [--plan /a.png] [--json] # public/: each file's kind, size, dimensions and uses; what the image pipeline makes of one
wf theme [--json] # The theme: every token, declared or inherited, its uses, the contrast pairs
wf motion [--json] # The animations, eases, speeds and route transitions, and the default
wf rules [--json] # The validate rules, named patterns, and every state held to a rule
wf scope FILE:LINE[:COL] [--json] # What can be read there: each name in scope, its type, where it comes from
wf translations [--missing L] [--unused] [--json] # The translations table: what each locale lacks, what nothing names
wf context [--open FILE] [--guide full] [--json] # Everything an agent is given about the project, private env withheld
wf components [--json] # Every component a call can name, built-in and the project's: props and their kinds, events, slots, parts
wf explain [CODE] # A diagnostic's entry: meaning, example, fix
wf serve [-d DIR] # Dev server (localhost:3000): rebuilds on save, reloads the page, shows a failed build's error over it
wf generate page|component|store <name> # Scaffold a file, in the project's layout
wf fmt [path] [--check] [--stdout] # Format a project's sources (--check fails when one would change)
wf fmt --to wfx|wf [path] [--stdout] # Switch a project between .wf and .wfx
wf test [path] [--update] # Run the `test "…" { }` declarations under tests/
wf docs [-d DIR] [-o OUT] # A component gallery: every built-in, and what the project declares
wf migrate [path] [--check] [--wfx] # WebFluent 2 → 3 (and to .wfx), 3 → 4, and 4 → 4.2 (`external` rewritten)
wf verify [path] [--json] [--budget MS] [--returning-visitor] # Load every page in a real browser: errors, failed requests, paint timing
wf audit [path] [--json] # What the project trusts: markup, origins, storage, env, policy, dependencies
wf registry [--json] # Every built-in: props, cases, flags, events, slots, parts
wf types [path] [--json] # What a project declares: enums, types, components, stores, pageswf verify builds nothing — it loads what wf build already wrote. It
starts a headless Chrome, serves the output, opens every route the
project has, and fails on an uncaught exception, a console.error, a
file that did not arrive, an image that failed, or a page that rendered
no text. It prints the first-contentful-paint, the node count and the
bytes each route fetched, says which built-ins no page drew, and takes
--budget MS to fail a route that paints too slowly. --json is the
same report for a pipeline. It needs a Chrome or Chromium on the machine
(or WF_CHROME pointing at one) and says so plainly when there is none.
16 route(s) in http://127.0.0.1:39785
ok / 236ms 613 nodes 447.0 kB 11 req
ok /app/deployments 68ms 657 nodes 360.6 kB 10 req
16 page(s), 0 problem(s)
wf serve puts a stores button in the corner of every page it serves:
what each store that has been built is holding, a log of every action with
the state on each side of it, and a click to put a store back to before one
ran.
wf serve answers a request that is not a page (a fetch, a file) and
that the build has no file for with a 404 and a line in the terminal —
only a navigation gets the shell. Its builds are development builds: a
response is held to its declared type (resource x: [User], -> User) and
a mismatch is a .parse error naming the place ($[1].name is missing); a
route no page answers shows a 404 box; a for item that throws shows where
(the rest of the list draws — on a deployed page it leaves a gap); an effect
that feeds itself stops after 100 runs with a warning.
wf verify visits :param routes at their paths: values, or at a
placeholder (/user/1); fails a page whose router drew nothing into
<main>; and with --returning-visitor visits every route again with
storage holding what the previous build's pages kept.
wf serve builds first, then watches src/, public/ and the config
(dev.hot_reload, on by default): a save rebuilds, and every page it
serves carries /__wf/dev.js, which reloads the page when a build lands
and draws the error of a build that failed over the page — with the
diagnostics — until the next build passes. /__wf/status is what it
polls.
wf test renders every test "name" { … } declared under tests/ (or in
src/) through the template engine, with the project's components,
stores, types and constants at hand, and holds the render to what it
expects and to a snapshot in tests/__snapshots__/<file>/<name>.html
(written when missing, rewritten with --update). A build ignores tests.
expect reads the text a reader sees, never the markup, laid out as a
browser lays it out — white space collapsed, blocks (and what a Row,
Stack or Grid lays out) apart, inline elements that touch touching, so
Text { Code("live") Code(": ") } reads live: rendered and in a browser
alike; t("key") reads
the project's default locale; data: { … } seeds the body's state and,
by a store's name, the store — in a render and in a browser alike; a
page with no route parameter is placed by its name, as a component is
(Tasks, then click "Add"), so a test can click through a whole page;
and wf test tests/cart.wf runs one file's tests in the project it
belongs to. The spa and static starters come with tests of their own.
test "greets by name" {
Greeting("Sam")
expect "Hello, Sam"
expect not "Hello, world"
}
test "lists the data"(data: { items: ["a", "b"] }) {
state open = true
for it in items { if open { Text(it) } }
expect "b"
}
test "reporting an incident opens one more" {
use IncidentStore
state draft = ""
Text("{IncidentStore.open} open")
Input(bind: draft, label: "What happened")
Button("Report") { on click { IncidentStore.report(draft) } }
expect "2 open"
type "Queue backing up" into "What happened"
click "Report"
expect "3 open"
}
A test with a step that acts is compiled into a real page, served and
run in a headless Chrome (or WF_CHROME), because a click runs a handler,
which runs an action, which repaints; so is one that looks at a Portal
(in its body or a component it places), whose block only the running page
draws. Its page is drawn as the built one is: the project's scripts, its
stylesheets under src/, those meta.stylesheets links, and public/
served beside it. A test that only looks needs no browser and is compared
to its snapshot as well. The steps run in the order written, everything is found
by the name a reader sees (a button's text, a control's label, placeholder
or aria-label), and a handler that throws fails the test.
| Step | What it does |
|---|---|
expect "text" · expect not "text" |
What the page must, or must not, show |
click "Save" |
Clicks whatever carries that name |
type "Ada" into "Name" |
Types into the control that label names |
press "Enter" · press "Escape" in "Search" |
A key, on the focused element or a named one; Escape closes an open Modal or Dialog |
wf docs writes docs/index.html: a self-contained gallery of every
built-in — props, cases, flags, events, slots, parts — and of the
project's own components, enums, types, stores and pages, read from the
same registry the compiler and the editor read.
wf types --json lists what the project's scripts declare beside its own
declarations — each function's parameters and return as its JSDoc types
them — so a tool knows what a call site may pass to either.
wf registry --json and wf types --json are the registry and a project's
declarations as a tool reads them — the studio's inspector offers a dropdown
for an enum prop, a toggle for a flag, a handler editor for an event and a
drop target for a slot from these, and a call site of the project's own
UserCard reads like one of Button.
One lexeme, one meaning. Declarations are lowercase keywords; elements are
capitalised; a .flag sets a boolean prop or picks a case; name: value
passes a prop; a block is the element's children; on click { } is a
handler; style { } is raw CSS. What a component takes — its props, cases,
events, slots and parts — comes from one registry the compiler, the
linters and the language server all read, and anything that resolves to
nothing is an error, never a silent no-op.
page Home(path: "/", title: "Home") {
state count = 0
Container {
Heading("Welcome").h1
Text("Count: {count}")
Button("+1").primary { on click { count = count + 1 } }
}
}
path— URL route. A dynamic segment is a typed parameter of the page:page User(path: "/user/:id", id: String) { Text(id) }title— Browser tab title, and the heading of a search result; may name the page's parameters,title: "{slug} — Blog", or splice an expression over them and the constants:"{posts.find(p => p.slug == slug)?.title ?? slug} — Blog"description— The snippet a search result and a link preview showimage— Link-preview image, site-relative or absoluteimage_alt— What that image shows (og:image:alt,twitter:image:alt)type—"website"(default) or"article"noindex: true— keeps the page out of search results and out of the sitemaplayout: Shell(crumb: "Home")— the component that frames the page; the page renders in its default slot, and the skip link jumps past the layout's own chrome to the page's first elementguard— Expression that must hold for the route to renderredirect— Where to send the visitor when the guard fails
page Post(path: "/blog/slow-roasting", title: "Slow roasting, explained",
description: "Why we take eleven minutes over it.",
type: "article", image: "/roast.png") { Container { Text("…") } }
Pages own their routes: app places a bare Router, and the table is built
from every page's path, ordered by specificity (static segments before
:params, * last), so declaration order cannot shadow a route.
A page may add tags of its own to the document's head — painted at build time where the value is known, kept current on the live page, and gone when the page is:
page Post(path: "/p/:slug", title: "Post", slug: String) {
head {
meta(property: "og:image", content: post.image)
link(rel: "canonical", href: "https://example.com/p/{slug}")
script(src: "/analytics.js", defer: true)
}
…
}
/// A person, at a glance.
component UserCard(_ name: String, role: String, active: Bool = true) {
Card.elevated {
Row(align: .center, gap: .md) {
Avatar(initials: "U").primary
Stack {
Text(name).bold
Text(role).muted
}
if active {
Badge("Active").success
}
}
}
}
// Usage
UserCard("Monzer", role: "Developer")
UserCard("Sam", role: "Designer").active
- Prop types:
String,Number,Bool,Map,Any,[T](a list),T?(may be null), or a declaredtypeorenum _ name: Type— the one prop a call may pass positionally; it must be first- Default values:
active: Bool = true; aBoolprop is set with a flag (.active), an enum prop with a flag or by name (.loud,tone: .loud) - A
///comment above a declaration, a prop or an event is its documentation, shown on hover; above astate,persist,derivedoraction, it is that member's
A prop that reads state stays live inside the component: Chip(pressed: showErrors) repaints when showErrors changes.
A component places its caller's block with children, and may declare
named slots the caller fills with name { … }. A fill is compiled in the
caller's scope, so it reads the caller's state and loop bindings; the
component only decides where it lands.
component Panel(_ title: String) {
slot trailing
Card {
Row(justify: .between) { Heading(title).h3 trailing }
children
}
}
Panel("Keys") {
trailing { Badge("Beta") }
Text("Rotate every 90 days.")
Button("Generate").primary { on click { generate() } }
}
A page's layout: is a component with a default slot, called with the page
as its block.
A scoped slot hands its fill values: the component declares what it hands over, uses the slot with those values by name, and the caller's fill names them in order.
component Rows(items: [Todo]) {
slot row(item: Todo, index: Number)
for it, i in items by it.id { row(item: it, index: i) }
}
Rows(items: todos) { row(t, i) { Text("{i}: {t.title}") } }
A component may declare parts — components of its own, called under its name:
component Panel(_ title: String) {
part Header(_ text: String) { Heading(text).h3 }
part Footer { Divider }
Card { Text(title) children }
}
Panel("Keys") { Panel.Header("Rotate") Text("…") Panel.Footer }
A component's enum props are written to its root element as
data-<prop>="<case>" (data-tone="loud"), so a stylesheet can select on
them.
A component declares the events it fires and their arguments; emit fires
one; the caller handles it with on name(args) { }, a name for each value
the event sends, in order — event menu(key: String, x: Number, y: Number)
is handled on menu(key, x, y) { }, each name typed as declared. It may
name fewer (on menu(key)), never more (T22). A DOM event written on
a component call — on click { } — attaches to the component's root
element, so a styled button component is clickable wherever it is used.
So does an attribute the component takes no prop for — aria-label,
data-*, id, role, title, hidden — on the live page, in the
static paint and in a test's render alike, written as on an element
(data-open: true is data-open="true", false none but an ARIA
state's): Avatar2(user, aria-label: "Profile").
component TodoRow(_ label: String, done: Bool = false) {
event toggle(id: String)
Row {
Checkbox(checked: done) { on change { emit toggle(label) } }
Text(label)
}
}
TodoRow("Milk") { on toggle(id) { Todos.toggle(id) } }
component Entry(_ key: String) {
event menu(key: String, x: Number, y: Number)
Button(key) { on contextmenu(e) { e.preventDefault() emit menu(key, e.clientX, e.clientY) } }
}
Entry("row-1") { on menu(key, x, y) { Menus.open(key, x, y) } }
enum Tone { calm, loud }
type Todo { id: String, title: String, done: Bool = false, tone: Tone = .calm, note: String? = null }
store Todos {
state items: [Todo] = []
action add(title: String) { items = items.concat([Todo(id: uuid(), title: title)]) }
}
Eleven types come with the language. Each is a plain JSON value at run
time — no wrapper — so it crosses a fetch, a persist, the static
paint and the template engine unchanged, and .native() hands a real
platform object to any library that wants one.
| Type | Carried by | Written | What it can do |
|---|---|---|---|
Date |
"2026-03-14" |
@2026-03-14 |
.year() .month() .day() .weekday(), .plus(days: 3), .minus(…), .isBefore(d) .isAfter(d) .isSame(d), .until(d), .startOfDay() .startOfWeek() .startOfMonth() .endOfDay(), .native() |
Time |
"09:30" |
@09:30 |
.hour() .minute() .second(), .plus(minutes: 30), the comparisons |
DateTime |
ISO 8601 | @2026-03-14T09:30Z |
the above, .date() .time(), .inZone("Europe/Berlin") |
Duration |
milliseconds | 3.days, 90.minutes, 250.ms |
.days() .hours() .minutes() .seconds() .ms() |
Money |
{ amount: 1299, currency: "EUR" } |
€12.99, $9.99 |
.plus(m) .minus(m) .times(n) .convert(rate, "USD"), the fields .amount and .currency; format(m) needs no style |
Url |
a string | "https://example.com" |
.host() .path() .query(), .with(query: { page: 2 }) |
Email |
a string | "ada@example.com" |
.domain() |
Color |
a string | #0F766E |
.mix(other, 0.2) .lighten(n) .darken(n) .alpha(0.5) .contrast(other) |
Uuid |
a string | uuid() makes one |
the methods of a string |
File |
the browser's File |
what FileUpload yields |
.preview(), the fields .name .size .type |
Secret |
a string | — | nothing: it may not be shown, logged, persisted or put in text |
Minor units for money, so nothing drifts. Milliseconds for a duration, so
it adds to a moment. Everything the language computes is a call;
everything the carrier already has is a field — so price.amount is a
field and due.year() is a call.
type Booking {
id: Uuid
guest: Email
from: Date
nights: Number(1..=30)
total: Money
}
state due: Date = @2026-03-14
derived checkout = due.plus(days: 5) // 2026-03-19
derived late = now.date().isAfter(checkout)
Text("Due {format(total)} on {checkout:.date(long)}")
A literal written where one of these is wanted is read as one and held to
its shape: state site: Url = "example" is an error where it is written.
A type the program declares wins over the name — a project with its own
type Color { … } means that one.
now joins viewport, query, hash and theme as a browser value: a
DateTime that keeps itself current, every minute by default, or as often
as a page asks — now(every: 1.seconds). Read it where it is shown or in a
derived value: a state takes it once, so state clock = now(every: 1.seconds) stays at the moment the page opened (X06).
A condition on a type. Any type may say what its values must be, and the checker holds every value it can read to it:
state nights: Number(1..=30) = 12
state pass: String(minLength: 8) = "correcthorse"
state start: Date(after: @2026-01-01) = @2026-03-14
min, max, minLength, maxLength, pattern, after, before, and a
range for the two ends. A value that arrives at run time is validation's to
refuse, from the same condition.
A secret is not a string. Secret is refused by the compiler wherever
it would escape: shown by an element, spliced into text (which is how one
ends up in a URL), logged, or kept with persist. Each is a T12.
A record is built with named fields, Todo(id: "1", title: "x"), and one
whose fields all have defaults with none, Offers() — a prop's default
too, offers: Offers = Offers(); a case is written .calm. The type checker reads every declared type and infers the
rest (literals, records, lists, the built-in methods of strings, numbers and
lists, store members, lambdas). Anything unresolved is Any, which agrees
with everything, so a program that declares no types checks as it always
did; every annotation narrows what the checker can say. See
Compiler diagnostics for what it reports.
A record may extend another, and a case may carry a payload:
type Admin = User { role: String } // every field of User, plus role
enum Status { idle, failed(reason: String), done(count: Number, label: String) }
state s: Status = .idle
action fail() { s = .failed("timed out") }
match s {
.idle { Text("…") }
.failed(r) { Text("Failed: {r}") } // one name per part of the payload
.done(n, l) { Text("{n} {l}") }
}
derived note = match s { .failed(r) { r } else { "" } } // one name in a match expression
A case with a payload is ["failed", "timed out"] at run time and a bare
case its name, so s == .idle still compares. A map literal has the
shape it was written with: state form = { name: "", age: 0 } lets the
checker read form.name and refuse form.nam (T05); a field only some
items of a list have is T?; an empty {} or a spread says nothing about
the keys.
const API = "/api/v1"
const PAGE_SIZE: Number = 20
resource rows = fetch("{API}/rows?limit={PAGE_SIZE}")
Text(env.PUBLIC_APP_NAME)
const declares a top-level value read everywhere; env.X reads the
"env": { "PUBLIC_APP_NAME": "…" } map of webfluent.app.json, the
shell's variables and a .env file, fixed at build time. A page, a
component, a store or an api may only read a name that begins PUBLIC_
or that public_env lists — everything it reads is in the bundle, so any
other name there is a compile error. env is the language's name for
these settings and nothing else's: a state, prop, parameter, loop item or
const called env is E118 (call it target or stage).
image hero = "media/hero.jpg" reads the picture at build time and writes
it again at every width a page will ask for:
image hero = "hero.jpg"
page Home(path: "/", title: "Home") {
Image(hero, alt: "The team at the launch",
sizes: "(max-width: 768px) 100vw, 1200px",
placeholder: .blur)
Text("It is {hero.width} by {hero.height}, and mostly {hero.color}.")
}
What the build writes: hero.<hash>.<width>.webp at each width in
media.widths that is smaller than the original (never up-scaled), plus
the original under its own hashed name — so a host can cache every one of
them forever. What the page gets is a <picture> with a <source> per
format, width and height from the real file (so nothing shifts when it
arrives), and a placeholder: .blur inlines a 16-pixel-wide copy as a data
URI, .color fills the box with the image's average colour, .none does
neither.
The name is a value too: hero.src, .width, .height, .color,
.placeholder, .srcset.
{ "build": { "media": {
"formats": ["webp"], "widths": [480, 960, 1440, 1920],
"quality": 78, "pipeline": true
} } }Image(src: "/hero.jpg", alt: "…") with a literal path of public/ to a
PNG, JPEG, GIF or WebP is processed the same way, without a declaration:
the same files, the same <picture>, sizes: and placeholder: too. An
SVG, a URL or a path computed at run time is used as written, and so is a
picture carried in its address (data:image/png;base64,…).
WebP and JPEG are written lossily at quality. A file that would be no
smaller than the original is not written: the original serves there, and
wf assets --plan says so.
media.pipeline: false copies the file as public/ always did. The work
is cached in .wf-cache/media/ by content hash, so a build that changes no
image does no image work. A PDF or a deck embeds the real picture.
data posts = "posts.json" (or data posts: [Post] = "content/posts.json")
is a constant whose value is a JSON file's, read at build time from the
project directory or its src/; it is inlined into the bundle and seeded
into the static paint. A page on a :param route names the values a
static build renders it for with paths:, one value per parameter (a map
by name for several):
data posts = "posts.json"
page Post(path: "/p/:slug", title: "Post", slug: String, paths: posts.map(p => p.slug)) {
derived post = posts.find(p => p.slug == slug)
Heading(post?.title ?? "?").h1
}
The static build writes p/<slug>/index.html for each, with slug and
params.slug seeded, and lists them in the sitemap.
A .md file under src/ is a page: front matter between --- lines
names path (default /<stem>, / for index), title, description,
layout, image, type and noindex; the rest is its Markdown, placed
as Markdown(…) in the page's body (inside the layout's default slot when
one is named).
---
title: About us
description: Who we are.
layout: Shell
---
# About
We make *things*.Any named argument a built-in does not declare is written to its root
element as an HTML attribute, with a warning; aria-*, data-* and the
global attributes (id, role, tabindex, title, hidden, lang,
dir) are declared families and draw none:
Button("Errors", aria-pressed: showErrors, data-tone: "danger") { on click { showErrors = !showErrors } }
Table.Row(aria-selected: isSelected) { Table.Cell("…") }
Badge("Ready", data-tone: "success")
A value that reads state follows it. aria-* keeps a false value as the
string "false" (a real ARIA state); any other attribute given false is
omitted.
Every built-in takes them, and its on handlers, on the element it draws:
its root (a VirtualList's scrolling box, a Splitter's handle, a Tabs'
or a Carousel's box, a Sidebar's panel, a Breadcrumb's <nav>, a
chart's <figure>, a Modal's <dialog>, the <img> of a <picture>)
or, for a control drawn in a box (Switch, Checkbox, Radio, Slider,
DatePicker, FileUpload), its input — hidden, lang and dir stay on
its box. A role: or aria-label: written on it takes the place of its
own (a Splitter's separator, a Switch's switch, a Breadcrumb's
name); a Sidebar's id: is the one its toggle points at. The pre-render
and the template engine write the same, as far as the build knows them:
VirtualList(rowHeight: 24, role: "listbox", tabindex: "0", aria-activedescendant: "ref-{current}") {
on keydown(e) { if e.key == "ArrowDown" { current = current + 1 } }
for r in refs by r.id { Text(r.id, id: "ref-{r.id}", role: "option") }
}
class: does not replace the element's classes, it adds to them.
Card(class: "feature wide") renders class="wf-card feature wide", and a
value that reads state is followed — the classes it named last time come
off, the ones it names now go on, and a class a script added is left alone.
It is how an element picks up a rule from the project's own stylesheets (see
Stylesheets). A map turns each class on with its own
condition, and a list joins strings and maps, skipping null:
Text(t.title, class: { "is-done": t.done, "is-urgent": t.priority > 2 })
Card(class: ["feature", tone, if open { "is-open" }])
app is the root of the site: what wraps every page, and a bare Router
where the current page renders. The Router can sit at any depth.
// Simple: Router at top level
app {
Navbar {
Navbar.Brand { Link("Ledger", to: "/") }
Navbar.Links { Link("Home", to: "/") }
}
Router
}
// Sidebar layout: Router nested inside Row > Container
app {
Row {
style { min-height: 100vh }
Sidebar {
style {
width: 220px
position: fixed
top: 0
left: 0
bottom: 0
background: #1A1A19
}
Sidebar.Header { Text("My App").bold }
Sidebar.Item(to: "/", icon: "home") { Text("Home") }
Sidebar.Item(to: "/about", icon: "info") { Text("About") }
}
Container {
style {
margin-left: 220px
flex: 1
}
Router
}
}
}
Router(transition: .fade, duration: "200ms") plays the old page out and the
new one in on a route change (.slide slides them). A page that names a
layout: is framed by that component instead of, or as well as, the app's
own chrome.
A store is built the first time something reads it. Declaring one does
nothing, so the order stores are declared in cannot matter — a derived
may read a store declared below it — and a store nothing reads costs
nothing, in time or in bytes.
store Cart(scope: .app) { … } // the default: as long as the page is open
store Session(scope: .session) { … } // this tab's; what it keeps goes in the tab's storage
store Filters(scope: .route) { … } // this route's; dropped when the route changes
store Boot(eager: true) { … } // built at boot, for set-up that must run
store CartStore {
state items = []
state total = 0
derived count = items.length
action add(product: Map) {
items = items.concat([product])
total = total + product.price
}
action getHeaders() {
let h = {}
h["Authorization"] = "Bearer " + accessToken
return h
}
action clear() {
items = []
total = 0
}
}
// Usage in any page/component:
page Shop(path: "/shop") {
use CartStore
Text("Cart: {CartStore.count} items")
Button("Clear") { on click { CartStore.clear() } }
}
state count = 0 // Signal — reactive variable
state draft: String = "" // with a declared type
derived double = count * 2 // Computed — auto-updates
effect { log(count) } // Side effect — runs on change
- UI elements that reference state variables auto-update when values change
- String interpolation:
"Hello, {name}!"— reactive in UI elements - A call at the top of a page or component —
Store.load(id)— is set-up code, run once when it renders
persist scheme = "light" // kept in the browser across visits (page, component or store)
persist items: [Item] = [] { // and what a value says about itself
in: .local // .local (default) · .session (this tab) · .app: window.wfStorage.app, a project script's { get, set, watch? } (get may return a promise)
version: 2 // the version of the shape this build writes
sync: false // don't adopt what another tab or window wrote
key: listId // one stored value per key, followed as state (key: Project.id keeps one per project; a store's may read the store)
migrate 1 -> 2 { old.map(i => Item(id: i.id, qty: i.count)) }
}
every(1000) { tick = tick + 1 } // a timer; stops when its page, branch or item leaves
after(3000) { toast = null } // once
effect { watch(count) cleanup { unwatch() } } // run before the next run, and on leaving
Input(bind: q, ref: search) // a handle on the element: search.focus(), search.value
Text(if viewport.md { "wide" } else { "narrow" }) // viewport.width/height/sm/md/lg/xl, kept current
Text(query.tab ?? "all") // the URL's ?tab=…, and `hash` its #fragment
What a page, a branch, a list item, a match arm or a slot creates — effects,
timers, listeners, handles — is disposed of when it leaves; an async action's
name.pending is true while a call of it runs (Button("Save", disabled: save.pending), Api.sync.pending on a store).
Button("Click") {
on click { doSomething() }
}
Input(bind: query).text {
on input { search(query) }
}
Form {
on submit(event) { event.preventDefault() saveData() }
}
Input(bind: q) {
on keydown(e) { if e.key == "Escape" { q = "" } }
}
DOM events: click, input, change, submit, focus, blur,
keydown, keyup, keypress, mouseenter, mouseleave, the gestures
the runtime recognises — longpress(p) (held half a second; its click is not
a click), swipe(s) (a finger or pen, s.direction "left"/"right"/
"up"/"down"), receive(d) and dragmove(d) (below) — and every other
standard one — dblclick, contextmenu, pointerdown, pointermove,
touchstart, wheel, scroll, paste, drop, dragover, ended,
animationend, … (wf registry --json lists them). The handler's
parameter names the DOM event; without one, event is in scope — unless
the program has an event of its own (a loop's item, a state, an action),
which wins, as every name a program declares does. A DOM
event sends that one value and nothing beside it: on click(e, x) is T22
— read what it carries from the event, e.clientX. Only a component's
declared event sends several (Components, Events). An
element's block is ordered style → transition → on … → slot fills →
children.
Drag and drop: drag: value on any element picks it up (a mouse moved a
few pixels, a finger held still) and on receive(d) on another takes it:
d.value is what was dropped, d.position "before", "after" or, with
dropInside: true, "inside"; dropAxis: .x for a list across the page (a
tab strip). dropValue: v names the target, as d.over; the dragged
element's on dragmove(d) hears where it would land as it moves (d.over
null when nowhere). dragGroup: "tabs" on both keeps drags apart. The
runtime draws a ghost and the drop line, scrolls a box near its edge, and
Escape cancels. On your component's call (TreeRow(name, drag: path, dropInside: true) { on receive(d) { … } }) these, and on longpress and
on swipe, are the one element it draws, as an attribute or a DOM event's
handler written there is — unless it declares a prop or an event of that
name. A component whose body is an if, a for or several elements has no
one element for them: C07.
on key("ctrl+k") { … } answers to one key: modifiers mod (⌘ on a Mac,
Ctrl elsewhere — the one to use for an app's shortcuts), ctrl, shift,
alt, meta/cmd, then the key as the browser names it (k, Enter,
Escape/esc, ArrowDown/down, space, F2). A chord is its steps
with a space between: on key("mod+k mod+s"), the second within 1.5 s; the
second step alone then does not fire its own shortcut. On an element it
listens there; at the top of a page, a component or app it listens on the
document for as long as that shows (on app, always); on key("Escape", e)
names the event too. Escape closes the newest open Popover or Menu
first, and nothing behind it hears that key; a Tooltip keeps it only
while its control has focus. A page, a component or app may also handle
keydown, keyup, and the window's resize, scroll, hashchange,
popstate, online, offline, message, storage,
visibilitychange, blur and focus (the window's) itself, outside any
element. keyIs(e, "mod+s") is
whether a key event is a combination — for a table of commands held as
data — and keyLabel("mod+shift+p") how this system writes it (⇧⌘P,
Ctrl+Shift+P); a Menu.Item's or Tooltip's shortcut: is written
that way. trapFocus: open on any element keeps Tab inside it while
open holds, moving focus in and back out.
Form(bind: form) hands a handle on the form: form.valid, form.errors
by field name, form.touched, form.pending (an async rule is still
asking), form.values by field name:, form.reset(), form.submit(),
and form.apply(serverErrors) for what a 422 said.
A validate block sits beside the state it guards. The control bound to
that state then shows what the rules say — no error: to write, and the
accessible plumbing (role="alert", aria-invalid, aria-describedby)
is the same one error: has always used.
api Backend(base: "/api") {
get emailTaken(email: String) -> Bool
post signUp(body: Map)
}
page Signup(path: "/join", title: "Join", description: "Make an account.") {
state email = ""
state password = ""
state confirm = ""
state note = ""
validate email {
required
email
async "That address is taken" { !(await Backend.emailTaken(email: email)) }
}
validate password {
required
minLength(8) "Use at least 8 characters"
pattern(/[0-9]/) "Include a number"
}
validate confirm { matches(password) "The two passwords differ" }
Form(bind: form, show: .onBlur) { // .onBlur (default) · .onSubmit · .live
on submit { Backend.signUp(body: { email: email, password: password }) }
Input(bind: email, label: "Email").email
Input(bind: password, label: "Password").password
Input(bind: confirm, label: "Confirm").password
Textarea(bind: note, label: "Anything else?", rows: 4, maxLength: 200)
Button("Join", type: .submit, disabled: !form.valid || form.pending).primary
}
}
The rules: required, email, url, minLength(n), maxLength(n),
min(v), max(v), pattern(/…/), matches(other), oneOf([…]),
custom "…" { expr }, async "…" { await … }. Each takes an optional
message — a string, or t("key") read in the reader's language; without
one the rule's own is used, which a project's translations replace by
naming form.required, form.email and so on. An argument that reads
state — max(Ledger.today) — is read each time the rule is checked.
A rule is checked against what it guards — minLength on a Number is
T01 — and every rule but required passes an empty value, so a blank
optional field is one message, not two.
A refined type validates itself. state nights: Number(1..=30) needs
no rule written for the range; the condition on the type is already one.
A submit that fails shows every message, moves focus to the first field that has one and announces it, and does not run the handler.
store AuthStore {
state accessToken = ""
action getHeaders() {
let h = {}
h["Authorization"] = "Bearer " + accessToken
return h
}
action load() {
let r = await fetch("/api/me")
accessToken = r.token
}
}
let declares a local of an action or handler, which — like a for
binding, a lambda's parameter or a caught error — shadows a store member,
a prop or a state of the same name until its block ends (let refs = … in
a store with state refs is the action's own); await inside one makes
it async; return leaves it with a value or without. for loops (with an
index, over a range), try { } catch e { } and destructuring let are
imperative statements too:
action sync() {
try {
let r = await fetch("/api/sync")
let { items, total } = r
let [first, second] = items
for item, i in items { log("{i}: {item.title}") }
for n in 1..=total { count = n }
} catch e {
error = e.message
}
}
Standard browser APIs are available without any prefix — they compile directly to their JavaScript equivalents:
// Storage
localStorage.setItem("token", tok)
sessionStorage.getItem("key")
// Window & Document
window.open("https://example.com")
document.title = "New Title"
// Console
console.log("debug info")
// JSON
payload = JSON.parse(responseText)
text = JSON.stringify(obj)
// Timers
setTimeout(callback, 1000)
// Other globals: Math, Date, Array, Object, Promise, Error,
// parseInt, parseFloat, encodeURIComponent, atob, btoa, fetch,
// alert, confirm, prompt, RegExp, Map, Set, etc.
x => expr and (a, b) => expr are lambdas with one expression as the
body, returning a map with (x) => { key: value }; a ?? b takes b when
a is null; if c { a } else { b } (with no else, null when c fails), if let x = v { a } else { b } and
match t { .calm { 1 } else { 2 } } are values; $token is a design
token; .case is a case of an enum; a?.b, a?.m(), a?.[i] read
through null; /…/flags is a regular expression; ...x spreads a list or
a map; a..b and a..=b are ranges.
derived open = incidents.filter(i => !i.resolved)
derived byAge = rows.slice().sort((a, b) => a.age - b.age)
derived label = if let s = selected { s.title } else { "none" }
derived city = user?.address?.city ?? "—"
derived valid = /^[\w.]+@[\w.]+$/.test(email)
derived slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-")
derived merged = { ...defaults, ...overrides, open: true }
derived all = [...pinned, ...rest]
derived pages = 1..=pageCount
derived byTeam = rows.groupBy(r => r.team)
derived top = rows.sortBy(r => -r.score).take(3)
derived names = rows.map(r => r.name).unique().join(", ")
derived short = description.truncate(80)
List helpers: sortBy(f), groupBy(f) (a map of lists), unique(),
take(n), first(), last(), flatMap(f), sum(), beside the
JavaScript methods; string helpers: capitalize(), truncate(n),
dedent() (the common indentation off a block of text), lines(),
words(), and match, replace, replaceAll, split, search with a
regex. Every one runs at build time too, so the static paint shows the
result.
A member something declares always wins over a helper of its name: a
store's action preview(path) or action first(n) is called as
OpenFiles.preview(path) from a page or another store, and a worker's
css.last(kind) is the worker's — never the list or file helper.
Text(format(price, .currency)) // $1,234.50 — the page's locale
Text(format(price, .currency, "EUR")) // €1,234.50
Text(format(count, .integer)) // 1,235
Text(format(ratio, .percent, 1)) // 25.6%
Text(format(views, .compact)) // 1.2K
Text(format(when, .date, "long")) // March 5, 2024
Text(format(when, "yyyy-MM-dd HH:mm")) // a pattern: yyyy yy MMMM MMM MM M dd d EEEE EEE HH H hh h mm ss a
Text(ago(when)) // 3 minutes ago · yesterday · in 2 weeks
format(value, .style, option) and ago(date) speak the i18n locale when
the project has one (a change of locale redraws them) and the document's
language otherwise. Styles: .number (the default), .integer,
.decimal (option: places, 2), .currency (option: the code, USD),
.percent (of a fraction; option: places, 0), .compact, .date,
.time, .datetime (option: short, medium, long, full),
.relative. The static paint formats with a table of the common locales
and currencies and English names; the browser's Intl takes over on
hydration.
// Conditional
if isLoggedIn {
Text("Welcome!")
} else {
Button("Log In") { on click { navigate("/login") } }
}
// A value that may be null, bound when it is not. The binding follows the
// value: another user is drawn in place, and the branch is drawn again only
// when the value goes to null and back
if let user = session.user {
Text(user.name)
}
// Loop, keyed: an item keeps its nodes for as long as its key is in the
// list — across inserts, removals, moves, and a new value under the same
// key, which its body reads live. The key may read the index too
// (`by "{item.id}-{i}"`); the index alone moves with the position, so key
// by what identifies the item
for item in items by item.id {
Card { Text(item.name) }
}
// Loop with index
for item, index in items {
Text("{index}. {item.name}")
}
// Show/hide (keeps in DOM, toggles visibility)
show isVisible {
Modal { Text("Content") }
}
// One arm by a value: a resource's states, or an enum's cases. An arm is
// drawn again when the arm changes; its binding follows the value in place
match tone {
.calm { Text("Calm") }
else { Text("Loud") }
}
resource users = fetch("/api/users")
match users {
loading { Spinner }
error(err) { Alert("Failed: {err.message}").danger }
ready(users) {
for user in users by user.id {
Text(user.name)
}
}
}
A resource declares its type — resource users: [User] = fetch(…) — and
the ready arm's binding has it. Options: fetch(url, method: "POST", body: { key: value }, headers: { "Authorization": token }). The request
repeats when a URL that reads state changes. users.state, users.data,
users.error, users.refreshing and users.pending (and a connection's
.messages) are live wherever they are read — Button(if plan.data != null { "Request approval" } else { "Deploy" }) changes when the plan arrives,
with no derived in between.
Asking again. on: .focus, .reconnect or .interval(30.seconds) (an
option of fetch(…), or of an endpoint's call), users.reload() and
users.invalidate() ask again. Asking again for the request already shown
is a refresh, and the page stays as it is while it runs: users.state
stays "ready", the data stays, and the answer is drawn in place. The
ready arm's binding is live — text, attributes, if, if let, a keyed
for … by and component props inside the arm follow the new data; a row
whose key is still there keeps its nodes, its focus and its components'
state; and the parts of the answer equal to what was shown are kept as the
same objects, so an answer that changed nothing redraws nothing. The arm is
drawn again only when the arm changes — loading, ready, error.
users.refreshingis true while a refresh runs, with the data shown;users.pendingwhile any request of it runs, the first load included.- A refresh that fails keeps the data and
"ready", and setsusers.erroruntil the next answer arrives, so the arm can say the data is stale. A first load that fails is"error". - A request that changed — an address or an argument that reads state now
names something else: another environment, another page — is a first load
again,
"loading", since the data on screen would be the wrong data. Withpaginate:, the next page is a refresh.
type Server { id: String, name: String, status: String }
page Servers(path: "/servers", title: "Servers", description: "Every server, kept current.") {
state stage = "test"
resource servers: [Server] = fetch("/api/servers?env={stage}", on: .interval(30.seconds))
Heading("Servers").h1
Button("Production") { on click { stage = "prod" } }
match servers {
loading { Skeleton(height: "20px") }
error(e) { Alert(e.message).danger }
ready(list) {
if servers.error != null { Alert("Could not refresh; this is what was there.").warning }
if servers.refreshing { Spinner.sm }
for s in list by s.id { Text("{s.name}: {s.status}") }
}
}
}
Map literals support quoted string keys for headers and special field names:
resource data = fetch("/api/users", method: "POST",
headers: { "Content-Type": "application/json", "X-Api-Key": apiKey },
body: { action: "create", token: sessionToken })
match data {
ready(data) { Text("Done") }
else { Spinner }
}
Reserved words (action, token, error, state, etc.) work as map keys.
api is where a service lives: its address, how it is reached, and what it
has. Every call site is then typed, cached and cancellable, and the day the
server changes its contract the build says so.
type User { id: String, name: String }
api Backend(base: env.PUBLIC_API ?? "/api/v1") {
timeout: 10.seconds
retry: .backoff(times: 3, on: [.network, .timeout, .status5xx])
credentials: .sameOrigin // .omit · .sameOrigin · .include — the
// session cookie, which is where a
// session belongs; `persist` is not
on request(r) { r.headers["X-Request-Id"] = uuid() }
on response(r) { log("{r.status} {r.url}") }
get users(page: Number = 1, q: String?) -> [User]
get user(id: String) at "users/:id" -> User
post createUser(body: Map) -> User
errors { 422 -> Map }
delete removeUser(id: String) at "users/:id"
post avatar(id: String, file: File) at "users/:id/avatar" -> Url
}
Or read the whole surface from a specification, and let the compiler check every call against it:
api Backend from "openapi.json" (base: env.PUBLIC_API)
Every endpoint, parameter, response type and error shape comes from the
spec, and each named schema becomes something the program can name: an
object a type; an allOf a type ServerDetail = EnvSummary { … } that
extends the first record it names; a string with cases ("enum": ["prod", "test"]) an enum Env { prod, test }, or String when a case is not a name
("operate.flags"); anything else (another $ref, a oneOf of one type and
null, a string) the type it reads as, and a oneOf of several types Any.
A value that may be null — 3.1's "type": ["string", "null"], a oneOf of
{ "type": "null" } and a $ref, 3.0's nullable: true beside anything —
is T?. A string's format is the language's type in what the server sends
— date-time → DateTime, date → Date, uuid → Uuid, email →
Email, uri → Url — and stays String in what the program sends (a
parameter, a body, a record one reaches, but for its readOnly fields) and
for time. Shared parameters and bodies ($refs into components) are read
too. A type or enum the program declares wins over the spec's.
An endpoint is called like anything else, and a resource over one is
typed by what it returns:
resource users = Backend.users(page: n, cache: .swr(60.seconds), on: .focus)
match users {
loading { Skeleton }
error(e) {
match e {
.offline { Alert("You are offline.").warning }
.timeout { Alert("The server took too long.").warning }
else { Alert(e.message).danger }
}
Button("Try again") { on click { users.reload() } }
}
ready(list) { for u in list by u.id { Text(u.name) } }
}
What comes with it, at every call site:
| Abort | on scope leave, on an argument that reads state changing, on .cancel(), on timeout |
| Errors | .offline, .timeout, .aborted, .parse, .network, .status(code, body) — the body decoded to the declared type, and .message, .status, .headers to read |
| Retry | exponential backoff with jitter, per error kind, honouring Retry-After |
| Cache | .swr(60.seconds), .none, .forever — keyed by method, address and body, with ETag/If-None-Match revalidation and 304 served from what is held |
| Dedupe | two readers of one address make one request |
| Refetch | on: .focus, .reconnect, .interval(30.seconds) — a refresh: the data stays on the page while it runs, and the answer is drawn in place (see Data Fetching) |
| Pagination | paginate: .page gives .items, .loadMore(), .hasMore |
| Progress | a File parameter makes Backend.avatar.progress a Number signal |
| Bodies | JSON, text, a File (sent as a form), FormData, Blob, ArrayBuffer, URLSearchParams |
| Responses | JSON, as: .text, .blob, .arrayBuffer, 204 as no value, and .lines() for a streamed response |
| Elsewhere | Backend.users.invalidate(), .prefetch(args), .key(args), .url(args) |
| Empty path parameters | a call whose path parameter is null or "" is not sent (users/ would be the collection, not the user); it ends .aborted, and a resource over it stays loading until the value arrives |
await fetch(url, options) in an action goes through the same engine, and
window.fetch is untouched for anything that wants the browser's own.
A socket, a stream of events, a channel every tab hears, or a peer — a direct line to another reader's page. Each is closed when the page that opened it leaves, so a route change cannot leak one.
socket chat = ws("wss://example.com/chat", protocols: ["v2"], heartbeat: 20.seconds) {
send Outgoing // typed both ways
receive Incoming
on message(m) { Feed.add(m) }
}
match chat {
connecting { Spinner.sm }
open { for m in chat.messages by m.id { Bubble(m) } }
closed(c) { Alert("Disconnected ({c.code})").warning }
error(e) { Alert(e.message).danger }
}
Button("Send") { on click { chat.send(Outgoing(text: draft)) draft = "" } }
stream ticks = sse("/events", events: ["price", "status"])
effect { if let p = ticks.last("price") { price = p } }
channel cart = broadcast("cart") { on message(m) { Cart.merge(m) } }
Button("Sync") { on click { cart.post({ items: Cart.count }) } }
beacon("/analytics", { event: "checkout", value: total }) // survives unload
A socket reconnects with backoff, keeps itself alive with a heartbeat, and holds what was sent while it was down. A stream resumes where it was.
Each is a handle, not a signal: .state (what a match reads),
.messages, .last(kind), .error, .closure (.code, .reason),
.close(), and .send(v) on a socket or a peer, .post(v) on a channel.
A store may hold any of them too: opened when the store is first read,
closed with it, read as Lang.css.state and in its actions as css.
worker runs a script of public/ beside the page — a language service, a
parser — held like a socket:
store Lang {
state problems: [Map] = []
worker css = spawn("/workers/css.js", restart: 1, timeout: 5000) {
on message(m) { if m.type == "diagnostics" { problems = m.items } }
on restart(e) { log("css restarted: {e.message}") }
}
action complete(text: String, at: Number) {
return await css.request("complete", { text: text, at: at }, { supersede: true })
}
}
.request(method, params, { timeout, supersede }) posts { id, method, params } and is a promise of the { id, result } the worker posts back
(or rejects with its { id, error }, a timeout — 10 s unset — or a newer
request of the method with supersede: true, which also posts
{ cancel: id }). .send(v) posts and expects nothing. What it posts
with no id is a message: on message, .last(kind) by its type or
method. .pending counts what is out; .state is open, error or
closed. A crash fails what was out and starts it again restart: times
(on restart(e)), then is error (on error(e)); .restart() starts it
again by hand. type: .module for a module worker. The script is a file of
the site (under the base path); a missing one is a D01.
peer opens a WebRTC data channel straight to another page. The language
supplies the channel, not a server: what the two sides must tell each other
to connect — an offer, an answer, the routes each can be reached by — goes
out through signal:, over whatever the app already has, and what the other
side sent is handed to link.signal(m). One side is the initiator.
page Room(path: "/room", title: "Room", description: "Two readers, one line.") {
state heard = ""
channel lobby = broadcast("room") { on message(m) { link.signal(m) } }
peer link = rtc(signal: m => lobby.post(m), initiator: query.host == "1",
ice: [{ urls: "stun:stun.example.com:3478" }]) {
on message(m) { heard = m.text }
}
Heading("Room").h1
match link {
connecting { Text("Waiting for the other side…").muted }
open { Button("Wave") { on click { link.send({ text: "hello" }) } } }
closed(c) { Text("They left.") }
error(e) { Alert(e.message).danger }
}
Text(heard)
}
A broadcast channel signals between two tabs of one browser; between two
readers the signal rides the app's own socket or api. ice: names the
servers that help two readers behind different networks find each other —
none by default, which connects readers on the same network only. A page
that leaves closes its peer, and the other side reads closed at once.
Like viewport and theme, read where it matters:
if !network.online { Alert("You are offline — changes are queued.").warning }
Image(hero, alt: "…", loading: if network.saveData { "lazy" } else { "eager" })
network.online, .effectiveType (4g, 3g, …), .saveData, .downlink,
and .queued — the writes waiting for the connection (see Offline).
Name offline in the config and wf build writes a service worker, sw.js,
that stores the site for when the network is gone:
{ "offline": {
"precache": ["/", "/docs/*"],
"fallback": "/offline",
"cache": { "/api/*": "network-first" },
"sync": true
} }precache— the routes a first visit stores, as globs (default"/"), each with its own chunk and sheet, and the shell with them. A stored route loads with no network; a navigation goes to the network first when there is one, so a page that changed is not served stale.fallback— a page'spath, shown for a route that was not stored.cache— how a path the build did not write is fetched:network-first,cache-first,stale-while-revalidateornetwork-only. Anything not named passes through untouched.sync— a write (any method butGETandHEAD, throughfetchor anapi) that fails for want of a network is kept and sent, in order, when the connection returns — by Background Sync where the browser has it, so a closed page still sends it. The call resolves with nothing, so what it showed stays shown. A server that answers has the write, even to refuse it; one that is down or asks to wait keeps it for later. AFilebody is not kept.
The worker is versioned by a hash of everything the build wrote, so a deploy that changes a byte is a new version. It installs and waits:
page Home(path: "/", title: "Home", description: "The front page.") {
if update.available {
Button("A new version is ready — reload") { on click { update.apply() } }
}
if network.queued > 0 {
Text("{network.queued} change(s) will be sent when you are back online").muted
}
Heading("Home").h1
}
update.apply() takes the new version and reloads the page once — not on
the first install, and not twice for one update. Under wf serve the
worker takes itself away, so an edit is never hidden behind its own cache.
optimistic(holder, change) shows it at once. If anything later in the
action throws, what was shown is taken back — the action rolls itself back:
action rename(id: String, name: String) {
optimistic(Todos.items, items => items.map(i => if i.id == id { { ...i, title: name } } else { i }))
await Backend.updateUser(id: id, body: { name: name })
Backend.users.invalidate()
}
A .js file under src/ is part of the project, as a .css file is: a
plain browser script — no export, no import, no build step — copied
to build/js/ byte for byte and linked with <script defer> on every page,
before the compiled code. What it declares at the top level (function,
class, const, let, var, and anything assigned to window.x) is in
scope by name.
// src/tilt.js
/**
* Tilt an element toward the pointer.
* @param {HTMLElement} node
* @param {number} [max] degrees
*/
function initTilt(node, max = 15) {
return { setMax(m) { max = m; }, destroy() {} };
}page Gallery(path: "/gallery", title: "Gallery", description: "Cards that lean.") {
state max = 15
Heading("Gallery").h1
Card(class: "tilt",
mount: (n) => initTilt(n, max), // each time the element is made
update: (tilt) => tilt.setMax(max), // again when what it reads changes
cleanup: (tilt) => tilt.destroy()) { // when its page, branch or item leaves
Text("Lean on me.")
}
}
- A call is checked: the number of arguments (
T10, defaults and...restallowed for), and each argument's type from the JSDoc (T01) —string/number/boolean→String/Number/Bool,T[]→[T],?T/[name]→ optional,(v: T) => R→ a function (an action may be passed), anything elseAny;@returnstypes the result. No comment, no narrowing: adding a script never breaks a build. - A class is constructed: a call to one compiles to
new C(…). - One name, one meaning: two scripts declaring one name, or a script
name that is also a component, store,
const,typeor a name the language or browser owns (format,log,fetch,WF,env), is an error naming both. - A module is refused: a top-level
import/exportstops the build; a file the scanner cannot read is still linked, with a warning, and its names are not in scope; apublic/file at the same address is an error. - A script reports, WebFluent owns the class: hand the script an action
(
mount: (n) => watchVisible(n, setVisible)) and keep the look inclass: { "is-visible": visible }. wf:renderis dispatched ondocumentonce per drawing — the first mount, the takeover of a pre-rendered page, every route — with{ route, params }, for a script that runs its ownquerySelectorAll.
mount:, update: and cleanup: are on every element and component call
(a component's root, unless it declares the prop); Host(tag: "canvas", …)
is the same lifetime on a bare node (div · span · canvas · svg ·
section · figure · pre · p · ul · table).
for row, i in Completions.rows by i {
Portal(into: editorHost(row.id), data-popup: "row") { // a node CodeMirror made
Text(row.label) {
on mousedown(e) { e.preventDefault() } // the editor keeps the focus
on click { Completions.pick(i) }
}
}
}
The library keeps the node's place, keys and focus; the page draws what is
in it. into: takes an element (what a script returns, JSDoc
HTMLElement|null), a ref: handle (even one written further down), or
null, which keeps the block aside with its nodes and state until a node is
named again; it is read again whenever what it reads changes and once more
when the page is drawn and in the document, and the same box moves. The block is drawn in the scope the portal is written in — its
handlers, emit, state, mount: and cleanup: are that page's or
component's — and a mount: inside runs once the block is in place. The
box goes when its page, branch or list item does; the node stays.
Events bubble from the node it is drawn in, not from where it is written,
and show does not hide it (its block is not in show's place): use if,
or into: null.
The compiler never fetches anything, so a library on another origin is a URL in the config, used from your own scripts:
{ "meta": {
"scripts": [
"https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js",
{ "src": "https://esm.example.com/confetti.mjs", "module": true, "as": "Confetti" },
{ "src": "https://cdn.example.com/lib.js", "globals": ["Lib"] },
{ "src": "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX", "load": "after" }
],
"integrity": { "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js": "sha384-…" }
} }Each is linked defer in the order listed, before the project's scripts; a
module is imported by a generated externals.js and its exports put on
window.<as>; globals are names .wf may call directly, each Any, and a
class among them is constructed when called. "async": true loads one
nothing waits for (analytics, a tag manager) with <script async>, so it
never holds up the page's code; "load": "after" goes further and has no
tag at all — the runtime asks for it once the page has loaded and drawn its
first frame, so it never counts against how soon the page is ready. Neither
can be a module or have globals (E111). Their origins join
script-src, and one without an integrity hash draws a warning.
page Pricing(path: "/pricing", title: "Pricing", description: "Plans.") {
state loaded = false
Heading("Pricing").h1
Element("stripe-pricing-table", publishableKey: "pk_live_…") { // the attribute `publishable-key`
on ready { loaded = true } // any event it fires
}
}
Element takes the tag first (lower case, with a hyphen); every named
argument is an attribute and follows state; class:, style { } and
mount: work as anywhere. external — 4.1's typed declaration of a module
or element — is gone; wf migrate rewrites it.
{ "build": { "output_type": "elements", "elements": ["PriceTag", "Rating"] } }PriceTag becomes <price-tag>; a one-word component is prefixed so its
tag has the hyphen a custom element needs (<wf-rating>). The build writes
elements.js, styles.css and a page listing the tags. An attribute is a
prop — a Number read as a number, a Map or list as JSON, a Bool true
when present and false when ="false" — read again whenever it changes; an
event the component declares is a bubbling CustomEvent; what the
component made goes when the element leaves the document. React, Vue,
Svelte, Rails, WordPress and a plain page can all place a tag, which is why
this is one output rather than an adapter per framework.
navigate("/path") // Programmatic
Link("About", to: "/about") // Declarative
The same grammar, with blocks written by indentation instead of braces. A
file named .wfx is read that way: a line whose next line is indented
deeper opens a block, and a dedent closes every block it leaves. Nothing
else changes — the parser, the checks, the output and the editor support
are the same, and a project may mix .wf and .wfx files.
type Todo { id: String, title: String }
store Todos
state items: [Todo] = []
action add(title: String)
items = items.concat([Todo(id: uuid(), title: title)])
component TodoRow(_ label: String, todo: Todo)
Row
Text(label)
page Home(path: "/", title: "Todos")
use Todos
state draft = ""
Row(align: .center, gap: .sm)
style
padding: 6px 0
&:hover
background: $surface
on click
draft = ""
Text("Todos").bold
if Todos.items.length == 0
Text("Nothing yet").muted
else
for todo in Todos.items by todo.id
TodoRow(todo.title, todo: todo)
Button("Add").primary { on click { Todos.add(draft) } }
- Inside parentheses, brackets, and braces you write yourself, layout is
free, as it is in a
.wffile: a multi-line argument list, a map literal, a one-line{ on click { save() } }all read the same. - An empty block is written
{ }. - A comment line counts for nothing; blank lines count for nothing.
- Indentation must be consistent: a line that lines up with no enclosing block is an error.
wf fmt --to wfx rewrites a project's .wf files as .wfx (and --to wf the other way); it is a change of layout and nothing else — the build
is byte-identical — and it refuses a file whose indentation does not
already follow its braces rather than change what it says. wf migrate --wfx migrates a WebFluent 2 project straight into the indented layout.
wf generate writes .wfx in a project that has .wfx files and no
.wf.
wf fmt (no --to) formats a project's sources in place: four spaces a
level, a line that closes a block one level out, trailing blanks gone,
tabs made spaces, runs of blank lines folded to one, a brace one space
from what it opens (Row{ → Row {, }else → } else). A value carried
over a line by a trailing comma is left as written; comments stay where
they are. The result is held to the file's tokens, so formatting never
changes what a file says; wf fmt --check writes nothing and fails when a
file would change; a .wfx file is normalised through its braced spelling.
| Component | Usage |
|---|---|
Container |
Container { ... } — centered max-width wrapper |
Host |
Host(tag: "canvas", mount: (n) => …, update: (h) => …, cleanup: (h) => …) — a node handed to somebody else's code, with a lifetime |
Element |
Element("stripe-pricing-table", publishableKey: key) { on ready { … } } — any custom element, by its tag; named arguments are attributes, handlers its events |
Portal |
Portal(into: editorHost(row.id), data-popup: "row") { … } — its block drawn into a node somebody else's code owns: an element a script returns, a ref: handle, or null to keep it aside; into: is read again when what it reads changes, and the block moves there with its nodes and state; it goes with the page, branch or item it is written in, and what is inside belongs there (handlers, emit, lifetimes). The box is display: contents; id:, data-* and aria-* are its own; the pre-rendered page has only a marker. Without into: it is a C01 |
Row |
Row(gap: md, align: center, justify: between) { ... } — horizontal flex. gap: xs sm md lg xl · align: start center end stretch baseline · justify: start center end between around evenly |
Column |
Column(span: 6) { ... } — 12-column grid child |
Grid |
Grid(columns: 3, gap: md) { ... } — CSS grid; takes the same gap/align/justify as Row |
Stack |
Stack(gap: md) { ... } — vertical flex; takes the same gap/align/justify as Row |
Spacer |
Spacer or Spacer.sm Spacer.xl — vertical space (the size is a flag) |
Divider |
Divider() — horizontal line |
VirtualList |
VirtualList(rowHeight: 32, reveal: selected, label: "Files") { for f in files by f.path { … } } — a scrolling box that draws only the rows in view of its one keyed for; without rowHeight: rows are measured (estimate: for the rest); reveal: scrolls the row with that key into view; give the box a height its rows do not set (style { height: 400px }, a max-height:), and one drawn before it is laid out paints its first frame with the rows in view and the overscan only, its reveal: row among them; anything else in its body is a V12 and draws everything |
Splitter |
Splitter(bind: side, min: 160, max: 480, reset: 240, label: "Sidebar width") — a handle that sizes the region beside it: dragged between min: and max:, double-click or Enter back to reset:, arrows ±16 px (Shift ±64), Home/End; .horizontal between regions one above the other, .reverse on the start side of what it sizes (a pane on the right, a panel below) |
| Component | Usage |
|---|---|
Navbar |
Navbar { Navbar.Brand { ... } Navbar.Links { ... } Navbar.Actions { ... } } |
Sidebar |
Sidebar { Sidebar.Header { ... } Sidebar.Item(to: "/", icon: "home") { ... } Sidebar.Divider } |
Link |
Link(to: "/path") { Text("Label") } — the link whose to matches the current route carries .active and aria-current="page"; active: .prefix also matches routes beneath it; a to with a query ("/?show=open") matches only when the address has those values too, and a plain link to the same path then gives way to it |
Tabs |
Tabs { Tabs.Page("Tab 1") { ... } Tabs.Page("Tab 2") { ... } } |
Breadcrumb |
Breadcrumb { Breadcrumb.Item(to: "/") { Text("Home") } Breadcrumb.Item { Text("Current") } } |
Menu |
Menu(trigger: "Options") { Menu.Item(shortcut: "F2") { ... } Menu.Divider Menu.Sub(label: "Move to") { ... } }, or opened by state: Menu(visible: asked, at: point) (a context menu, point = { x: e.clientX, y: e.clientY }) or anchor: someRef; placement:, on close(reason). Items take shortcut:, checked:, disabled:. Drawn in the top layer |
| Component | Usage |
|---|---|
Card |
Card.elevated { Card.Header { ... } Card.Body { ... } Card.Footer { ... } } — variants: .flat, .elevated, .outlined |
Table |
Table(caption: "Deployments") { Table.Head { Table.Row { Table.Cell("Col") } } Table.Body { Table.Row { Table.Cell("Val") } } } — cells inside Table.Head are <th scope="col">, as is Table.Cell("Build").header anywhere (a header cell a component renders); caption is the table's accessible name, rendered visually hidden |
List |
List { List.Item { Text("Item 1") } List.Item { Text("Item 2") } } — List.ordered for numbered |
Badge |
Badge("Label").primary — tones: .primary, .secondary, .success, .danger, .warning, .info; .pill |
Tag |
Tag("JavaScript") |
Avatar |
Avatar(src: "/photo.jpg", alt: "User") or Avatar(initials: "MO").primary — flags: .sm, .lg, .primary |
Tooltip |
Tooltip("Save", shortcut: "Ctrl+S", delay: 400, placement: .bottom) { Button("Save").primary } — wraps children; shows above everything after delay: ms of hover (unset, 500), at once on keyboard focus |
| Component | Usage |
|---|---|
Input |
Input(bind: var, placeholder: "...", label: "Name", hint: "As on your passport", error: nameError).text — with label:, hint: or error: the control is wrapped in a field: the label is a <label for>, the hint and the error are linked by aria-describedby, the error (a string, empty when there is none) is announced as it appears and sets aria-invalid |
Select |
Select(bind: var, label: "Choose", hint: "...", error: roleError) { Select.Option("Label 1", value: "val1") } — field wrapping as Input; an option's positional is its visible label, value: what is bound (the label when there is no value:) |
Checkbox |
Checkbox(bind: var, label: "Agree") |
Radio |
Radio(bind: var, value: "opt1", label: "Option 1") |
Switch |
Switch(bind: var, label: "Enable") |
Slider |
Slider(bind: volume, min: 0, max: 100, step: 1, label: "Volume") — range input with reactive value. aria-*, id:, role:, disabled: and the rest of its attributes reach the input (hidden, lang, dir its box); with aria-valuetext: the raw number is not shown beside the track (you are showing it) |
DatePicker |
DatePicker(bind: selectedDate, label: "Start Date", min: "2026-01-01") — date input |
FileUpload |
FileUpload(accept: "image/*", label: "Upload Photo") { on change(e) { handleFile(e) } } — flags: .multiple |
Textarea |
Textarea(bind: note, label: "Notes", rows: 4, maxLength: 200) — several lines; with maxLength it counts what is left |
Form |
Form(bind: form, show: .onBlur) { on submit { save() } ... } — show: when a validate message appears (.onBlur, .onSubmit, .live) |
Input types (a flag): .text, .email, .password, .number, .search, .tel, .url, .date, .time, .datetime, .color
| Component | Usage |
|---|---|
Alert |
Alert("Message").success — tones: .success, .danger, .warning, .info |
Toast |
Toast("Saved!").success — temporary notification |
Modal |
Modal(visible: showModal, title: "Title") { on close { ... } ... Modal.Footer { ... } } — a <dialog>; title: is its heading, and Modal.Title { Markdown("Rename {name}") } draws the heading instead, title: staying its name; .closable puts a close button in the heading row; on close runs however it closes; an open one is the newest layer, so opened over a popover it takes the first Escape and the popover the next |
Dialog |
Dialog(visible: show, title: "Confirm") { ... } — as Modal |
Popover |
Popover(visible: open, anchor: button, placement: .bottomStart, label: "Branches").arrow { on close(reason) { ... } ... } — a layer above everything at anchor: (a ref:) or at: a point; flips and slides to stay on screen; Escape or a press outside sets visible: back to false, and on close hears "escape" or "outside"; role: (in place of dialog), tabindex:, aria-*, data-*, ref: and its other on handlers are its element's, as on any element — and on a Menu, a Tooltip, a Modal and a Dialog too |
Spinner |
Spinner or Spinner.lg |
Progress |
Progress(value: 75, max: 100) |
Skeleton |
Skeleton(height: "20px", width: "200px") or Skeleton(size: "48px").circle — flags: .circle |
| Component | Usage |
|---|---|
Button |
Button("Label").primary.lg — click handler: Button("Save") { on click { doSave() } } |
IconButton |
IconButton(icon: "close", label: "Close") or IconButton(icon: "edit", label: "Edit").primary { on click { editItem() } } — flags: .sm, .lg, .primary, .danger |
ButtonGroup |
ButtonGroup { Button("A") Button("B") } |
Dropdown |
Dropdown(label: "Actions") { Dropdown.Item { ... } } |
Button tones: .primary, .secondary, .success, .danger, .warning, .info
Button flags: .sm, .lg, .full, .rounded, .pill, .outlined; type: .submit inside a form (any other button is type="button", so it never submits one)
| Component | Usage |
|---|---|
Image |
Image(src: "/photo.jpg", alt: "Description"), or Image(hero, alt: "…", sizes: "…", placeholder: .blur) with an image the program declares; a literal src: naming a PNG, JPEG, GIF or WebP in public/ is processed by the build just as an image is — decoded asynchronously; the first image on a page loads eagerly at high priority (it is usually the largest paint), the rest lazily. loading: overrides |
Video |
Video(src: "/video.mp4", captions: "/clip.en.vtt", poster: "/frame.jpg").controls — muted, loop, playsinline; without captions it draws an A09 |
Audio |
Audio(src: "/talk.mp3", transcript: "/talk.txt").controls — the transcript is a link beneath the player |
Frame |
Frame(src: "/preview/", title: "Preview", sandbox: "allow-scripts") { on message(m) { heard = m.data } } — another page inside this one; on message hears only what that page posts; without title: it draws an A17. Frame(srcdoc: html, title: "Example", sandbox: "") draws the HTML it is given instead of an address, sandboxed as sandbox: says, and draws the new HTML when a value that reads state changes |
Icon |
Icon("home") or Icon("search").lg.primary — 32 built-in SVG icons rendered inline |
Carousel |
Carousel(autoplay: true, interval: 5000) { Carousel.Slide { Image(src: "...") } } — slide track with dots and autoplay |
| Component | Usage |
|---|---|
Text |
Text("Hello").bold.muted.center |
Heading |
Heading("Title").h1 — levels: .h1 … .h6 (.h2 is the default) |
Code |
Code("const x = 1").block — .block for multi-line |
Blockquote |
Blockquote { Text("Quote text") } |
Unsafe.Html |
Unsafe.Html(sanitize(body)) — markup, as markup. The one door for HTML a page did not write; every use draws a V03 |
Markdown |
Markdown(text) — a small Markdown rendered as HTML, at build time and live: # headings, paragraphs (a line ending in two spaces or \ breaks, and the paragraph's last line shows its \; any other newline is a space, as in CommonMark), fenced code, > quotes, one-level -/1. lists, ---, `code`, **strong**, *em*, [text](url) (its words hold no unescaped [), ; a backslash before ASCII punctuation is that character and nothing more, as in CommonMark (\*not em\*, \[not a link\], \# starting a line is no heading, \\ is one backslash; in a URL javascript\: is checked as javascript:), and a backslash before anything else, or in code or a fence, is shown; the text is escaped first, so HTML in it is shown, not run, and a link or picture whose URL a browser would run (javascript:, vbscript:, data:) is drawn as its words — a picture carried in its address () is drawn, a link to one is not; a site-relative one takes the base_path. Markdown(text).inert is for text somebody else wrote, such as a language server's or a /// comment's documentation: entities are decoded before escaping (<T> shows <T>, a <script> stays text; in `code` and fences they stay as written), a link is drawn as its words, a picture is left out with the space after it, and the rest, backslash escapes too, is drawn as plain Markdown draws it (a language server's margin\-top shows margin-top) |
Text flags: .bold, .italic, .underline, .uppercase, .lowercase, .left, .center, .right, .muted, .sm, .lg, .heading, .subtitle, and the tones .primary, .secondary, .danger, .success, .warning, .info
Structural codegen with sub-components for app navigation:
Sidebar {
Sidebar.Header { Text("My App").heading }
Sidebar.Item(to: "/", icon: "home") { Text("Home") }
Sidebar.Item(to: "/settings", icon: "settings") { Text("Settings") }
Sidebar.Divider
Sidebar.Item(to: "/logout", icon: "logout") { Text("Logout") }
}
Sub-components: Sidebar.Header, Sidebar.Item, Sidebar.Divider
A Sidebar.Item whose to matches the current route is marked .active +
aria-current="page", like a Link; active: .prefix matches sub-routes.
Navigation breadcrumb with proper separator rendering:
Breadcrumb {
Breadcrumb.Item(to: "/") { Text("Home") }
Breadcrumb.Item(to: "/docs") { Text("Docs") }
Breadcrumb.Item { Text("Components") }
}
The last item (without to:) renders as the current page (no link).
Wraps children, shows text on hover after delay: ms (unset, 500) and at
once on keyboard focus, above everything; shortcut: adds a chip. Escape
hides it. While focus is on its control the key is the tooltip's and goes
no further; otherwise it goes on — to the Popover or Menu below, which
it closes, or to the element with focus — so a tooltip under a resting
pointer never takes an editor's or a field's Escape:
Tooltip("Click to save", shortcut: "Ctrl+S") {
Button("Save").primary
}
Displays an image or initials:
Avatar(src: "/photo.jpg", alt: "User")
Avatar(initials: "MO").primary
Avatar(initials: "J").lg
Flags: .sm, .lg, .primary
Loading placeholder:
Skeleton(height: "20px", width: "200px")
Skeleton(size: "48px").circle
Flags: .circle
Slide track with dots and autoplay:
Carousel(autoplay: true, interval: 5000) {
Carousel.Slide { Image(src: "/1.jpg", alt: "Slide 1") }
Carousel.Slide { Image(src: "/2.jpg", alt: "Slide 2") }
}
Icon-only button with aria-label:
IconButton(icon: "close", label: "Close")
IconButton(icon: "edit", label: "Edit").primary { on click { editItem() } }
IconButton(icon: "menu", label: "Actions", aria-haspopup: "menu", aria-expanded: open) { on click { toggleMenu() } }
Flags: .sm, .lg, .primary, .danger. The label is the accessible name (aria-label, and the title unless a title: is written), never visible text; every other named argument (type, disabled, aria-*, data-*) is an attribute as on Button.
Range input with reactive value:
Slider(bind: volume, min: 0, max: 100, step: 1, label: "Volume")
Date input:
DatePicker(bind: selectedDate, label: "Start Date", min: "2026-01-01")
Styled file input:
FileUpload(accept: "image/*", label: "Upload Photo") {
on change(event) { handleFile(event) }
}
FileUpload(accept: ".pdf,.doc", label: "Documents").multiple
WebFluent includes 62 built-in SVG icons, rendered inline. Available icons:
home, menu, search, close, user, settings, check, plus, minus, edit, trash, star, heart, mail, bell, download, upload, eye, link, calendar, filter, info, warning, arrow-left, arrow-right, chevron-down, chevron-right, chevron-left, logout, copy, sun, moon; for an editor or a tool: folder, folder-plus, folder-off, file, file-plus, refresh, alert-circle, clock, dot, diff, book, git-pull-request, pin, git-branch, more-horizontal, window-minimize, window-maximize, outline, help-circle, collapse-all, locate, chevron-up, globe, external-link, panel-down, panel-up, code, monitor, git-commit, play
Usage:
Icon("home")
Icon("search").lg.primary
IconButton(icon: "close", label: "Close")
Sidebar.Item(to: "/", icon: "home") { Text("Home") }
A flag is written tight after the element, its arguments or another flag:
Button("Save").primary.lg. It sets a Bool prop (.required,
.outlined, .pill) or picks the case of an enum prop (.lg is size: .lg, .primary is tone: .primary); a case that more than one prop has
is named — tone: .info. Each component's flags are listed in its entry
above; the language server offers them after ., and a flag the
component does not take is an error. A Bool prop may be given a
condition instead — Button("Light", outlined: theme != "light") — and
the flag's look follows it.
Sizes: .sm, .md, .lg (Spacer and gap: also take .xs, .xl)
Tones: .primary, .secondary, .success, .danger, .warning, .info
Shape: .rounded, .pill
Surface: .flat, .elevated, .outlined
Width: .full, .fluid
Text: .bold, .italic, .underline, .uppercase, .lowercase, .left, .center, .right, .muted, .heading, .subtitle
Heading level: .h1 … .h6
Input type: .text, .email, .password, .number, .search, .tel, .url, .date, .time, .datetime, .color
Animation (on every element): .fadeIn, .fadeOut, .slideUp, .slideDown, .slideLeft, .slideRight, .scaleIn, .scaleOut, .bounce, .shake, .pulse, .spin, .expand, .collapse
Speed (on every element): .fast (150ms), .slow (500ms)
Every element takes the universal motion props: an enter animation as a
flag or animate:, exit:, delay:, duration:, speed: (.fast,
.normal, .slow — the flags .fast and .slow are this prop),
stagger: inside a list, easing:, on: (when the animation plays —
.mount or .enterView), shared: (a name it keeps across a route
change) and count: (a number that counts to its new value). Beside
them, every element also takes class:, ref:, and mount:/update:/
cleanup: (see Your own JavaScript).
An element that stands still plays its enter animation as a stylesheet rule, so a static page animates before its script has loaded. Everything the runtime plays — a branch arriving or leaving, a list reordering, a route changing, a replay — goes through the Web Animations API, where an animation that is interrupted gives way to the one that replaced it.
// Mount animation (plays once on appear)
Card.elevated.fadeIn { Text("…") }
Heading("Title").h1.slideUp.slow
// Enter and exit: on the root of an if, for or show, the branch plays
// them; anywhere else the element plays its own exit when it leaves
if showPanel {
Card(exit: .fadeOut).scaleIn { Text("Animated panel") }
}
// List with stagger; a keyed list moves items instead of rebuilding them
for item in items by item.id {
Text(item.name, exit: .fadeOut, stagger: "50ms").slideUp
}
// Replay on hover (via event)
Card.outlined.fadeIn {
on mouseenter(event) { replayAnimation(event.currentTarget, "fadeIn") }
}
// Plays when the reader has scrolled to it, and once
Section(animate: .fadeIn, on: .enterView) { Text("Further down") }
// A box that opens to the height of its content, which CSS cannot do
show details { Stack(animate: .expand) { Text("As much text as there is") } }
// A number that arrives rather than jumps, formatted the whole way
Text(format(revenue, .currency), count: "600ms")
// The same element carried from one page to the next
Image(post.cover, alt: post.title, shared: "cover-{post.id}")
// Orchestration: `after:` is measured from the step before it, so these
// start at 0ms, 120ms and 240ms. It writes the clock onto the elements
// as `delay:` and leaves nothing of itself behind.
sequence {
step { Heading("Welcome").h1.fadeIn }
step(after: "120ms") { Text("What we do.").slideUp }
step(after: "120ms") { Button("Start").primary.slideUp }
}
// A handle, for a page that drives one itself: it plays when it is made
Card(ref: panel) { on mouseenter { animate(panel, "pulse", "400ms") } }
// Transition block (declarative, per-property; tokens allowed)
Button("Hover") {
transition {
background: 200ms ease
transform: $d-fast spring
}
}
// CSS transition property (in style block)
Button("Hover") {
style {
transition: all 200ms ease
}
}
A route change animates too: Router(transition: .fade | .slide, duration: "200ms") in app. Where the browser has the View Transitions API the
change plays through it — the browser's own crossfade, or the slide the
sheet defines for [data-wf-transition="slide"], which a project's own
::view-transition-* rules may restyle — and the class-based animation is
the fallback. A reader who asked for less motion
(prefers-reduced-motion) gets none: nothing started, no wait, a box that
expands simply open and a number that counts simply there.
easing: takes a named easing — .standard, .spring, .ease,
.easeIn, .easeOut, .easeInOut, .linear, .bouncy, .smooth — or
any CSS timing function as a string. Each name is a design token
($ease-standard, $ease-spring), so a theme retunes every animation at
once; a spring is sampled into the linear() easing the platform runs, so
it overshoots and settles as a spring does. motion in the config sets
what an animation does when the element says nothing:
{ "motion": { "duration": "180ms", "easing": "$ease-standard" } }Card {
style {
background: #f0f0f0
border-radius: 1rem
padding: 2rem
box-shadow: 0 2px 8px rgba(0,0,0,0.1)
transition: all 200ms ease
}
}
Style properties use CSS names (hyphenated). A value is raw CSS to the end
of its line or a ; — no quotes — so anything CSS accepts is written as
CSS: rgba(0,0,0,0.1), 0 4px 12px, "Inter", sans-serif. Two more
things may appear in a value: $token, a design token ($surface, or the
short name a property's group resolves — padding: $xl is $spacing-xl),
and {expr}, a splice that reads state. A value that reads state follows
it: style { width: {pct}% } or style { background: {tone} } repaints
whenever pct or tone changes; a value without a splice compiles to a
stylesheet rule the element carries by class.
A PDF or a deck is laid out from the same HTML and CSS the page would
get, so a style { } block, a theme and the project's .css files mean
there what they mean on the web — every property the engine draws, on any
element (see PDF Output). @media print { } applies to
paper only; a rule only a screen has (:hover, a width query) does not.
Any layout prop takes one value per breakpoint. It compiles to a class and a media query — no JavaScript, no resize listener, and the right layout in the very first paint:
Grid(columns: { base: 1, md: 2, lg: 3 }, gap: { base: .sm, lg: .lg }) { … }
Row(direction: { base: .column, md: .row }) { … }
Column(span: { base: 12, md: 6 }) { … }
The steps are base, sm, md, lg, xl, and each is a design
token — screen-sm … screen-xl — so a theme moves them and every
media query, and viewport.md, move with it. A step a value omits keeps
whatever the step below it said.
The same names are sugar inside a style { } block: @md { … } is
@media (min-width: $screen-md).
Heading("Title").h1 {
style {
font-size: 24px
@md { font-size: 34px }
}
}
A layout stays as it is written. The engine used to reflow every Row
into a column and every Grid into one column below 768px, with
!important — which took twelve overrides and five !importants to undo
on one site. It does not any more. A layout that should reflow says so:
Row.stacks { … } // a column on a narrow screen
Grid(columns: { base: 1, md: 3 }) { … } // or say what it does at each width
wf migrate adds .stacks to every Row, Grid and Column of a
WebFluent 2 project, so the old behaviour carries over, written down.
viewport stays for what is genuinely behavioural — a drawer instead of a
sidebar — and follows matchMedia, which fires once when the answer
changes, rather than on every pixel of a drag.
Style blocks support @media queries for responsive design:
Sidebar {
style {
width: 260px
position: fixed
@media (max-width: 768px) {
display: none
}
}
}
Heading("Title").h1 {
style {
font-size: 3rem
@media (max-width: 768px) {
font-size: 1.5rem
}
}
}
A @media block is compiled into styles.css under a class named by its
content, and the element carries the class — so a hundred identical cards
share one rule, and the static paint has it before JavaScript runs. Values
inside it take $tokens like any other style value.
What an inline style cannot say — how an element looks while hovered,
focused, pressed or disabled — is a nested rule in the same style { },
in CSS nesting syntax: & is the element.
Button("Publish changes") {
style {
background: $brand
color: $on-brand
&:hover { background: $brand-hover }
&:active { transform: translateY(1px) }
&:focus-visible { outline: 2px solid $focus-ring; outline-offset: 2px }
&:disabled { opacity: 0.45 }
}
}
Input(placeholder: "shop.example.com").text {
style { &::placeholder { color: $text-tertiary } }
}
Link("Home", to: "/") {
style { &[aria-current="page"] { font-weight: 600 } }
}
Any selector CSS nesting allows: &:hover, &:focus-visible (the keyboard
focus ring, not a ring on every click), &:active, &:disabled,
&::placeholder, &:focus-within, and the ARIA states a control keeps in
its attributes — &[aria-current="page"], which the router sets on a
Link; &[aria-pressed="true"], &[aria-selected="true"],
&[aria-checked="true"], &[aria-expanded="true"],
&[aria-invalid="true"] — so the rule keys off the attribute assistive
technology reads. These are stylesheet rules, so their values must be
known at build time: literals and tokens, not state. They override the
element's base declarations while the state or media condition holds (they
are emitted !important, since the base is inline); when a state rule and a
media rule set the same property, the media rule wins.
A rule that has to vary per element reads a custom property the element sets — a custom property can follow state, the rule cannot:
Button(label) {
style {
--hover-bg: {hoverColor} // state, prop or derived
&:hover { background: var(--hover-bg) }
}
}
A custom property of the element's own is read with var(--name); $name
is reserved for design tokens.
What no style { } block can say — a selector over several elements, a
@keyframes, a @font-face, a class a dozen elements share by name, a rule
on html or ::selection — goes in a .css file, and any .css file under
src/ is part of the build:
src/
├── styles.css # global rules
└── components/
├── Card.wf
└── card.css # beside the component it styles
/* src/components/card.css */
.feature {
container-type: inline-size;
}
.feature--first::before {
content: counter(feature, upper-roman);
color: var(--text-secondary);
}Card(class: "feature feature--first") { ... }
Card(class: tone) { ... } // a state: the classes follow it
The files are read in path order and bundled into styles.css after the
engine's rules (so on equal specificity yours win) and before the rules that
style { } blocks compile to (which carry tripled specificity, as the
inline styles they replace beat any sheet). They are minified with the rest.
var(--token) reaches every design token the theme declares. There is no
scoping: a class is global, and named on purpose.
meta.stylesheets in the config still links a sheet that is not built —
a file in public/ or a URL — ahead of styles.css.
A .js file under src/ is part of the build the same way — see
Your own JavaScript.
A theme is written in WebFluent, in your own src/:
theme Brand {
color-primary: #3B82F6
color-secondary: #8B5CF6
font-family: Inter, sans-serif
radius-md: 0.5rem
}
Every token you do not name keeps its baseline value, so a theme is only as
large as the difference you want. A theme may also declare tokens of its own
(surface-raised: #131519, viz-1: #ff6a2b); every token becomes a custom
property on :root, usable from any style block as $surface-raised and
from a stylesheet as var(--surface-raised). Declare one and it is used
automatically; declare several and pick one with "theme": { "name": "Brand" }.
Dark mode. Declare a second theme with the tokens that change and name
it in the config: "theme": { "name": "Brand", "dark": "Night" }. Its
tokens apply under prefers-color-scheme: dark, and whenever the reader
chose — setTheme("dark"), setTheme("light"), setTheme("system"),
kept across visits; theme reads the choice.
theme Night { color-background: #0B1220 color-text: #E5E7EB color-surface: #111827 }
Keyframes. animation Name { from { … } 50% { … } to { … } } declares
keyframes: play them with animate: .Name or exit: .Name on any element,
or write animation: Name 1s infinite in a style. @container (…) { }
inside a style block is a container query, passed through as written.
The baseline names system fonts on purpose. A theme that names a web font
lists where to fetch it, and every page links it ahead of styles.css with a
preconnect to its origin:
{ "meta": { "fonts": ["https://fonts.googleapis.com/css2?family=Manrope:wght@400..800&display=swap"] } }meta.stylesheets links extra stylesheets the same way — a file in public/
by site-relative path, or a URL. It is for the few things no element-level
style { } can say (html { background }, ::selection), not for component
styling, which belongs in .wf source.
For values a machine supplies (a deploy pipeline injecting a brand colour),
theme.tokens in webfluent.app.json still applies, on top of the theme:
{ "theme": { "tokens": { "color-primary": "#8B5CF6" } } }build/
├── index.html # the shell (SPA) or the pre-rendered page (SSG)
├── app.js # the runtime this program needs, stores, components
├── pages/<Name>.js # one chunk per Page, loaded when its route shows
├── pages/<Name>.css # the style rules only that page reaches
├── styles.css # tokens, only the component rules the project uses,
│ # the project's own .css files, and the compiled
│ # style { } rules more than one page shares
├── sw.js # with "offline" in the config: the service worker
├── *.gz # every text file above, gzipped, beside itself
└── … # public/ copied to the root, sitemap.xml, robots.txt
app.js opens with the runtime — but only the part of it the program
reaches. The runtime is a set of feature modules (each, router, net,
format, icons, carousel, …), and a build reads what it needs from the
bundle it just wrote: every WF.<name> the code generator emitted, closed
over each module's dependencies, plus the glyphs the program names, which
are the only ones the icon table keeps. A page that shows static text
carries 3 of the 37 modules — an app.js of 3.7 kB gzipped; a for adds
each, a second page adds router, a resource adds net.
Nothing is hand-marked, so the set cannot fall behind the compiler. A name
a build cannot see — an icon assembled at run time, a WF.* call in a
hand-written <script> — is the one case it gets wrong, and
"build": { "runtime": "full" } ships every module for it.
:root carries only the design tokens something in the output names —
a stylesheet, a script, a page, a file copied from public/ — and the
tokens those name in turn.
wf build --stats prints what the build weighs: every text file with its
gzipped size and how it moved since the last build, and each runtime module
with its size and what reached it.
What it weighs
app.js 10.6 kB (3.6 kB gzipped) −0.1 kB
styles.css 10.0 kB (2.7 kB gzipped)
Runtime: 3 of 37 modules, 23.4 kB of 220.3 kB (before minifying)
core 18.6 kB always
pages 2.5 kB WF.page
hydrate 0.9 kB WF.hydrate
left out: motion helpers format form text when match slot each show …
build.budget names a gzipped size an output must stay under. A build over
it prints a warning and goes on — a build that fails on size is a decision
for a project's own CI, which can read the same numbers:
{ "build": { "budget": { "app.js": "40 kB", "styles.css": "30 kB" } } }Each build records what it weighed in .wf-sizes.json beside the project
(not in the output, which is published), so the next --stats shows the
difference.
build.compress (on by default) writes <file>.gz beside every text
output over a kilobyte — HTML, JavaScript, CSS, SVG, JSON, XML — for a host
that serves a precompressed file when it has one (nginx gzip_static on,
Apache MultiViews, most CDNs and static hosts). The compressor is the
engine's own, within a percent of zlib's default level, so the build has no
dependency for it; wf serve sends the same files, and compresses on the
way out what has none, so what Lighthouse measures against the dev server is
what a deployed site sends. Hosts that ignore .gz files lose nothing.
build.minify (on by default) strips comments and whitespace from the
bundle and the sheets. build.split (on by default) writes each page as its
own chunk: a static build's page links app.js and its chunk side by side,
and a single-page build fetches a chunk the first time its route is shown.
A literal in a style { } block is compiled into a stylesheet rule under a
class named by the block's content, so a hundred identical cards share one
rule; only a value that reads state is set on the element. With split, a
rule only one page can reach — in its body, or through components no other
page uses — is written to pages/<Name>.css, which the page links after
styles.css and the router loads before drawing the page; rules the App
body or two pages reach stay in styles.css.
Images are given decoding="async"; the first on a page is eager with
fetchpriority="high" and every later one loading="lazy", unless the
element sets loading: itself.
app.js opens with the runtime, reachable as window.WF from the console,
a script, or the studio. The compiled pages call it; a hand-written script
may too. Its surface:
| Group | Functions |
|---|---|
| State | signal(v) → getter with .set/.update/.subscribe; effect(fn); computed(fn) |
| Elements | el(tag, attrs, …children) — a thunk attribute or child follows state; text(v); props(given, defaults); onRoot(node, event, fn); classes(node, fn); bound(control) — what a bound control holds, typed |
| Bodies | when(parent, cond, then, else, anim), and held(read) — what an if let binding reads: the value now, or the last one while the branch leaves; each(parent, list, item, {key, index, live, enter, exit, …}) — keyed items keep their nodes across inserts, removals and moves, and with live across a new value too, handed to item(value, index, itemOf, indexOf) as accessors; show(parent, cond, body, anim); match(parent, key, arg, arms) — an arm is called with the value and an accessor of it, and drawn again when the arm changes |
| Motion | animate(target, name, duration) → a handle with play/cancel/finished, playing when it is made; replay(node, name); animateIn/animateOut(node, name, duration, delay, easing); expand(node, open, duration, easing) to the measured height; countTo(node, from, to, duration, format) and counted(node, valueFn, duration, format); onEnterView(node, name, …); shared(node, name); mark(node, attrs) puts data-wf-exit/data-wf-delay/data-wf-duration/data-wf-animate on a component's root — what when, each, show and match play before removing an element, and what times its enter animation |
| Routing | router(routes, container, {transition, duration}) — fade or slide plays the old page out and the new one in; navigate(path); params(); activeLink(a, href, prefix); page(name, fn); loadPage; loadSheet |
| Data | resource(url, options) → {state, data, error, refreshing, pending, reload, invalidate, cancel}; request(url, options) → the parsed body (await fetch(…) in an action compiles to it); fetch(url, options); store(name, define, options) — a proxy that builds define() on first read; dropRouteStores(); emit(props, event, …args); locales(default, tables) → WF.i18n |
| Stores, seen | watchStores(fn) → every action with the state on each side of it, and the function that stops it; storeSnapshot(name?); restoreStore(name, values). Nothing is recorded while nothing is watching |
| Widgets | toast(message, tone, ms); dialog(el, read, write); popup; tabs; drawer; announce(text); carousel; tooltip; menu; field |
| Safety | safeUrl(url) → the URL where a browser may follow it, "" where it may not; sanitize(html) → the allow-listed markup. Both have a build-time twin, pinned by a shared case table |
| Text | markdown(text) → HTML; highlight(code, lang) → HTML with wf-tok-* spans (wf, json, bash, css), the twin of codegen::highlight |
| Interop | attach(node, mount, update, cleanup) — what mount:/update:/cleanup: compile to on any element (a component's fragment is attached at its root); portal(box, read) — what Portal compiles to: box kept in the node read() names (read again once the drawing is over), aside on null, gone with its scope, returning what runs the lifetimes its block held; wf:render on document after each drawing; jsonAttr(raw) — an attribute holding JSON, as the prop it stands for |
| Boot | mount(fn, container); hydrate(fn, container) and hydrating(root) — draw beside a pre-rendered page and take it over in place; live(node) — something holds node, so the takeover keeps the drawn one; mainOf(root); setBasePath; setSsgMode; __debug, __reg (the studio's) |
| Scopes | onCleanup(fn) — run when what owns it leaves; attempt(fn) — an action that rolls itself back on a throw |
| Keys | onKey(node, combination, fn); keyIs(event, combination); keyLabel(combination), keyAria(combination) — what on key("mod+k"), keyIs and keyLabel compile to |
| Focus | trapFocus(node, read) — what trapFocus: compiles to |
| Long lists | windowed(node, listFn, itemFn, options) — what a VirtualList's for compiles to |
| Gestures | gesture(node, name, fn), draggable(node, value, options), dropTarget(node, options), splitter(node, read, write, options) — what on longpress, on swipe, drag:, on receive and Splitter compile to |
| Values | caseOf(v), payload(v) read an enum case and its payload; dateOf, timeOf, urlWith, urlQuery are the scalar helpers behind .date(), .time(), .with() and .query() |
Set meta.site_url and the compiler writes everything a search engine and a link
preview need. Without it, the tags that require an absolute URL are omitted
rather than guessed at — a relative canonical causes problems later, so none is
better than a wrong one.
{
"meta": {
"site_url": "https://example.com",
"site_name": "Ledger",
"description": "Fallback for pages that set none",
"image": "/card.png",
"image_alt": "The ledger, open on a desk",
"owner": "organization",
"same_as": ["https://github.com/ledger"],
"sitemap": true
}
}Every page then carries:
- a self-referencing absolute
<link rel="canonical"> - Open Graph and Twitter card tags, built from the page's title, description and image;
og:image:width/og:image:heightread from the file when the image is inpublic/,og:image:alt/twitter:image:altfrom the page'simage_alt:ormeta.image_alt, andog:localeas Open Graph spells it (en→en_US,en-GB→en_GB) with anog:locale:alternateper other locale <link rel="alternate" hreflang="…">for each locale plusx-default, when i18n declares more than one- JSON-LD (
WebSite, the owner,WebPage/Article, and aBreadcrumbListderived from the route). The owner issite_nameas anOrganization, or aPersonwith"owner": "person"(andjob_title);same_aslists its other profiles;owner_detailsadds any other schema.org property as written (alternateName,worksFor,alumniOf,knowsAbout, … — not the node's@type,@id,name,url,jobTitleorsameAs, which areE111);WebSite.publishernames it, and on a personal site every page isaboutit
meta.preload lists files on the site to fetch with the page — a font a
project .css file's @font-face names, the first screen's picture — as a
<link rel="preload"> in every head, ahead of the stylesheets, as taken
from the extension (a font is also crossorigin). One on another origin is
E111.
A static build writes /contact as contact/index.html, and a host such as
GitHub Pages answers /contact with a 301 to /contact/. build.clean_urls
keeps the canonical address one the host serves directly: "file" also writes
contact.html (and addresses the page's assets from the root under
base_path); "directory" names every route /contact/ — canonical, og:url,
sitemap, breadcrumbs, and every Link(to:)/navigate() to one of the pages.
A build also writes sitemap.xml and robots.txt. Dynamic routes, catch-alls and
noindex pages are left out of the sitemap. priority and changefreq are not
emitted: Google ignores both.
What the compiler guarantees, as properties of the output rather than advice:
- Text is text. Every string an element shows goes in as a text node.
Unsafe.Html(markup)is the one door for markup, named so a review greps for it, and every use draws aV03. Anhtml:(ormarkdown:) written on an element is an attribute of that name, as any argument an element does not declare is (C04), never markup. - No inline handlers.
Button("x", onclick: expr)is a compile error pointing aton click { }. - No URL a browser would run.
href:,src:,to:,poster:andnavigate()takehttp,https,mailto,tel,sms,ftpor a relative URL. A literal is refused where it is written; anything else goes throughWF.safeUrlat the moment it is used.javascript:,data:,vbscript:,blob:andfile:are refused — but for a picture carried in its address (data:image/png;base64,…,data:image/svg+xml,…), which anImage's or anAvatar'ssrc:, a Markdown picture and a sanitised<img>take (WF.safePicture): an img never runs what it shows. A link, a frame or a form never takes one, and no otherdata:is a picture. rel="noopener noreferrer"on any link withtarget:.- A
Secretcannot escape — shown, spliced, logged or persisted is aT12.
sanitize(html) is an allow-list of elements and attributes — no script,
no style, no frame, no form, no on*, and every href/src through the
scheme check. It runs at build time and in the browser from the same list,
so a pre-rendered page shows the sanitised body immediately.
Unsafe.Html(sanitize(post.body)) // markup from outside the project
Sessions belong in an httpOnly cookie, not in persist: any script
that reaches the page can read localStorage. api B(…) { credentials: .sameOrigin } sends the cookie with every call and the page holds no
token at all.
env splits in two. env.NAME is replaced at build time and the value
is in the bundle, so only a public name may be read from a page, a
component, a store or an api block: one that begins PUBLIC_, or one
public_env lists. Any other name is a compile error naming the file and
the line; the values still reach wf render, which runs on a server.
{ "env": { "PUBLIC_API": "/api/v1", "STRIPE_SECRET": "sk_…" }, "public_env": [] }wf audit prints what a review asks for: every Unsafe.* and whether it
is sanitised, the project's scripts and what each declares, every other
origin, everything kept on the reader's machine,
every env name and whether it is public, the policy this build ships, and
what the compiler depends on. --json for a tool; it never fails a build.
"build": { "csp": true } — on for a project wf init creates — emits a
Content-Security-Policy meta tag and a _headers file for hosts that
read one. frame-ancestors is only in _headers, because a browser
ignores it in a meta tag. It is 'none', with X-Frame-Options: DENY
(wf serve sends the same); a project that places a Frame with a
site-relative src: ("/preview/") gets 'self' and SAMEORIGIN, so it
may frame its own pages and no one else may. The policy is widened by exactly the origins
meta.fonts, meta.stylesheets and meta.scripts declare, so a declared
font or library is never blocked by the policy that ships beside it; the
project's own scripts are same-origin and need nothing. meta.connect names
the origins a page may send requests to besides its own — an API elsewhere,
an analytics endpoint (https://*.google-analytics.com) — as connect-src;
meta.img names those it may load images from — a CDN, a tag's tracking
pixel (https://www.googletagmanager.com) — as img-src; meta.frame the
origins a page may frame, as frame-src; connect and frame take an
app's own scheme alone too ("ipc:", "wfpreview:"), never the web's
(https:, data:, blob:); meta.wasm: true adds 'wasm-unsafe-eval'
for a page that compiles WebAssembly, meta.style_elements: true adds
'unsafe-inline' to style-src for one whose scripts write <style>; an entry with a path, or a keyword,
is E111. A vendor's inline set-up
snippet (dataLayer, gtag('config', …)) is a plain script under src/.
The build reads its own output back and holds it to that policy: an
inline <script> or <style>, a style= attribute, an on* attribute,
or a script or stylesheet from an origin the policy never named stops the
build. One exception is widened rather than refused — a style { } value
that reads state is written on the element, so style-src gains
'unsafe-inline' and the build names the pages that caused it. A page's
own head { script(src: …) } or stylesheet link is added on the live
page, so it is held to the policy where it is written: one from an origin
the config does not declare is E902 on that line.
meta.integrity gives a declared external asset its subresource-integrity
hash (emitted with crossorigin="anonymous"); a build warns about one that
has none. The build never fetches the file to work the hash out.
// src/translations/en.json
{ "nav.home": "Home", "greeting": "Hello, {name}!" }
// src/translations/ar.json
{ "nav.home": "الرئيسية", "greeting": "أهلاً، {name}!" }{
"i18n": {
"default_locale": "en",
"locales": ["en", "ar"],
"dir": "src/translations"
}
}wf init my-app --locales en,ar (spa or static; the first is the
default) writes the i18n config above with meta.lang the default, every
visible string of the starter through t("…"), a language switch with one
entry per locale, and src/translations/<locale>.json for each: the
default's holds the starter's English (the text its pages and tests are
written in), another the starter's translation into it where it has one
(Arabic), otherwise the same English to translate. A tag is en, ar,
pt-BR, zh-Hant; one named twice, an empty list or a code that is not
a tag is refused. pdf and slides take none. The library:
webfluent::init::create_with(dir, name, &Options { template, locales }).
Text(t("nav.home")) // Translated text
Text(t("greeting", { name: user.name })) // With interpolation
Text(t("items", { count: n })) // A plural form, picked by `count`
Button("EN") { on click { setLocale("en") } } // Switch locale
Button("AR") { on click { setLocale("ar") } } // Auto-RTL for Arabic
A message with plural forms is several keys: "items.one": "{count} item", "items.other": "{count} items" — and items.zero, items.two,
items.few, items.many where the locale's CLDR rules call for them;
t("items", { count: n }) picks the form, exactly none items.zero when
the file has it in any language, falling back to items.other and then
items — the static paint and the live page by the same rules. A key's
forms come from one language. format and ago speak the locale too.
wf translations is the table: what each locale lacks, what nothing names.
RTL locales (automatic dir="rtl", from the first paint when the default
locale is one): ar, he, fa, ur, ps, sd, ckb, ug, yi, dv,
ks, syr, arc, nqo — with any region (ar-EG)
A Latin code or figure inside Arabic text (2026-0417) can come out with
its parts reordered; give it its own left-to-right run:
Text("2026-0417", class: "ltr") with .ltr { direction: ltr; unicode-bidi: isolate; } in the project's CSS.
{ "build": { "ssg": true, "base_path": "/my-site" } }- Pre-renders each page to its own
index.html - Static content visible immediately (no blank screen)
- JavaScript hydrates for interactivity: it takes the painted page over in place — an element nothing holds stays as painted, never moved or drawn again, so the first paint stays the page's largest
- Dynamic content (state, if/for, fetch) handled by JS after hydration
{
"build": {
"output_type": "pdf",
"pdf": {
"page_size": "A4",
"margins": { "top": 72, "bottom": 72, "left": 72, "right": 72 },
"default_font": "Helvetica",
"default_font_size": 12,
"output_filename": "report.pdf",
"fonts": [],
"system_fonts": true
}
}
}A PDF is the page, printed — by the compiler's own paged engine, no browser and no external tool. The program is rendered to the static HTML and CSS a template renders, and that is laid out on paper:
- The cascade. The engine's rules for every built-in, the theme's
tokens, every
.cssfile undersrc/and everystyle { }block: descendant, child and sibling combinators, attribute selectors,:first-child/:nth-child,:not()/:is()/:where(), specificity, inheritance,var(),em,rem,%,::before/::aftercontent. ACardin a PDF looks like theCardon the page;class: "callout"brings its rules. - Layout. Block, flex and grid as CSS defines them —
Row,Stack,Grid,Columnside by side as on the web — absolute positioning,gap,min-/max-sizes,aspect-ratio, inline-blocks on a line (a badge in a sentence), tables sized by their content, list markers.pxis 0.75pt;100vhis the page's height. - Text. Shaped (ligatures, kerning, Arabic joining), bidirectional, broken at Unicode's line-break opportunities, aligned or justified.
- Paint. Colours, linear and radial gradients with any number of stops,
background pictures, borders per side (solid, dashed, dotted) with rounded
corners, shadows, opacity,
overflow: hidden,transform: rotate(); PNG, JPEG (embedded as it is), WebP, GIF, and SVG kept vector. - The file. Fonts embedded as subsets with the text attached (copy and search work), compressed, with title and author, an outline of the headings, and links that work.
| Element | Purpose |
|---|---|
Document(size:, landscape, margin:, title:, author:, subject:, keywords:, lang:) |
The root: paper (A4, A3, A5, Letter, Legal, Tabloid, or "210mm 297mm"), margins (one length or four), and the file's metadata; build.pdf decides what it omits |
Header(on:) · Footer(on:) |
Drawn in the top/bottom margin of every page, or on: .first, .rest, .odd, .even (.all is the default) |
Background(on:) |
Behind each page's content, the whole page edge to edge: a cover's gradient, a band, a sidebar strip |
Watermark("DRAFT") |
Large, faint, rotated text behind each page; styled by .wf-watermark |
TableOfContents(levels: 2, title:) |
The headings with the page each lands on, dotted leaders, each entry a link |
PageBreak |
A new page; so do break-before: page / break-after: page |
Section, Paragraph |
Grouping, and a paragraph's spacing |
Chart(kind:, data:, x:, y:) |
.bar (grouped, or stacked), .line, .area, .pie, .donut over a list of records, as vector graphics; colors, ink, grid, legend, labels, unit, width/height |
QrCode(value, color:, background:) |
A QR code of a URL or any text |
Inside a Header, Footer or Background, page and pages are the
page's number and the count: Text("Page {page} of {pages}"). The html
or body background fills every page edge to edge, so a dark theme prints
dark. Chart and QrCode draw on a web page too, as an SVG made at build
time — there what they read must be known then (a literal, a const, a
data file), or it is E109.
- Paragraphs break between lines, two at least on each side (
orphans,widows). - Tables break between rows, and the header row is drawn again on every page the table continues on.
- A grid breaks between its rows; side-by-side columns taller than a page break each on its own.
- A heading never ends a page: it moves with what follows.
break-inside: avoidkeeps a box whole (a totals block, a signature).- A box that runs onto the next page is painted on both, its border open at the break.
The default faces are Liberation Sans, Serif and Mono, built into wf, so
Helvetica, Arial, system-ui, sans-serif, Times, serif, Courier
and monospace resolve with nothing installed. Any other family is a file:
.ttf/.otf under fonts/, src/fonts/, public/fonts/ or pdf.fonts,
named by the family inside it (variable fonts drawn at the asked weight; a
missing bold or italic synthesised), or a local @font-face. A character no
named face has comes from one that has it — the project's fonts, then the
machine's, which the build names in a note: so the document can be made
reproducible by copying them into fonts/. system_fonts: false forbids
the machine's; a character no font has is reported, never drawn as ?.
A document whose lang is ar, he, fa or ur is laid out right to
left — text, flex rows, grids, table columns, list markers — with runs of
the other direction ordered by the Unicode bidirectional algorithm.
const SALES = [
{ month: "Jan", web: 41, store: 22 },
{ month: "Feb", web: 48, store: 25 },
]
page Report(path: "/", title: "Report", description: "The quarter.") {
Document(size: "A4", title: "Q1 report", author: "Company Inc.") {
Background(on: .first) {
Stack { style { height: 100%; background: linear-gradient(160deg, #0B1220, #1E3A8A) } }
}
Header(on: .rest) { Text("Company Inc. · Q1").muted.sm.right }
Footer { Text("Page {page} of {pages}").muted.sm.center }
Watermark("DRAFT")
Heading("Q1 Report").h1
TableOfContents(levels: 2)
PageBreak
Heading("Sales").h2
Row(gap: .lg) {
Chart(kind: .bar, data: SALES, x: "month", y: ["web", "store"], unit: "k")
Chart(kind: .donut, data: SALES, x: "month", y: "web", labels: true)
}
Table {
Table.Head { Table.Row { Table.Cell("Region") Table.Cell("Revenue") } }
Table.Body { Table.Row { Table.Cell("North America") Table.Cell("$2.4M") } }
}
Card {
style { break-inside: avoid }
Text("Revenue grew 15% this quarter.").bold
}
QrCode("https://example.com/q1")
}
}
Refused in a PDF or a deck (E109, where it is written): controls
(Button, Input, Select, Checkbox, Switch, Form, …), navigation
(Router, Navbar, Sidebar, Tabs, Menu), overlays (Modal, Dialog,
Toast, Tooltip), media that plays (Video, Audio, Carousel),
elements a script draws (Host, Element, Portal), handlers, resource, motion.
Everything static draws.
{
"build": {
"output_type": "slides",
"slides": {
"size": "16:9",
"default_font": "Helvetica",
"default_font_size": 24,
"margin": 60,
"show_slide_numbers": true,
"footer_text": "My Deck",
"background_color": "#1A1A19",
"chrome_color": null,
"output_filename": "deck.pdf",
"fonts": [],
"system_fonts": true
}
}
}The same engine, one Slide = one page: a slide is a box the size of
the page with slides.margin as its padding (a deck's own rule overrides
it). A deck is a Presentation { } inside a page; slide elements outside
one are a compile error. A Presentation holds slides, a for or an if
over them, and components whose body is a slide — so a deck writes its
openers and footers once and makes a slide per item of its data:
component Point(_ label: String, n: Number) {
Slide { Heading(label).h1 Text("{n} of 3").muted }
}
page D(path: "/", title: "Deck") {
Presentation {
TitleSlide("Three points")
for p, i in ["Fast", "Small", "Typed"] { Point(p, n: i + 1) }
}
}
| Element | Purpose |
|---|---|
Presentation { ... } |
The deck — its children are slides |
Slide { ... } |
Freeform, a column from the top |
TitleSlide("Title", subtitle: "…") |
Cover, centred |
SectionSlide("Label").primary |
Full-bleed band; .primary, .success, .danger, .warning, .info |
TwoColumn { Container { … } Container { … } } |
Two equal columns |
ImageSlide(src: "…", caption: "…") |
A picture with an optional caption |
Each is styled by the engine's sheet (every size relative to
default_font_size), and a project's rules or a style { } change it as
anywhere: Slide { style { background: linear-gradient(135deg, #1E3A8A, #0F172A); color: #fff } }. Everything a document draws, a slide draws —
layout, charts, QR codes, pictures, tables, Arabic.
slides.size:"16:9"(960×540pt, default),"4:3"(720×540),"A4-landscape"(841.89×595.28), or"WIDTHxHEIGHT"in points;slides.width+slides.heightoverride it.show_slide_numbers(n / total, bottom right) andfooter_text(bottom left) are the chrome;chrome_colorcolours it, flipping between dark and light on the slide's background whennull.background_colorpaints every slide.- What runs past a slide's edge is clipped, with a note naming the slide; the build does not fail.
page Deck(path: "/", title: "Q1 Review", description: "The quarter.") {
Presentation {
TitleSlide("Q1 Review", subtitle: "Company Inc. — March 2026")
SectionSlide("Numbers").primary
Slide {
Heading("Highlights").h1
List {
List.Item { Text("Launched 3 new products") }
List.Item { Text("Revenue grew 15%") }
}
}
TwoColumn {
Container { Heading("Wins").h3 Text("New enterprise deals") }
Container { Heading("Risks").h3 Text("Supply chain delays") }
}
ImageSlide(src: "chart.png", caption: "Q1 revenue by region")
}
}
A plain "…" string reads the escapes \n \t \r \\ \" \{ \} and {…}
splices. Two other forms exist for text a plain string makes you spell
twice:
// Raw: no escapes, no splices — the text is what is written. As many `#`
// as it takes for the text not to close it.
const SAMPLE = #"page Home(path: "/") { state n = 0 }"#
const TRICKY = ##"a raw string ends with "# — like that"##
// Block: over as many lines as it likes, escapes and splices on, and the
// indentation the source gave it removed. The closing `"""` says how far
// the text was indented, and that much comes off every line.
const LETTER = """
Dear {name},
thank you.
""" // "Dear Ada,\n thank you."
A splice may say how to show its value — {value:.style} or
{value:.style(option)}, which is format(value, .style, option) written
where it is read:
Text("Total: {total:.currency}") // Total: $1,234.50
Text("{count:.integer} items") // 1,235 items
Text("Shipped {when:.date(long)}") // Shipped March 5, 2026
A splice opens with a name, a ( or a [, and closes on the same line;
anything else — "{key: value}" in prose, "{" alone — is text.
wf build, wf check, wf serve and the editor report the same findings, from one
pipeline (diagnostics::check::check_project). Every stage runs whatever
the one before it found, and every file is read even when another does not
parse (the parser picks up again at the next declaration), so one build
shows every mistake once. Each finding has a code, a severity, a span, a
hint, the other places it concerns and, where there is one, a fix:
error[T05]: `User` has no field `nmae`
--> src/pages/Profile.wf:3:18
|
3 | Heading(user.nmae).h1
| ^^^^
= help: Its fields are `id`, `name`
= docs: https://webfluent.monzeromer.dev/docs/guide/diagnostics#t05-a-field-or-method-that-does-not-exist
The place comes first in the file:line:col form problem matchers read;
colour only on a terminal (and never with NO_COLOR); one summary line at
the end (error: the build stopped: 2 errors, 5 warnings). A build that
stops on its findings exits 1; one that could not run — a file it could
not read, a config it could not load — exits 2. wf serve's overlay and
/__wf/status carry the structured list. Errors stop the build; warnings
do not. The codes live in one registry (diagnostics::codes), which the
guide's chapter, the editor's links and the tests read.
"lints": { "A11": "off", "U": "error", "R01": "warn" } in the config says
what a finding counts as, by code or family. A warning may be turned off or
raised; an error may be lowered only when it cannot ship a broken page
(R01, C02, S04, I01, I02, I04).
One finding on one line is accepted with // wf-allow(U01) (codes or
families, comma-separated): on a line of its own it covers the next line of
code, at the end of a line that line. Only what lints could lower may be
allowed, and an allow that covers nothing is U07.
--format json|sarif|github on wf build and wf check writes the
findings for a tool (one document on standard output for JSON and SARIF,
with the progress moved to standard error; ::error annotations for a
GitHub workflow); --deny-warnings fails on a warning; wf explain T05
prints a code's entry. Where the fix is known it is part of the finding —
the editor's quick fix and the JSON's fixes: the nearest name for T05,
T06, T16, T13, E101, E103; ?. for T04; the missing props for
C01; the missing arms for T15; the flag for V01; alt: "" for A01;
derived for X06; an _ for U01–U05.
| Family | What |
|---|---|
E |
syntax and structure |
T |
types |
C |
components and their props |
R |
routes and navigation |
X |
state and reactivity |
F |
forms and bindings |
I |
translations |
D |
data, assets and what is kept in the browser |
A S P U V |
accessibility, search, persisted values, unused, vocabulary |
Errors — the program cannot mean what it says.
| Code | What it means |
|---|---|
E001 |
Text the lexer cannot read: a stray character, a string or comment that never closes |
E002 |
A syntax error: what was expected, and what was found |
E003 |
.wfx indentation that lines up with no block |
E004 |
A file in the WebFluent 2 grammar (wf migrate) |
E101 |
A component nothing declares (an element, a layout:, a part) |
E102 |
A name declared twice in one scope — two stores, a state and a derived — with the first as a related place |
E103 |
A flag, case, part, event, slot or emit the registry or the component's declaration does not know (Button has no flag or enum case huge``, with the ones it takes) |
E104 |
A layout: component with no default slot |
E105 |
A statement a render block cannot hold (n = n + 1 loose in a page) |
E106 |
An on* attribute: script in an attribute |
E107 |
A href/src/to literal naming a scheme a browser runs |
E108 |
A non-public env name read from a page |
E109 |
An element a PDF or a slide deck cannot draw |
E110 |
A project script that cannot be a plain script (a module, a public/ clash, a name something else means) |
E111 |
A setting that cannot work (meta.scripts module without as, build.elements, offline, a missing openapi.json) |
E113 |
An argument that cannot be positional |
E114 |
emit outside a component |
E116 |
Element's tag is not a custom element's |
E118 |
A name the language already has, declared as the program's own — state env, for t in …, a prop query, a const format |
E901 |
The compiler wrote JavaScript a browser would refuse (it reads back every script it writes — a WebFluent bug) |
E902 |
A page the content security policy shipped beside it would block |
C02 |
A prop your component does not declare |
S04 |
Two pages claim one route |
And what the type checker finds:
| Code | What it means |
|---|---|
T01 |
A value of the wrong type given to a prop, a state, a field, a parameter or an assignment — `count` of `C` is `String`, but `Number` is wanted |
T02 |
A case the enum does not have |
T04 |
A value that may be null read as if it were not; if let, ?? or a != null check narrows it |
T05 |
A field or method a record does not have |
T06 |
A member or action a store does not have |
T07 |
A list, record or action used as a condition, which is always true |
T08 |
A for over something that is not a list |
T09 |
An emit whose arguments do not match the event's |
T10 |
A call with the wrong number or kind of arguments |
T11 |
A match on something that is neither a resource nor an enum, or with arms of the wrong kind |
T12 |
A Secret where it would escape — shown, spliced into text, joined with +, handed to a browser global (console, storage, a fetch address), logged, or kept with persist. A secret has no string members |
T13 |
A name, or a function called, that nothing declares — a ReferenceError in the browser. event (e), value and key are names only inside a handler. Not applied to a template rendered with data |
T14 |
A comparison that is always the same: "1" == 1, a record against a string, an enum against a case it does not have |
T15 |
A match with no else that misses a case of the enum, or one with a case twice. A match expression that covers every case needs no else |
T16 |
A method a number, a string or a list does not have, with the nearest one that it does (joined → join) |
T17 |
An async action's result used before it is awaited; await outside an action, a handler, a timer or a hook (in a derived value or an effect); .pending on an action that awaits nothing |
T18 |
Arithmetic on something that is not a number ("12" * 2, items - 1) |
X01 |
An assignment to something that cannot change: a const, a data constant, a derived value (a store's too), a prop, a route parameter, a loop variable, an action |
C01 |
A required prop or record field left out — on a call, a layout:, a record (Todo(title: "x") when id has no default), a built-in's (Portal without into:); a flag gives a Bool or an enum prop |
C03 |
A part outside its owner (Select.Option with no Select around it) |
C07 |
drag:, drop…:, on receive, on dragmove, on longpress or on swipe on a component whose body is not one element (an if, a for, several elements) |
X02 |
An effect that writes, every run, something it reads — itself or through an action it calls — so it never settles |
X04 |
A derived value that calls an action that assigns state |
X05 |
use of a store nothing declares |
F01 |
bind: to something that cannot be written: a constant, a derived value, a prop, a literal |
F02 |
bind: to a state the control cannot hold (a text Input on a Number — .number holds one) |
F03 |
validate x where no control binds x, or x is derived: its rules can never pass |
R01 |
A link or navigate() to a path no page's route matches (spliced paths by their shape; a path with no leading /) |
R02 |
A route's :param with no page parameter of that name, or the reverse |
R03 |
An app with no Router (no page is ever drawn), or with two |
V12 |
A VirtualList whose body is not one keyed for: it draws every row |
E117 |
An on key("…") combination no keyboard sends — an unknown modifier, no key, a key that does not exist, in any step of a chord |
D02 |
A persist of a value storage cannot keep as JSON — a function, a File, a promise |
I01 |
A literal t("key") that no locale's translations have, so the page shows the key |
I02 |
A message and its call that disagree: a value passed the message does not use, or a placeholder the call does not pass |
I04 |
setLocale("xx") naming a locale i18n.locales does not list |
V06 |
A $token the resolved theme does not declare, so the property falls back to nothing |
An endpoint a service does not have is T06, and an argument it does not
take is T10.
A value whose type the checker cannot work out is Any, which agrees with
everything; a name nothing declares is T13. T04 also covers an item at a
fixed index (todos[0], which is nothing when the list is empty, unless a
condition like todos.length > 0 encloses it) and a field that may be null
read after a ?. (sel?.note.length). A check for null narrows in the
else branch and after if x == null { return }, and xs.filter(x => x != null) is a list of what is there ([T?] becomes [T]). A refined type
(Number(1..=30)) is held to every literal assigned to it, and a derived
value's annotation to what it works out to.
Where there is one right answer the compiler writes it instead of
reporting: a change inside a state's value — form.name = v,
items[i].done = v, items.sort() as a statement, t.done = v on a loop's
item in a handler — compiles to the state given a copy with that place
changed (WF.setIn, WF.mutated), so what reads it repaints and persist
writes it; reading items.sort(…) in an expression sorts a copy; a value
spliced into a to:, navigate(), fetch() or resource address is
encodeURIComponented where it stands for a value (not when it opens the
address, is a whole query, or follows #), in the live page and the static
paint alike.
Warnings.
| Rule | What it means |
|---|---|
A01 |
An Image with no alt (a decorative one takes alt: "") |
A02 |
An IconButton with no accessible name — label: is its name, never visible text |
A03 |
An Input with neither label: nor placeholder: |
A04 |
A Checkbox, Radio, Switch, Slider or Textarea with no label: |
A05 |
A Button with no text — no label, no aria-label:, and no Text in its block (a branch or a component it places counts) |
A06 |
A Link with no text |
A07 |
A Heading with no text |
A08 |
A Modal or Dialog with no title:, which is its accessible name |
A09 |
A Video with no controls, or a Video/Audio with no captions:/transcript: |
A10 |
A Table with no header row (Table.Head) |
A11 |
A heading level skipped — h2 straight to h4 |
A12 |
A page with no h1 |
A13 |
A theme's colour pairing falls below the WCAG AA contrast ratio |
A14 |
A role: that requires particular children (tablist → tab, list → listitem, menu → menuitem, …) holds a control that is not one |
A15 |
A control's aria-label does not contain its visible text — its label, the literals in its block, and what a component in the block renders from its literal props — so what a voice-control user says does not match what they see |
S01 |
A page has no title |
S02 |
A page has no description, so its search snippet is written for it |
S03 |
A description longer than ~160 characters, which a search result truncates |
P01 |
A persist at version: n with nothing to bring an older version forward, so what a returning reader had is discarded |
P02 |
A migrate step above the declared version:, which never runs |
P03 |
A persist inside a store(scope: .route): the route change drops the store, and the next read builds it again from storage, so the value comes straight back |
V01 |
A bare word in an argument that nothing in scope declares — a name misspelled, or a flag written without its dot (did you mean .center?); an if let binding or a case's payload is declared through its whole block |
V02 |
A flag whose class no stylesheet — the engine's or one of the project's .css files — defines; the registry keeps this from happening for the built-ins |
V03 |
An Unsafe.Html, and whether what it puts in went through sanitize |
V04 |
A class: naming one of the built-ins' wf-* classes, which brings that built-in's rules with it |
U01–U02 |
A state or derived value nothing in its page or component reads (an assignment alone does not read it) |
U03 |
A component nothing places, names as a layout, or reaches as a part |
U04 |
A store member — state, derived, action — nothing reads, inside the store or as Store.member |
U05 |
An action nothing calls; a name that starts with _ is understood to be unused on purpose |
U07 |
A // wf-allow(CODE) that covers nothing it names, or names an error no allow may silence |
C04 |
A named argument a built-in does not declare, written to the element as an attribute |
C05 |
A positional argument a built-in does not take |
C06 |
A positional argument bound to a component's first prop, which is not marked _ |
D05 |
A font, stylesheet or script from another origin with no meta.integrity hash |
T19 |
A value that may be null spliced into text, where it shows as null |
R04 |
A relative src:/href: literal on a nested route, which resolves against the page's address |
F04 |
Checkbox(checked: x) with no bind: and no on change: the box toggles, the state does not |
F05 |
A Select whose bound state starts as none of its options |
T20 |
A list or a record shown as text — a list's items run together, a record shows [object Object] |
D01 |
A site-relative src:, poster:, captions:, transcript: or spawn(…) naming a file public/ does not have |
D03 |
A persist in a component placed more than once, or in a loop, which every instance shares; key: gives each its own (persist open = false { key: item.id }) |
D04 |
A persisted value's shape changed since the last build with no version: raised (the shapes are kept in .wf-cache/persist.json) |
I03 |
A message one locale has and another does not |
A16 |
A literal id: inside a for, or in a component placed more than once, which several elements then share |
A17 |
A Frame with no title:, which is how a screen reader announces it |
U06 |
Code after a return in the same block |
U08 |
A condition that is always the same — a literal, or a value compared with itself |
U09 |
A for with no by whose items hold state, a control or a stateful component, which starts again when the list changes |
V05 |
A style property no CSS defines (colr:), with the nearest one that it does |
V07 |
A bare number spliced as a whole length (width: {pct}), which the browser drops |
T21 |
A resource match with no error arm and no else |
U10 |
A match's else that no value reaches: every case has its arm |
E112 |
A key in webfluent.app.json nothing reads, with the nearest one that is |
E115 |
A project script the compiler could not read (still linked; its names are not in scope) |
V08 |
An icon the runtime does not draw |
V09 |
A handler for an event the element does not fire |
V10 |
A Host(tag: …) it does not make — it makes div, span, canvas, svg, section, figure, pre, p, ul, table; any other is a div |
V11 |
A component named like a built-in (Footer, Chart): a call of that name draws the built-in, so the component is never drawn |
X06 |
A state whose initial value reads now, viewport, query, hash, theme, network or update (outside a lambda) and that nothing assigns, binds or changes in place: it holds the value it started with, so state clock = now(every: 1.seconds) never ticks. The fix makes it derived |
The heading-outline rules (A11, A12) do not apply to Presentation or
Document output, where an h1 per slide or per section is correct.
WebFluent 4 → 5 needs no rewrite: 5.0 refuses what used to compile and
then fail in the browser. Run wf check (it writes nothing); every finding
has a code, a place and what to do, wf explain CODE prints its entry, and
the editor's quick fixes apply the ones with a known fix. While a project
catches up, lints lowers what ships no broken page and // wf-allow(CODE)
accepts one finding on one line.
wf migrate [path] [--check] [--stdout] does three things.
WebFluent 2 → 3 rewrites every .wf under src/ in place, with a
note for anything that needed a decision. It is a change of spelling and
nothing else: the migrated project builds to what it built before. A
WebFluent 2 file given to wf build is refused with a pointer to it.
WebFluent 3 → 4 is not a change of spelling but of what the compiler
allows, so it runs over the project rather than the files. It adds every
env name the pages already read to public_env — preserving what the
project did, and printing the list, because "public" means anyone who
opens the site may read the value. It then names, with the file and the
line, everything 4 refuses that 3 allowed: an on* attribute, a
javascript: or data: URL. Those have no automatic rewrite. Finally it
states the changes that need no edit and are worth knowing: a store is
built on first read rather than at boot, a persist value follows the
site's other tabs, and WF.store/WF.host were renamed.
WebFluent 4 → 4.2 runs first, since 4.2 refuses external: a remote
module becomes a meta.scripts entry (module: true, as:, its integrity
moved to meta.integrity; its calls keep working, typed Any), a local one
a plain script under src/ with its exports off and its declared
signatures as JSDoc (X.f(…) becomes f(…)), and an external element
call Element("tag", …). It lists every .js already under src/, which
now ships on every page.
{
"name": "My App",
"version": "1.0.0",
"author": "Name",
"theme": {
"name": "Brand",
"tokens": {},
"builtin": "full"
},
"build": {
"output": "./build",
"minify": true,
"ssg": false,
"base_path": "",
"clean_urls": null,
"csp": false,
"split": true,
"compress": true,
"sourcemap": false,
"runtime": "auto",
"budget": {},
"media": { "formats": ["webp"], "widths": [480, 960, 1440, 1920], "quality": 78, "pipeline": true },
"output_type": "spa",
"elements": [],
"pdf": {
"page_size": "A4",
"margins": { "top": 72, "bottom": 72, "left": 72, "right": 72 },
"default_font": "Helvetica",
"default_font_size": 12,
"output_filename": null,
"fonts": [],
"system_fonts": true
},
"slides": {
"size": "16:9",
"width": null,
"height": null,
"default_font": "Helvetica",
"default_font_size": 24,
"margin": 60,
"show_slide_numbers": false,
"footer_text": null,
"background_color": null,
"chrome_color": null,
"output_filename": null,
"fonts": [],
"system_fonts": true
}
},
"dev": { "port": 3000, "hot_reload": true },
"motion": { "duration": "180ms", "easing": "$ease-standard" },
"meta": {
"title": "",
"description": "",
"favicon": "",
"touch_icon": "",
"lang": "en",
"site_url": "",
"site_name": "",
"owner": "organization",
"same_as": [],
"job_title": "",
"image": "",
"image_alt": "",
"sitemap": true,
"fonts": [],
"preload": [],
"stylesheets": [],
"scripts": [],
"connect": [],
"img": [],
"frame": [],
"wasm": false,
"style_elements": false,
"integrity": {}
},
"env": {},
"public_env": [],
"lints": {},
"offline": {
"precache": ["/"],
"fallback": null,
"cache": {},
"sync": false
},
"i18n": {
"default_locale": "en",
"locales": ["en"],
"dir": "src/translations"
}
}WebFluent can be used as a template engine from Rust or Node.js to render .wf templates to HTML or PDF with JSON data.
# Render to HTML
wf render template.wf --data data.json --format html -o output.html
# Render to HTML fragment (no <html> wrapper)
wf render template.wf --data data.json --format html-fragment
# Render to PDF
wf render template.wf --data data.json --format pdf -o report.pdf
# Pipe JSON from stdin
echo '{"name":"Monzer"}' | wf render template.wf --format html
# A directory of templates (components shared), one page of it, in Arabic
wf render templates/ --page Invoice --lang ar --data data.json
# With theme, and a design token over the theme's (repeatable)
wf render template.wf --data data.json --theme Brand --token color-primary=#8B5CF6webfluent = { version = "5", default-features = false } # the engine, without the `wf` command's depsuse serde::Serialize;
use webfluent::{PdfConfig, Template};
#[derive(Serialize)]
struct Hello { name: String }
let tpl = Template::from_str(r#"page Hello(path: "/", title: "Hi {name}") { Heading("Hello, {name}!").h1 }"#)?;
// or: Template::from_file("invoice.wf")?, Template::from_files(&[..])?,
// Template::from_dir("templates")?, Template::from_sources(&[("a.wf", SRC)])?
let data = Hello { name: "World".into() }; // any `Serialize`, or serde_json::Value
let html = tpl.render_html(&data)?; // whole document: <title>, <html lang>, CSS
let frag = tpl.render_html_fragment(&data)?; // markup only
let (css, body) = tpl.render_html_parts(&data)?; // CSS to serve at its own URL
let pdf = tpl.render_pdf(&data)?; // Vec<u8>
let slides = tpl.render_slides(&data)?; // Vec<u8> (PDF deck)
let templates = Template::from_dir("templates")?; // components shared across files
let receipt = templates.page("Receipt")? // one page of several; pages() lists them
.with_theme("Brand")
.with_tokens(&[("color-primary", "#8B5CF6")])
.with_lang("ar")
.with_pdf(PdfConfig { page_size: "Letter".into(), ..PdfConfig::default() })
.render_pdf(&data)?;A Template parses and checks once, is Send + Sync and cheap to clone,
so a server builds it at start-up and renders from any thread. Values from
the data are escaped, and a URL from the data a browser would run
(javascript:, data:) is dropped from href/src. consts and data
files are in scope; components expand in HTML, PDF and slides alike.
// npm install webfluent — a wrapper around `wf render`; needs `wf` installed
const { Template } = require('webfluent');
const tpl = Template.fromString('page Hello(path: "/") { Heading("Hello, {name}!").h1 }');
// or: Template.fromFile('invoice.wf'), Template.fromDir('templates')
const html = tpl.renderHtml({ name: "World" }); // Full HTML string
const frag = tpl.renderHtmlFragment({ name: "World" }); // Fragment string
const pdf = tpl.renderPdf({ name: "World" }); // Buffer
// In a server, the …Async forms do not block the event loop
const page = Template.fromDir('templates').page('Invoice').withLang('en');
const out = await page.renderHtmlAsync(data); // renderPdfAsync → Promise<Buffer>Data is a JSON object. Top-level keys become template variables:
// template.wf
page Invoice(path: "/", title: "Invoice") {
Container {
Heading("Invoice #{number}").h1
Text("Customer: {customer.name}")
for item in items {
Card { Text(item.name).bold Text("${item.price}") }
}
if paid { Badge("PAID").success }
else { Badge("UNPAID").danger }
}
}
{
"number": "INV-001",
"customer": { "name": "Acme Corp" },
"items": [{ "name": "Widget", "price": 9.99 }],
"paid": true
}Supported in templates: all layout, typography, data display components, for loops, if/else, string interpolation, style blocks, flags, themes. A state is its first value and a derived value what that works out to (data handed to the render wins over either) — so a page may work its figures out once and show them anywhere.
Not supported: effect, handlers (on click), navigation, stores, animations, resource — nothing runs after the render.
- Every page needs a path:
page Name(path: "/route") { ... } - State is reactive: any UI referencing a state variable auto-updates
- Flags are written with a dot, after the parentheses:
Button("Label").primary.lg— order doesn't matter. A flag is a Bool prop or an enum case the component declares; a word nothing declares is an error. A prop, state or derived name is an expression, so a component with atextprop writesText(text)and means the prop - One positional argument, then named ones:
Input(bind: myVar, placeholder: "...").text— an enum-valued prop takes a case:Row(gap: .md, align: .center) - Braces for children/body:
Card { Card.Body { Text("content") } } - Parts use dot, after the owner:
Card.Header,Card.Body,Card.Footer,Navbar.Brand,Table.Row,Select.Option - Event handlers:
on click { ... }inside an element's block,on click(e) { ... }when the body reads the event. AButton's block holds what it shows;on clickholds what it does - String interpolation is reactive:
Text("Count: {count}")updates whencountchanges - Imports via
use:use StoreNameto access shared stores - No semicolons needed: statements are newline-separated;
;separates two on one line returnin actions:return exprreturns a value from store actions- Style values are CSS:
padding: 1rem 2rem,background: $surface,width: {pct}%— no quotes;$nameis a design token,{expr}a live value; two on one line are separated by;, never,(E002) @mediaand nested rules inside style blocks:@media (max-width: 768px) { display: none }and&:hover { … }compile to stylesheet rules scoped to the element- Router nests anywhere:
Routercan be insideRow,Container,Stack, or any layout wrapper at any depth; pages own their routes - Browser globals are not prefixed:
localStorage,window,console,JSON,Math,Date,setTimeout,fetch,Promise, etc. compile as-is - Both
!=and!==: both inequality operators are supported (both compile to!==in JS); against anullliteral,==and!=compile to JavaScript's loose== null, so an absent value — a query key the address lacks — counts as null - Quoted map keys:
{ "Content-Type": "application/json" }— use for HTTP headers and hyphenated keys - Reserved words as map keys, not as names:
{ action: "approve", token: tok }— all keywords work as map keys, fields and enum cases; a name the language has is never a program's own (E118):env,params,locale,dir,now,viewport,query,hash,theme,network,update, the built-in functions (t,format,ago,log,navigate,fetch,uuid,every,after, …) and the words a statement is written with (state,action,let,if,show,match,style,on, …) cannot be a state, prop, parameter, loop item, lambda parameter orconst.event,e,value,keyand the browser's globals can public/copies to build root: files inpublic/land at the root of the output directory, not nested- Slides need a Presentation wrapper:
Page X { Presentation { Slide { ... } } }— slide elements outsidePresentationare a compile error - One Slide = one PDF page: slides do not flow across pages; overflow is clipped with a note
- A PDF is the page, printed: the same CSS lays it out — style a document as you would a web page, and use
break-inside: avoid,PageBreakandHeader/Footerfor what paper needs