What an editor written in WebFluent asked of the language: a handler that
reads every value an event sends, gestures on a component's call, a
modal's own heading and close button, source maps, a translated
wf init, and icons; and tests that find a control by the name a reader
hears, each test file with its own declarations.
- A handler names each value its event sends (chapter 9).
A component that declares
event menu(key: String, x: Number, y: Number)andemit menu(k, x, y)is handled withon menu(key, x, y) { … }, each name typed as the event declares it. A handler may name fewer values than the event sends, never more: a name past the last one is the new error T22, which says what the event sends. The same holds for an api's hooks and a connection's handlers. - Gestures on a component's call (chapter 9).
drag:,dragGroup:,dropValue:,dropAxis:,dropInside:,on receive(d),on dragmove(d),on longpressandon swipewritten on a component's call land on its root element, asclass:andon contextmenualready did. A component whose body is not one element has nothing for them to land on, which is the new error C07. Modal.Titleand.closable(chapter 9).Modal.Title { … }(andDialog.Title) is drawn in the heading in place oftitle:'s words, so a heading can set a name apart in code;title:stays the dialog's name..closableputs a close button, named Close, in the heading row. It closes the dialog as Escape does, soon closeruns andvisible:follows. Both work in the live page, the pre-render and the template engine.- Source maps (chapter 39).
build.sourcemapwas in the config and nothing read it. It now writes a version 3 map beside the bundle and each page's chunk (app.js.map,pages/Home.js.map), with the sources' text, so a stack trace reads back to the.wfline each statement came from. The scripts are otherwise byte for byte what they are without one. wf init --locales(chapter 38).wf init my-app --locales en,ar(spa or static, the default first) starts a project translated: the i18n config andmeta.lang, every visible string of the starter throught(), a language switch built from the locales, andsrc/translations/<locale>.jsonfor each. A tag that is not one, a tag named twice and an empty list are refused, and so are locales forpdfandslides.- Three icons (chapter 28), 58 in all:
locatefor a panel that follows the caret,folder-offfor a folder that is gone, andfile-plusfor New file.
Each is for a tool built on the compiler
(chapter 38); paths
are under webfluent::.
VERSION: the compiler's version, aswf --versionprints it.BuildOptions::sourcemapasks for maps whatever the config says, for an editor's preview, andcodegen::sourcemap::SourceMapreads a map back and looks a place up, as a console does with a stack trace.init::create_with(dir, name, &Options { template, locales })iswf initwith locales;init::createstays the form without them.- The language server knows a handler's names: hover gives each its
type and completion offers them, where hovering
einon click(e) { … }used to show nothing. The tree-sitter grammar reads several names.
- A test finds a control by the name a reader hears (chapter 24).
What is
aria-hidden(a menu item's shortcut, a glyph) is left out of a name, soclick "Paste"finds an item that shows Ctrl+V. A submenu's entry is named by its label, not by its items:WF.submenupoints the entry'saria-labelledbyat its label, for a screen reader and forclick "File". - A test file's declarations are its own. Every test page was built
with every test file's declarations, so two files under
tests/that each declaredconst T06broke every test that runs in a browser ("Identifier 'T06' has already been declared"). A test now renders and runs with the project's declarations and its own file's only. - A closable modal's close button writes its state at once. The state
followed a task later, with the
closeevent, so opening the modal again straight after closing it did nothing. - A store's action named like a helper is the store's own.
OpenFiles.preview(path), called from another store's action, compiled to the runtime's file preview and lost its argument. A value with a method of its own named like a list or string helper (last,first,take…) now answers for itself, with every argument. - A block handed to a component reads the names where it was written
in
wf testand the template engine, as the live page and the pre-render already did: a panel'semptytext, handed to a frame with anemptyflag of its own, came out as "false". - A
Menu.Item'sclass:is added beside the item's own, where it wrote overwf-menu__itemand the item lost its padding and its shortcut column. - A15 holds a control's label to its words, not to a glyph beside them
that the author marks
aria-hidden(Aabeside "Match case"). - The components reference has an example of
Modal.Title,.closableandDialog.Title, so every part the registry lists has one a tool can show.
See chapter 46: gestures on a component
that is not one element (C07), a handler's names (T22), a test file's own
declarations, a control's name as a reader hears it, a block's names, a
menu item's class, and a value's own method over a helper. Run wf check.
The wf init starters, rewritten to work on first use, and the compiler
and runtime bugs that clicking through them turned up; what an editor
built on the compiler asks of it — icons, events, a member's ///, a
build of one route, a faster check; and a pre-render, a test and a PDF
that draw what the live page draws.
-
Starters that work on first use (chapter 38).
wf init spahasDayandNightthemes with a switch that callssetTheme, a task list that adds a task on Enter through aFormand asks in aModalbefore deleting one, a Settings page with validated fields, andtests/app.wfandtests/tasks.wf.wf init statichas a validated contact form with a required agreementCheckbox, English and Arabic translations, andtests/site.wf.wf testpasses in a new project. -
A test can place a whole page (chapter 24) by its name, as it places a component (
Tasks, thenclick "Add"), with its state, stores and handlers running. This works for a page with no route parameter whose name no component also has. In a test that acts,press "Enter"in a form's text field submits the form, andclick "Name"also finds, by its name, an element whose role is a link, a tab, a menu item (aMenu.Item), an option, a tree item, a switch, a checkbox or a radio (chapter 24). -
on blurandon focuson a page (chapter 9). A page, a component orapphears the window lose and gain focus for as long as it shows, for example to save when the window is left. Both listen on the window;on keydownandon keyupstill listen on the document. -
on auxclick,on focusinandon focusouton any element (chapter 37).on auxclick(e)hears a click with a button other than the main one (e.button == 1is the middle button, to close a tab);on focusinandon focusouthear focus move onto or off the element or anything inside it. -
23 icons for editors and tools (chapter 28), 55 in all:
folder,folder-plus,file,refresh,alert-circle,clock,dot,diff,book,git-branch,git-pull-request,pin,more-horizontal,window-minimize,window-maximize,outline,help-circle,collapse-all,chevron-up,globe,external-link,panel-downandpanel-up. They go wherever an icon name does and are drawn incurrentColor. -
Menu.Item.danger(chapter 9), ordanger: true, draws an item that cannot be undone, such as Delete or Discard, in the danger colour. -
A
Tooltip'sshortcut:reaches its control (chapter 9):Tooltip("Save", shortcut: "mod+s") { Button("Save") }also gives the buttonaria-keyshortcutsin ARIA's spelling (Control+S, orMeta+Son Apple's systems), unless it has one. -
A member's
///is its documentation (chapter 5). A///comment above astate,persist,derivedoractionin a page, a component or a store stays with it, in.wfand.wfxalike; the parser used to drop it. A///above anything else in a body is still dropped. -
Portal(chapter 32).Portal(into: node) { … }draws its block into a node somebody else's code made: an editor's tooltip, a map's popup, a chart's legend. The library keeps the node's place, its keys and its focus; the page keeps what is in it.into:takes an element, aref:handle or null; named another node the block moves there with its state, and null keeps it aside. Its handlers,emitand lifetimes belong to where it is written.Portalis now a built-in's name, so a project's own component or store of that name is reported. -
Markdown(text).inert(chapter 37), for text somebody else wrote, such as a language server's documentation: entities are decoded and shown as text, a link is its words and a picture is left out.
Each is for a tool built on the compiler
(chapter 38); paths
are under webfluent::.
- A build of one route.
BuildOptions::only_route(Some("/posts/hello")) renders the page answering that route, for a preview that cannot wait for a whole build. With aBuildCache, a page the cache holds for its current inputs, in memory or on disk, is reused (pages_reused); any other page this session has painted is written as it was last painted and counted in the newBuildReport::pages_deferred; a page never painted is rendered. The next build without the option renders what was deferred, and its output equals a build from nothing. Without a cache it builds everything, and what was last painted is kept only in memory, so aBuildCache::on_diskreopened in a new session defers nothing. - A member's documentation in the tree.
StateDecl,DerivedDeclandActionDeclcarrydoc: Option<String>, the///lines joined with\n;Statement::doc()andStatement::set_doc()read and set it, for a completion or a hover to show.print::statementwrites a documented member under its///, so a store whose members are documented prints back as itself.reprintandedit::rewrite_declarationreplace, remove and insert a member together with its///: a member taken out left its doc to document the next one, and one put in at the front went between the first member and its doc.print::written_start(source, &statement)is public: where a statement is written from, its///when it has one. - A code's guide entry.
diagnostics::codes::guide_section(code)returns a diagnostic code's section of the Diagnostics chapter as Markdown, without its heading (whatwf explainprints after the summary, before setting it for a terminal), orNone. The code is matched as given:T06, nott06. - A test's steps outside Chrome.
testing::step_script(what, name, value)returns the JavaScript expression aclick,typeorpressstep evaluates in the page, which answers"ok","none"or"no-option", so an editor's own window can find controls aswf testdoes. - A rendered test in its locale.
testing::render_testtakes the locale as a fourth argument (Tests::locale()is the project's default, oren), andtemplate::render_program_fragment_in(program, data, messages, locale)renders with that locale's plural rules. - The framing decision.
codegen::csp::frames_itself(&program),config::project::frame_options(&config)("DENY"or"SAMEORIGIN") and theBuildConfig::frames_itselfflag, which the build fills in, so a server an editor runs for its preview can send whatwf servesends. - A starter's files.
init::Template::files()lists a starter's own files, as (path, text) pairs with the project's name still{{name}};init::createwrites them, and.gitignore,AGENTS.mdandpublic/beside them. - The registry (chapter 38):
wf registry --jsonlists the new icons,auxclick,focusinandfocusoutamong every element's events, anddangeronMenu.Item. - Paged layout.
paged::style::Length::Mix(points, percent)holds acalc()of both (calc(50% - 12px)isMix(-9.0, 50.0));Stylegainshas_mix()andsettle_mix(w, h, main);codegen::builtin::span_classwrites aColumn's span class. check_project_viaruns its phases on scoped threads, and calls aProject'sfile_ofandsource_ofup front, on the calling thread. The findings and their order are unchanged.
-
A handler reads
keyandvalue(chapter 9).on keydown { if key == "Enter" { … } }passed the check and threwReferenceError: key is not definedon the first key press, which broke Enter in the SPA starter. A handler that readskeynow getsevent?.key, and one that readsvaluegetsevent?.target?.value, on every kind of handler; a state, a loop item or a parameter of that name is left alone. -
A layer that starts open no longer stops the page drawing (chapter 9). A
Modal,Dialog,Popoveror state-openedMenuwhosevisible:wastruefrom the start asked to open before it was in the document, and threw; each now opens once it is there. A layer anchored to an element stayed clamped to the window's edge once the element scrolled away; it now hides with it (data-anchor-hidden), stays open and shows again when the element comes back. The Escape that closes an openModalorDialogno longer reaches the page behind it. -
Menus and tooltips (chapter 9).
Menu(label:)is the list's accessible name; the button's words come fromtrigger:(aDropdown's still fromlabel:), so a menu thatvisible:opens no longer draws a stray button. Each menu and each tooltip drawn gets its own id at run time (WF.uid), where every copy aforor a component drew shared one, soaria-controlsandaria-describedbycould name another copy's. A menu keeps itsclass:and itsstyle { }class as it opens and closes. -
Forms ask their rules (chapter 16). A
Formwith nobind:in a page or component withvalidaterules ranon submitanyway; it now stops on a failing rule and shows the messages. Each page and each copy of a component has its own form handles: a page's carried into the next page compiled, and every copy of a component shared one form. A validatedCheckboxorSwitchshows its message beneath it, announced and tied to the control (aria-describedby,aria-invalid). A field's character count follows a value the program sets, such as a reset. -
Controls given a value work (chapter 16).
Switch(checked: value)withoutbind:drew no input; it now follows the value, and itson changehears the click. ASwitch'son changewas never attached. A boundCheckboxorRadiowith its ownon changelost the binding's write; the binding now writes first and the handler runs after it. -
Text that reads state follows it (chapter 21). Each of these was worked out once, when the page was drawn: the labels of
Checkbox,Radio,Switch,Slider,DatePickerandFileUpload; aTable'scaption:;Tabs.Pagetitles;Tooltiptext; aModal's orDialog'stitle:; aMenu'strigger:and aDropdown'slabel:; anAvatar'ssrc:,alt:andinitials:; aSkeleton'swidth:,height:andsize:; and anIcon's positional name (Icon(if done { "check" } else { "clock" })). SoCheckbox(label: t("…"))now changes language withsetLocale. Fixed text compiles as before. -
Stores (chapter 12). A store outlives the page that reads it first: its derived values and effects belonged to that page's scope and stopped when the reader moved on. A
derivedthat reads one written below it in the same store follows it, where it kept its first value. The built script declares each store after the stores ituses, so a read made while the script starts never meets a store not declared yet. -
The pre-render paints what the live page draws (chapter 26). A
Checkbox,RadioorSwitchis its<label>and<input>, ticked and disabled as its state starts, where only the labels' words showed. AMenuorDropdownis its button and a hidden list, where its entries lay in the page, and a menu that state opens is its list alone. A part written with flags carries their classes (Menu.Item.danger), andColumn(span: 8)itswf-col--8, where every column was painted the same width.disabled,requiredandcontrolsare written only whentrue, here and in the template engine, soButton("Go", disabled: false)is not a dead button..pop()and.shift()are evaluated on a list the build knows. -
A test that looks reads the page as a reader does (chapter 24). The template engine draws a
MenuorDropdownas its button and hidden list,keyLabel("mod+shift+p")as the build writes it (Ctrl+Shift+P), and at()with a count in the default locale's CLDR form.expectdecodes every numeric entity and the common named ones, soexpect "It isn't"matches. In a test that acts,expecttreats any run of white space as one space, as a test that looks already did. -
Column spans leave room for the gap (chapter 37).
span: 8andspan: 4in aRowwith a gap came to 100% plus the gap, and the second always wrapped; each span now gives up its share of the gap, a gap or a span given per breakpoint too. A PDF lays aColumnout at itsspan:, where every column came out equal, and the PDF engine'scalc()is a real parser: no spaces needed, nested parentheses, unary minus, functions inside, and a sum of a percentage and points resolved against the box it lands in (chapter 34). -
The
Navbarfollows the theme (chapter 37). Its background was a fixed translucent white; it is now the theme's surface, and its text the theme's text colour. -
setLocaleis remembered (chapter 21) across page loads, inlocalStorageunderwf:locale, so a static site keeps the reader's language from page to page. A?lang=address still wins, without changing the saved choice. -
A site may frame its own pages (chapter 23). A
Framewhosesrc:is site-relative ("/preview/","/embed/{id}") was refused by the site's own headers. A project that places one is servedX-Frame-Options: SAMEORIGINbywf serve, and its_headerssays the same withframe-ancestors 'self'; every other project keepsDENYand'none'. -
An
Avatar'ssrc:is checked like any othersrc:(chapter 23): a run-time value goes throughWF.safeUrl. -
Long keyed lists no longer stall as they grow (chapter 20). A keyed
forread the place of every item it kept before each change, so a 960-line log appended line by line froze the page for 3.5 s. Only items whose index changes are measured now; a list that only grows at its end measures nothing. -
A
Splittermoves from where it is drawn (chapter 9). A drag or an arrow key starts from the region's drawn size, not the bound value, and a reset writesreset:as given, soreset: 0can mean the region's default share. -
() => …takes no arguments (chapter 14). It was typed as taking one, so it could fail to fit a script parameter whose JSDoc says() => boolean. -
webfluenton Windows (chapter 2). A barewfrunsWF.msc, the Windows Firewall console, which comes first onPATH. The.msi, the release zip andinstall.ps1now also install the binary aswebfluent.exe, sowebfluent buildworks;wf.exedoes too. The.msinow keeps oneUpgradeCode, where each release had its own, so the next one replaces it instead of installing beside it. The Node binding looks forwebfluent.exeandwf.exeby their full names (chapter 35). -
The check does less (chapter 38). The
envlint lexes only a file whose text could nameenv.…, and the output checks work out what a build can evaluate only once they find aChartor aQrCode: on Halyard the check went from 28 ms to 18 ms. Its phases also run side by side, which helps most on a busy machine. -
A build into another folder reuses the cache (chapter 38). The cache's key held the output folder, so two builds of one project into different folders, as an editor's previews are, never shared a page.
-
Markdown links are checked, and backslash escapes work (chapter 27). Every link and picture in
Markdowngoes through the scheme check the rest of a page does, so[x](javascript:…)is drawn as its words. A backslash before punctuation draws that character (\*is*, not emphasis), as CommonMark has it, and the build and the browser draw Markdown alike, line breaks and white space included. -
Escape goes to the newest layer that can take it (chapter 9). A tooltip under the pointer no longer takes Escape from the page (an editor's completion list behind a toolbar button lost it); it hides and lets the key go on. An open
ModalorDialogis the newest layer, so its Escape comes before a popover's beneath it, and a press inside it no longer closes that popover.on closeon a modal is its event, with noV09. -
Every built-in takes what any element takes (chapter 37).
role:,tabindex:,aria-*,data-*andonhandlers reached aPopover,Menu,Tooltip,Modal,VirtualList,Splitter,Tabs,Sidebar,Sliderand the rest only in part, or not at all; each now writes them on its element, in the live page, the pre-render and the template engine alike, asC04says.disabled: truedisables aSlider,DatePickerorFileUpload. Anhtml:written on an element is an attribute, never markup. -
A long list drawn before its box is laid out (chapter 37) paints the right rows in its first frame, the row
reveal:names among them, where it drew about forty and trimmed them a frame later. -
A pre-rendered modal has its title as its heading, as the live page draws it, not as a
title=tooltip; anIconButton's owntitle:wins over its label on every page. -
A test reads a page as a reader does (chapter 24).
expectreads a plain render as a browser lays it out: elements that touch read as touching, where the render's line breaks put a space between them. A test run in a browser loads the project's own stylesheets and servespublic/beside the page, as the built page does. -
A record with all its fields defaulted is
Offers()(chapter 12), wherever an expression goes, a prop's default among them; a left-out field of a default record is filled in, in the pre-render too. -
data-x: trueisdata-x="true"on every page, given to an element or passed through a component's call, and a call'sdata-*,aria-*,id:andclass:reach the component's root in the template engine. -
A local is its own (chapter 12). A
let, a parameter or a loop's name in a store's action that shares a member's name was compiled to the member, so writing it wrote the store; in a page or a component, one that shared a prop's name read the prop.
See chapter 46: Markdown's backslash
escapes and checked links, attributes that built-ins now write, a modal's
Escape and painted title, column spans that leave room for the gap, a Menu's label: that no longer names its button, a
form without bind: that asks its rules, a bound box whose on change
wrote the value itself, and pre-rendered HTML and test snapshots that now
match the live page are what a project may notice. On Windows, type
webfluent and uninstall an older MSI. Run wf check.
An app's frame — layers, shortcuts, gestures, long lists, workers, frames —
and the compiler as a library an editor, a visual builder or an agent
builds on, each piece also a wf command.
- Layers (chapter 9).
Popover, aMenuat its button, at an anchor or at a point (at:, for a context menu), with submenus, andTooltip, drawn in the browser's top layer, so nothing clips or covers them. Each flips and slides to stay on screen; Escape closes the top one, a press outside closes what it is outside of, and focus goes back where it was. - Shortcuts for an app (chapter 9).
modis ⌘ on Apple's systems and Ctrl elsewhere; chords ("mod+k mod+s");keyIs(e, …)andkeyLabel(…)for a table of commands that drives menus and a palette; a page, a component orapphearskeydownand the window's events itself;trapFocuskeeps focus inside a panel. - Gestures (chapter 9).
drag:andon receive(d)with a ghost, edge scroll and Escape;on longpress,on swipe; andSplitterbetween two regions, with its size kept. VirtualList(chapter 37): a long list drawn a window at a time, rows measured or of a fixed height,reveal:to scroll one into view, andV12for a long list that is not windowed.worker(chapter 18): a Web Worker held like a connection, asked with.request(method, params)and heard withon message, restarted when it crashes; a store may hold connections.Frame(chapter 28): another page inside this one, itson messageonly that page's;A17for a frame with no title.persistfollows its key (chapter 12):key: Project.idkeeps one value per project;in: .appnames a storage the project gives itself, which may answer later.- The policy names an app's own schemes and WebAssembly
(chapter 33):
meta.connectandmeta.frametakeipc:orwfpreview:,meta.wasmlets a page compile WebAssembly,meta.style_elementslets a script write<style>. - A build cache: only the pages an edit changed are pre-rendered
again, in a session (
BuildCache::in_memory) or across runs. - An
Image's literalsrc:inpublic/is processed as animagedeclaration is: each width and format, its size, a<picture>. - Previews of what a reader asked for:
data-wf-motion="reduce"on the page's root stills every animation asprefers-reduced-motiondoes, anddata-wf-validation="shown"shows every field's message as a failed submit does. - Plurals by CLDR (chapter 21): the build picks
each count's form by the default locale's rules (CLDR 48, the same
table the browser's
Intl.PluralRulesreads), and exactly none iskey.zeroin any language.
Every one reads the project through a Vfs, so an editor's unsaved
buffers count (chapter 38).
- Printing and reprinting:
webfluent::print,reprintandedit::rewrite_*change an element, a statement or a declaration in the tree and write the smallest edit, every other byte as it was. - The node map both ways: each element's id, place, parent and what it
is inside; the node at a caret; the Layers tree;
wf build --studiostampsdata-wf-nodeand writesnodes.json. - Data an inspector draws from, each a command too:
wf components(every component, built-in and the project's),wf theme(every token, its uses and its contrast),wf motion,wf rules(thevalidaterules, named patterns, every state's rules),wf scope FILE:LINE(what can be read at a place, typed),wf translations(what each locale lacks, what nothing names;--plurals),wf assets,wf config,wf commands; and style values read into their parts and written back. wf fix: one decided fix on every family that has a certain one, and fix-all per family.- An agent's change set and context:
webfluent::agentholds an agent's edits apart from the working tree, builds them before anyone reads them, and gives them back by file or by hunk;wf contextis what an agent is given — the files, the problems, the components, the theme, the config with only its publicenv, and the guide.
- A project's own animation (
Card(animate: .Drop)) did nothing on an element that stood still; it plays. - A refined type's open end (
Number(0..10)took 10) and itsafter:andbefore:were not checked as the reader typed. - Pre-rendered controls dropped
name:andautocomplete:, and anInputwith its ownid:got a second one, so its label named nothing. - A locale missing one plural form showed the default locale's form, two
languages on one page; a site whose default locale is right to left was
served left to right until a script ran;
ar-EGwas not right to left. - A
letin an action, a handler, an effect or a timer was offered by completion everywhere in its page, and listed as a store's member. I01andI02missed at()in a page's header;I03said a reader sees the key, where they see the default locale's message.Unsafe.Htmlwithout asanitize(…)elsewhere put nothing in; a typo was reported once per check that found it; a page's ownstate envread the project'senv.- A comma between two style declarations made the browser drop both; it is an error.
See chapter 46: a comma between style
declarations, a field that accepted its range's open end, an animation
that now plays, a zero message that now shows, and a right-to-left
default locale are what a project may notice. Run wf check.
A state that copies a live value says so, a script's values reach a page, and the guide shows a project as a desktop app.
X06: a state that copies a live value once.state clock = now(every: 1.seconds)holds the moment the page opened and never ticks again, so every «… ago» built from it freezes; the same holds forviewport,query,hash,theme,networkandupdate. The warning names it where it is written, and when nothing assigns, binds or changes the state, the quick fix declares itderived, which follows the live value. A state the program does change —state tab = query.tab ?? "all"that a click then sets — is a starting value on purpose and draws nothing.- Desktop apps, in the guide. Chapter 33
shows a project as a Tauri or an Electron app: the build settings a
desktop needs, setting up each shell, the dev loop, calling the native
side through typed functions in a script under
src/, events, plugins, the security policy and packaging. The chapters after it move up one, to 34–49.
- A script's values reach a page. A value a project script declares at
its top level —
const VERSION = "1", alet, avar,window.ready = …— compiled to a signal read,_VERSION(), wherever a page or a component read it: in text, a splice, aderived, aniforshowcondition, an attribute, a handler. Nothing declares_VERSION, so the page threw as it drew (_VERSION is not defined), andwf checkpassed. A call to a script's function was right; a function handed on as a value was not. Every name a script declares is now its own global, read as itself, unless the page or component declares the same name, whose state still wins.
Nothing to change. A function written only to get a script's value onto a
page can go back to being the value. A build may now warn X06 on a
state whose first value reads a live one; making it derived is the
fix.
A resource asked again for what it shows keeps it on the page.
- A refresh keeps the page. Every time a resource was asked again —
on: .focus,on: .interval(…),reload(),invalidate()— it went back toloading, so amatchover it took the page down to its loading arm and built the ready arm again: a skeleton flash, the scroll lost, the focus and an open sheet gone, every row and every component in it new. Asking again for the same request is now a refresh: the resource staysreadyand keeps its data while it runs, and the answer is drawn in place.loadingis for a first load — nothing has arrived yet — and for a request that changed: an address or an argument that reads state now names something else (another environment, another page number), where what is on screen would be the wrong data. - A refresh that fails keeps the data. The resource stays
readyand setsname.error, which the next answer clears, so the ready arm can say the data is stale (if rows.error != null { Alert(…).warning }). A first load that fails iserror, as it was. - A
matcharm's binding is live.ready(d)anderror(e)were handed their value once, when the arm was drawn. A read of the binding now follows the resource: text, attributes,if,if let, a keyedfor … byand a component's props inside the arm change in place when a refresh lands, and the arm is drawn again only when the arm itself changes — loading, ready, error. An enum's arm follows a new payload of the same case the same way (.failed(r)kept showing the first reason it was drawn with), andelseis one arm, whichever keys it stands for. - An
if letbinding is live.if let u = selected { Sheet(u) }showed the value it was drawn with untilselectedwent to null and back; another value now reaches the branch in place. - A keyed list keeps an item for as long as its key is in it. An item
whose value changed under the same key — every row of a refreshed
response, which is all new objects — was built again, its focus and its
components' state with it. It now keeps its nodes, and its body reads the
item and the index live. A row a refresh did not change is not even
visited: what an answer has in common with the data shown is kept as the
same objects (an item matched by its
id, or by its value, or by its place), so an answer that changed nothing redraws nothing.
name.refreshingandname.pending.refreshingis true while a refresh runs with the data still shown;pendingis true while any request of the resource runs, the first load included. Both areBools, live wherever they are read. Withpaginate:, the next page is a refresh:loadMore()keeps the gathered items on the page, and anotherloadMore()while a page is on its way is ignored.
ready(users)overresource usersreads the data. An arm's binding named like its resource — the guide's own example — compiled to the resource's handle, sousers.lengthwas undefined andfor user in usersdrew nothing.on:andcache:on a resource over an address are policies.fetch("/api/rows", on: .focus)handed the runtime the word"focus", so such a resource never looked again — on focus, on reconnect or on an interval — and itscache:was not read as a policy either. They are read as an endpoint's are.
Nothing to change. What a page did to live through a refresh can go: a
sheet or a form kept outside the match with a snapshot of the data, a
derived copy held so the arm would not draw again. A page that wants a
spinner on every refresh shows one for name.refreshing. A hand-written
WF.match arm is handed its value as before, and an accessor of it as a
second argument; WF.each keeps a keyed item's nodes under a new value
with live: true, which is what a compiled for … by asks for.
What a branch inside a branch drew goes when the outer one does.
- A branch takes everything it holds when it goes. An
if,if let,matchorforat the top of another's body — directly in it, not inside an element — draws into the same parent as the outer one. The outer one remembered the nodes it drew when it drew them and took only those away, so what the inner one drew later stayed on the page:if let m = picked { if act == null { Sheet(m) } }left the sheet showing onceactwent back to null and thenpickeddid, and a tab whosematchover a resource drew again on a refetch left its list beside the next tab's. The same held forif … else if … else(the arm drawn last stayed beside the first), for a keyed list item with anifof its own (moved, and taken away, without what theifdrew since), for a list inside a list item, and for a scoped slot's fill. Each now draws between two markers of its own and takes what lies between them, as it is when it changes. Exit animations, and a pre-rendered page taken over in place, work as they did.
Nothing to change. A wrapper written only to keep a nested branch apart —
a Stack styled display: contents around a tab's match, or a branch
flattened into one if let over a derived value — can go.
Two things that compiled and then did not work in the browser.
- A resource read in a label follows the resource. Whether text or an
attribute is drawn live was decided by the reads a state compiles to, and
a resource's or a connection's values are read through its handle, so a
label that read only one was worked out once, while it was loading:
Button(if plan.data != null { "Request approval" } else { "Deploy" })stayed "Deploy" after the plan arrived, andText("{rows.state}")stayed "loading".name.state,.data,.errorand a connection's.messagesare now live in text, a splice and an attribute alike. - A list's key may read the index.
for e, n in list by nthrew before the list drew (_n is not defined), andwf checkpassed: the key was compiled with the item alone. It is given the item and the index, soby nworks and so doesby "{e.id}-{n}"— though a key of the index alone moves with the position, and keeps nothing across an insert.
Nothing to change. A derived value written only to make a resource's
label live can go back into the label.
A name the language has is not a program's to declare.
- A name the language already has is
E118, where it is declared. A page'sstate envcompiled every read to the build's settings and every write to the state:"{env}"showed[object Object],env == "prod"was never true, andwf checksaid nothing. A loop'sthid the translation function inside the loop the same way. Such a name is now refused as astate,derived,persistorlet, a prop, a route or action parameter, a loop item, a lambda's parameter, a binding ofif let,matchor a slot, and as a top-levelconst,data,image, store orapi. The names areenv,params,localeanddir; the values the browser keeps current (now,viewport,query,hash,theme,network,update); the language's functions (t,format,ago,log,navigate,fetch,uuid,every,after, …); the words a statement is written with (state,action,let,if,show,match,style,on, …); and, at the top level, the runtime'sWF. The message suggests a name of your own where there is a usual one —targetforenv,searchforquery,schemefortheme.event,e,value,keyand the browser's globals stay yours, and so does any name as a field, a map key or an enum case. - A top-level
const envis refused where it is written. It was refused only when the build read its own bundle back, as "a bug in WebFluent" (E901).
A program that declares one of these names stops at E118, on the line
that declares it; rename it and its reads. The usual ones are a loop's or a
lambda's t (for t in todos, items.filter(t => …) — todo, item),
state query (search), persist theme (scheme), a route parameter or
state env (target, stage) and an action named update (edit). The
guide, the examples and the site were renamed the same way.
A service read from its OpenAPI document is typed as well as one written by hand, and a browser run leaves nothing behind.
- A string with cases is an
enum.{"type": "string", "enum": ["prod", "test"]}— what utoipa writes for a Rust enum — was imported as an empty record, soText("{e.env}")showed[object Object]and drewT20. It is nowenum Env { prod, test }, whose cases are the strings the server sends. When a case cannot be written as a name ("operate.flags", a digit first,true), the schema is aStringwherever it is named. - A value that may be null is
T?, however the document says so. OpenAPI 3.1's"type": ["string", "null"]wasAny?; it isString?, and the same holds for numbers, booleans and lists. AoneOf(oranyOf) of{ "type": "null" }and a$refis that type's?, 3.0's"nullable": truecounts beside atype, anallOfor a$ref, and a record or an enum whose own schema allows null isT?wherever it is named. allOfis a record that extends another.[{ "$ref": "…/EnvSummary" }, { "properties": … }]istype ServerDetail = EnvSummary { … }; a second record it names and every part written in place are fields of its own, and what the schema around a part requires, the part's fields answer to.- A string's
formatis the language's type for what the server sends.date-timeis aDateTime,dateaDate,uuidaUuid,emailanEmail,uriaUrl— in a response, and in every record only responses reach. What the program sends (a parameter, a request body, and every record one reaches, but for itsreadOnlyfields) keepsString, so a route parameter or an input's text can still be sent.timestays aString: JSON Schema's carries an offsetTimedoes not read. - Shared parameters and request bodies are read. A parameter or a body
that is a
$refintocomponentswas dropped, so a call passing it wasT10; it is what its$refnames, and a body is required when the document says so. - Anything else a schema names is the type it reads as. A named
oneOf, an alias of another schema, or a plain string or number declares nothing, instead of an empty record whose every field read wasT05.
if letand a case's payload are names in their whole block.if let why = e.reason { Row { Pill(why) } }drewV01("nothing in scope declareswhy"), and so did.failed(why) { … }in amatch; both are now in scope however deep the element that reads them.- A button named by the
Textin its block draws noA05.Button { Icon("link") Text("Continue") }— and aTextin a branch, or in a component the block places — names the button. ATooltip's words, a custom element's tag and a loneBadgestill do not. wf verifyandwf testleave no browser profile behind. Each run that started Chrome left its ~150 MB profile in the temporary directory; where/tmpis memory, a day of test runs filled it and the swap behind it. Chrome is now started with its background downloads off, asked to close before it is stopped, and its profile is removed; each browser has a profile of its own.
A specification's nullable values are now T?, so a read that ignored null
— Text(e.reason.toUpperCase()) — is a T04 where it compiled before;
?., ?? or if let says what to do instead. A response field with a
format is now a DateTime, Url and so on, and a String assigned to one
is T01.
A page may embed a frame from another site — a video player, a map — and the policy that ships beside it says so.
meta.frame. With"build": { "csp": true }the policy isdefault-src 'self', so a frame from anywhere else — a YouTube or Vimeo player, a map — was blocked by the page that held it.meta.framenames the origins a page may embed, asmeta.imgdoes for images, and they joinframe-srcin the meta tag and in_headers:"frame": ["https://www.youtube-nocookie.com"]. An entry with a path or a keyword isE111.
- The landing page has a video: Rethinking the web layout.
- The fonts are served from the site rather than from Google Fonts, and preloaded with the page: a stylesheet on another origin held up every page's first paint by about a second on a phone.
- The "Read the chapter" links on the landing page's cards, and the language switch, carry an accessible name that says where they go.
A script nothing waits for no longer counts against how soon a page is ready.
"load": "after"on ameta.scriptsentry. An analytics tag listed in the HTML is fetched while the page is, even withasync, and a page's speed is measured against everything it asked for before it first painted: Google's 174 KB tag added more than a second to a phone's Largest Contentful Paint on a page that was otherwise ready. A script marked"load": "after"has no tag in the HTML; the runtime asks for it once the page has loaded and drawn its first frame, with itsmeta.integrityhash when it has one. Its origin stays in the policy. A module, or a script whoseglobals.wfcalls, cannot load after the page (E111).
A static page is no longer painted twice.
- A pre-rendered page is taken over in place. When a static build's
script arrived it drew the whole page again and replaced the one already on
screen. The reader saw nothing change, but the browser painted the page a
second time — and once a web font had arrived, the second paint of the
largest paragraph or image was larger than the first, so it became the
page's Largest Contentful Paint. On a phone a page that was readable in
under a second scored an LCP of 3.6 s. The script now draws beside the
painted page and takes it over: an element nothing holds stays exactly as
painted, its attributes brought up to date; one a handler, an effect, a
ref, a widget or a list holds is put where the painted one stood. The first paint stays the largest, and the reader keeps their scroll, focus and selection. A page that cannot be matched is drawn again whole, as before. - Under
wf serve, a takeover is checked. The runtime compares the page it took over with the one it drew, and says so in the console when they differ.
Fixes found getting ready for launch, and the project's new terms.
5.x is the stable line. From the first launch video until four weeks after it, releases are patch-only and batched at most weekly — no syntax changes and no breaking changes.
- WebFluent is MPL-2.0, no longer GPL-3.0. Changes to WebFluent's own files stay open; using it in a closed product is fine. The sites, PDFs and slide decks you build with it are yours, under any license you choose.
- No empty second page. A PDF shorter than one page came out as two:
the baseline's
body { min-height: 100vh }was measured against the paper while the engine laid out inside the margins. The body is the flow that runs across pages, so a minimum height no longer applies to it. wf renderwith no data renders with none. Without--datait read standard input regardless, so it waited on the keyboard, and an empty pipe was "Invalid JSON data". Nothing piped in, or an empty pipe, is now{}.- A responsive layout in a template.
wf renderand theTemplateAPI wrote a responsive prop's rules (Grid(columns: { base: 1, md: 3 })) but not the class that applies them, so the layout stayed at its narrowest. - A page's
.htmladdress. Withbuild.clean_urls: "file", openingcontact.html(orcontact/index.html) by its own address drew the 404 page once the router loaded. It now shows the page, and the address bar reads/contact. - A06 ("Link has no text content") counts text a
for,if,showormatchdraws inside the link, and anaria-label.
- One home: every link points at https://webfluent.monzeromer.dev.
- A gallery of real outputs at /showcase, rebuilt on every release.
examples/videos/, the projects the launch videos are recorded from, built in CI.- A PDF benchmark against headless Chrome,
bench/pdf/RESULTS.md, run withjust bench-pdf. - A test that fails if the previous major version is named as current.
An image from another origin can be allowed by the policy the build ships.
"img": ["https://www.googletagmanager.com"]adds those origins to the policy'simg-src. The policy allowed images only from the site itself anddata:URIs, and no setting could widen it: a CDN's pictures, or the tracking pixels a tag requests (googletagmanager.com/a,/td), were blocked, and Google's Tag Assistant reported each one.- An entry is an origin:
https://images.example.com, orhttps://*.example.comfor its subdomains. One with a path, a keyword or a scheme a picture is not fetched over (wss:) isE111. - The security chapter's tag-manager example adds Google Analytics' image
origins beside its
connectones.
A script nothing waits for no longer holds up the page.
{ "src": "…", "async": true }loads a library with<script async>instead ofdefer. Deferred scripts run in order, and everymeta.scriptsentry is linked ahead of the compiled code — so a large library nothing on the page uses, such as Google's 175 KBgtag.js, held the page's code, and the first full paint, until it had arrived. On a slow phone that was over a second of largest-contentful-paint. Anasyncscript runs whenever it arrives, as the vendors' own snippets load it.- A module, or a script whose
globals.wfcode calls, cannot beasync— the one is imported by the page's loader, the other might arrive after the code that calls it — and isE111. - The security chapter's tag-manager example loads
gtag.jsthis way. - For the Rust API:
ScriptSpecgains a publicis_asyncfield, so code that builds one with a struct literal addsis_async: false.
What a search engine and a link preview read, said in full by the build —
so a site needs no step after wf build to be found, shared and fast.
meta.owner: "person"publishes the site under aPersoninstead of anOrganization: theWebSite'spublisher, and on a personal site everyWebPage'sabout.job_titleis the person'sjobTitle;same_aslists their profiles elsewhere.meta.owner_detailsadds any other schema.org property to that node as written —alternateName,email,worksFor,alumniOf,knowsAbout,logo. What the node is (@type,@id,name,url) and what other settings write (jobTitle,sameAs) are refused (E111). Values are escaped for the<script>block they sit in.
og:image:widthandog:image:heightare read from the image when it is inpublic/.og:image:altandtwitter:image:altfrom a page'simage_alt:ormeta.image_alt.og:localeis written as Open Graph reads it (en→en_US,en-GB→en_GB), with anog:locale:alternatefor each other locale.
build.clean_urls. A static build writes/contactascontact/index.html, and a host such as GitHub Pages answers/contactwith a301."file"also writescontact.html;"directory"names every route/contact/— canonical,og:url, sitemap, breadcrumbs and every link to a page — so the canonical address is never one that redirects.
meta.preloadlists files on the site to fetch with the page: each is a<link rel="preload">in every head, ahead of the stylesheets,asfrom its extension, andcrossoriginfor a font. A font a project.cssfile's@font-facenames no longer waits forstyles.cssto arrive. A path on another origin isE111.
meta.connectnames the origins a page may send requests to besides its own — an API on another domain, an analytics endpoint — as the policy'sconnect-src:https://api.example.com, orhttps://*.google-analytics.comfor a domain's subdomains. Until now the policy left a page only its own origin, with no way to widen it. An entry with a path, or a keyword, isE111.
- Moves to webfluent.monzeromer.dev. It was still built for the
/WebFluentbase path, so its internal links and search index 404'd.
A PDF is the page, printed. The PDF and slide writers are replaced by one paged layout engine: the program is rendered to the HTML and CSS a template renders, and the compiler lays that out on paper itself — the real cascade, block, flex and grid layout, shaped and bidirectional text, pagination, and a PDF with embedded font subsets, an outline and links. No browser, no external tool. A design that works on the web works on paper.
- CSS, as the page has it. The engine's rules for every built-in, the
theme's tokens, the project's
.cssfiles and everystyle { }block, through a cascade with combinators, attribute selectors,:first-child/:nth-child, specificity, inheritance,var(),em,rem,%and::before/::after.@media printapplies. - Layout. Block, flex and grid, absolute positioning,
gap,min-/max-sizes,aspect-ratio, inline-blocks on a line, tables sized by their content, list markers. - Text. Shaped (ligatures, kerning, Arabic joining), bidirectional, broken at Unicode's opportunities, justified. A right-to-left document mirrors its rows, grids, tables and list markers.
- Paint. Gradients with any number of stops, background pictures, per-side borders with radii, shadows, opacity, clipping, rotation; PNG, JPEG, WebP, GIF and SVG (kept vector).
- Pagination. Lines kept two to a side, tables broken between rows with
their header repeated, side-by-side columns broken each on its own,
headings kept with what follows,
break-inside: avoid,PageBreakandbreak-before/break-after.
Header(on:),Footer(on:)withpageandpagesin scope;Background(on:), drawn edge to edge behind each page;Watermark("DRAFT").TableOfContents(levels:, title:)— headings with the page each lands on, dotted leaders, linked.Chart(kind: .bar | .line | .area | .pie | .donut, …)andQrCode(value), vector graphics in a PDF, and SVG on a web page where what they read is known at build time.Document(size:, landscape, margin:, title:, author:, subject:, keywords:, lang:)names the paper and the file's metadata.
Liberation Sans, Serif and Mono are built into wf, so Helvetica,
Arial, sans-serif, Times and monospace resolve with nothing
installed. .ttf/.otf files under fonts/ (or src/fonts/,
public/fonts/, pdf.fonts) and local @font-face rules are used by
their family name, variable fonts at the asked weight. A character no
named face has is drawn from one that has it; the build names any family
taken from the machine, and "system_fonts": false forbids it.
- A
Presentationholdsfor,ifand slide components: a deck writes its openers and footers once and makes a slide per item of its data. - A page's
stateandderivedvalues have their values on paper — and in any template render — so a slide shows a figure worked out from adatafile. Data handed to a render wins over either. :is()and:where()in the paged cascade. The engine's slide heading sizes use:where, and the slide margin is a default, so a deck's own classes always win.
render_pdf_reportandrender_slides_reportreturn aPdfReport: the bytes, the page count, each page's text in reading order and the notes. A template reads fonts and pictures from beside its files.
- The language server knows what the project writes. In a
pdforslidesproject, completion offers only what paper draws — noButton, noon click, noresource— aPresentationis offered its slides and the project's slide components, and hover says when an element is not drawn in this output and why.pageandpagesare names inside aHeader,FooterorBackground. On a web project the paper-only elements are offered last.
- Right to left, a flex row runs from the right. taffy already
mirrors a row under
direction: rtl; the engine reversed it again, so rows in an Arabic PDF came out left to right. - A keyed list no longer draws a row twice when a handler that runs as
a row leaves — an input's
blur— writes the list while it is being drawn. - A
Linkwhosetohas a query (/?show=open) is active only when the address has those values too; a row of filter links no longer marks every one as the current page. - Attributes on a component's call reach its root —
class:,aria-*,data-*,id,role,title,hidden— on the live page and in the static paint; before, they were dropped without a word. A component that declares a prop of that name still receives it. - A
Boolprop given a condition —outlined: theme != "light"— follows it, instead of becoming an attribute. - The static paint draws what the live page does: a component's
derivedthat reads a store, aclass:list with one entry only the browser knows,hidden:. - On a page with a layout, the skip link jumps past the layout's chrome to where the page starts.
wf test:expectreads the text a reader sees, not the markup; a date or money literal draws;data:seeds stores in a test that clicks;wf test tests/x.wfruns that file in its project.- A
validaterule's message may bet("key"), and a rule's argument that reads state is read when it checks. xs.filter(x => x != null)is a list of what is there ([T?]to[T]).wf fmtleaves arguments laid out over lines as written.- The breadcrumb JSON-LD lists only pages a reader can open; a page's
head { }counts as reading its values (no falseU02); a store action called in the store's own initial value is named for what it is. Host(title:)is thetitleattribute.
V11: a component of the project's own named like a built-in (component Chart), which shadows it.E109now refuses only what a page means only in a browser; everything static draws.
examples/documents/ holds three reference documents: a nine-page
Halyard platform report (dark cover, contents, charts, a 64-row table that
repeats its header, incidents, a QR code), an invoice (band, watermark,
repeated header, totals kept whole), and an Arabic résumé laid out right to
left.
examples/decks/ holds three slide decks: WebFluent itself in the docs
site's design system, Halyard's quarterly review drawn from the report's
data (a slide per incident from a for), and an Arabic workshop.
The cookbook is now Tutorials: three projects built step by step, each
complete under examples/tutorials/ and none needing a server — Todos
(easy), Field Notes, a statically built notes site (medium), and
Pocket, a budget tracker with forms, i18n and offline (hard). A test
holds every file the chapter shows to the project it comes from.
The tree-sitter grammars and the VS Code grammar highlight the new elements.
The language server says which version it is.
wf-lsp --version(and-V) prints the version;--helpsays what the binary is and how an editor runs it. An editor runs thewf-lspon yourPATHbefore a downloaded release, andcargo installleaves an old one there whenwfis updated — so an editor could report findings the compiler dropped long ago (an unknownHost) whilewf checkfound nothing. Comparingwf-lsp --versionwithwf --versionnow shows it.
- Troubleshooting names that symptom and its fix, and the CLI guide
says to keep
wf-lspat the version ofwf.
The language server catches up with the language. Everything the compiler knows — every declaration, every built-in type's methods, every keyword in every place — the editor now completes, explains, finds and renames, and it gains formatting, references, signature help, folding, inlay types and semantic highlighting. What it offers is read from tables the compiler publishes, each held by a test to the code that reads it, so the editor cannot fall behind the compiler again.
- Markdown pages are checked as you type, and a finding is checked
again when a file it depends on changes: a translation, a
.env, a project script, a file inpublic/. - Go to definition, find references, highlight and rename work for
every declaration — a type, an enum and its cases, a record's field, a
const, adatafile, an animation, a service's endpoint, a route parameter, a socket, a form or element handle — and inside a string's{…}splices. - Completion knows the fields of a record and the methods of each
built-in type (
due.offersplus,isBefore, …), a service's endpoints, the keywords each place allows (a store's, an action's, a test's steps), type names after:, duration units after a number,validaterules,persistsettings,formatstyles, responsive steps,Hosttags, key names inon key("…"), translation keys int("…"), publicenv.names, and codes in// wf-allow(. - Hover says what every name is:
Hostand the props every element takes, a function the language gives, every keyword, a record's field, an endpoint, a case, animage's size. - New: formatting (
wf fmton the open file), find references, document highlights, signature help inside a call, folding, inlay hints for the type of astateorderivedvalue written without one, and semantic highlighting — a name coloured by what it resolves to.
V10: aHost(tag: …)it does not make used to be adivwithout a word; it is now a warning naming the tags it makes.- An
imageis typed:hero.widhtis aT05, with the fields an image has.
A new project comes with the language reference its coding agent needs.
wf initwritesAGENTS.md: the WebFluent language reference, in the file an AI coding agent reads before it changes a project, headed by the version of thewfthat made it. An assistant asked to add a page writes this WebFluent — its flags, its slots, its checks — instead of guessing at a language it may never have seen. An older project can take the current one from the repository.
AGENTS.mdnameswf init's copy andtests/in the project layout, pins the Rust engine at version 5, says what moving from 4 to 5 asks (wf check, quick fixes,lintsand// wf-allowwhile a project catches up), and that a service call with an empty path parameter is not sent.
- CI's check that every
wf inittemplate builds clean looked for 4.x's warning format, so it could no longer fail on a warning; it runswf check --deny-warningsand requires the newAGENTS.md.
The compiler now refuses most of what used to compile and then fail in the
browser, says so with one kind of finding everywhere — the build, wf check, the editor, a CI annotation — and offers the fix where it knows
it. This is a major release because a project that built under 4.x may
stop on errors it always had: start with wf check, and read
Upgrading.
- Every finding has a code, a place and a span, a hint, the other places
it concerns and, where the fix is known, the edits that make it. The
build renders them as a compiler does — the line, the part underlined, a
link to the code's entry — and sums them up in one line. A build that
stops on its findings exits
1; one that could not run,2. - Every file is read, even when another does not parse: the parser picks up at the next declaration, so one build shows every mistake once.
wf checkruns every check and writes nothing.--format json,sariforgithubonwf buildandwf checkwrites the findings for a tool, code scanning or a pull request;--deny-warningsfails on a warning.wf explain CODEprints a code's entry.- Quick fixes in the editor, and
fixesin the JSON: the nearest name for a misspelt field, member, method, variable, component or flag;?.for a value that may be null; missing props; missingmatcharms; a bare word written as its flag;alt: ""; an_before a name nothing reads. lintsin the config lowers or raises a code or a family, and// wf-allow(CODE)accepts one finding on one line; an allow that covers nothing isU07.
Types (a field, member or method that does not exist; the wrong type; a
value that may be null; a match that misses a case; arithmetic on what is
not a number; a comparison that is always the same; await where it cannot
run), what cannot change (X01), components (C01–C06), routes (R01–
R04, two pages on one path), forms (F01–F05), reactivity (X02,
X04, X05), secrets through joins and browser globals (T12), data and
storage (D01–D05), translations (I01–I04), dates, money and key
combinations, styles (V05–V07), dead code (U06–U10), and a page's
head script from an origin the policy does not name (E902). Every code
is in the Diagnostics chapter with a program
that draws it and the output the compiler prints for it.
- A change inside a state's value (
form.name = v,items[i].done = v,items.sort()) compiles to an updated copy, so what reads it repaints. - A value spliced into an address is encoded where it stands for a value.
- A
matchexpression that covers every case needs noelse. persist … { key: id }keeps one stored value per instance.
wf serveanswers a fetch or file the build did not write with a404and a line in the terminal, not the page shell; its builds hold each response to its declared type ($[1].name is missing), show a route no page answers, and show a list item that threw where it is.wf verifyvisits:paramroutes, fails a page whose router drew nothing, and with--returning-visitorloads every route again as a reader whose storage holds the previous build's persisted values.press "Escape"inwf testcloses an open dialog.
- A fetch the dev server has no file for is a
404(also underwf verifyandwf test). - An
apicall with an empty path parameter is not sent (it would have asked for the collection); it ends.aborted, and a resource staysloading. - One list item that throws no longer blanks its list; an effect that never settles is stopped after 100 runs.
Programs the compiler accepted and then compiled wrong now compile right, or are refused where they are written; and the build reads back the JavaScript it wrote, so a script the browser would refuse never ships.
- A number field binds a number.
Input(bind: qty).numberstored the text the field held, soqty + 1was"31"; it now holds a number, andnullwhen it is emptied. ASelectbinds the option'svalue:as it was written:Select.Option("Two", value: 2)binds2, not"2". - A button in a form submits it only when it says so. Every
ButtonandIconButtonwithouttype: .submit(or.submit) istype="button", on the live page and in the static paint; "Add a row" in a form used to send the form. bind:on a loop item's field writes back.for t in todos by t.id { Input(bind: t.title) }only showed the value; it now writes the item and tells the list, so whatever readstodosfollows. In a loop with noby, the list hears of it when the field is left, so the row is not redrawn under the cursor.- Every control binds a store's member.
Switch,Checkbox,Radio,SliderandDatePickerbound only a page's state;Switch(bind: Prefs.dark)drew no input at all. - A state, derived or prop that holds a function is called.
derived scale = (x) => x * factorthenscale(3)compiled to a call of nothing; assigning a function to a state stored what the function returned.WF.signal(…).set(fn)now storesfn;.update(fn)is the updater it always was. - A store action's
letandif let. Aletreassigned later in the action compiled to aconst; anif letinside an action bound nothing. - A name declared twice is an error where it is written. Two stores,
two constants, a store and a constant, a state and a derived, a store's
state and its action, a prop and a state, a page's parameter and a state:
each compiled to a script the browser refused, or let the second quietly
replace the first. A component may still share its name with a
type. - The build reads back what it wrote. Every compiled script is checked before and after minifying — strings, templates, regular expressions and comments closed, brackets matched, no name declared twice in one block — and a fault stops the build as the compiler's bug, with the line it wrote.
wf test:type "Two" into "Size"on a select threw; it now picks the option by its text, or its value.
- A button inside a form that submitted it without saying so no longer
does: give it
type: .submit. - A program with a name declared twice no longer builds. Most such programs already failed in the browser.
The crate's documentation builds with no warning on docs.rs. Nothing the compiler or the template engine does changed.
- docs.rs documents the crate without warnings. 4.3.1 linked module
docs by full path, which a stable rustdoc needed and docs.rs's nightly
reported as redundant — seventeen warnings. Each of eight modules was
documented twice, on its
pub modinlib.rsand in its own file; the two are one now, and stable and nightly rustdoc both document the crate with none.
scripts/ship.shwaits for a just-pushed commit's CI run to appear, where it took the missing run for a failure and stopped.
The crate's documentation builds clean. Nothing the compiler or the template engine does changed.
- docs.rs builds the crate's documentation without warnings. 4.3.0's
build linked to
runtime::RUNTIME_JS, which no longer exists, and from public docs to a private field; a stable rustdoc found seventeen more — module docs that linked to their own items by a name that did not resolve. Every link now lands.
scripts/check.sh(just check) and CI build the documentation with warnings as errors, as docs.rs reads it, so a broken link fails before a release rather than after.
WebFluent as a template engine, from Rust first and from Node: templates load once and render on every request, spread over files, with your own data types, and untrusted data cannot put script in the page.
- A URL from the data that a browser would run is dropped. The template
engine wrote
Link("Pay", to: url)withurl = "javascript:…"as a live link, and the static paint did the same for anImage,Video,Audio, aSidebar.Itemand anElementattribute. Every URL attribute now goes through the scheme check the browser build applies.
Template::from_files,from_dirandfrom_sources: one template from several files — components, themes, types and constants shared — each finding naming its file.pages()lists the pages andpage(name)picks the one to render.- Data is any
serde::Serializevalue — your own structs — as well as aserde_json::Value. render_htmlwrites a<title>from the page's, filled from the data, andwith_langsets<html lang>(anddir="rtl"for an RTL language).with_pdf(PdfConfig)andwith_slides(SlidesConfig)set page size, margins and fonts;PdfConfig,PdfMarginsandSlidesConfigare exported at the crate root.- A
Templateparses and checks once and shares the result:Cloneis cheap and it isSend + Sync, for a server's state. - The
clifeature (default) holds thewfcommand's own dependencies;default-features = falsebuilds the engine without them — 43 crates instead of 62. cargo run --example invoicerenders a directory of templates to HTML, a fragment and a PDF.
renderHtmlAsync,renderHtmlFragmentAsync,renderPdfAsyncandrenderSlidesAsyncrender without blocking the event loop.Template.fromDir(dir),page(name)andwithLang(lang), as in Rust.- The data goes to
wfover stdin and a PDF comes back on stdout: nothing is written to disk per render. wf rendertakes a directory,--pageand--lang.
- A template's components are drawn in its PDF and slides. The PDF
path left every component call out, so an invoice's line items were
missing; it also left a function or method call unevaluated —
format(price, .currency)drew nothing. constanddatafiles are in scope in a template.Text("{LIMIT}")rendered empty.
The editor grammars catch up with 4.2. Nothing in the compiler changed.
Elementis highlighted as a built-in in Zed and VS Code; 4.2.0 left it out of both grammars.- A state named
showparses in the editor. The grammar readshowat the start of a line as theshow { }statement, soshow = !showin a handler was an error in Zed, though the compiler accepts it. It is a name wherever that statement cannot start, asonandeventare.
scripts/ship.sh(just ship VERSION) releases a version end to end: CI and the checks on the commit, the Zed pin, the bump, the docs site, the preflight, then the commit, the push and the tag after a prompt.
JavaScript is part of a project the way CSS is. A .js file under src/
is a plain browser script, linked on every page as written, and what it
declares is in scope by name — checked, documented and offered by the
editor. external is gone; wf migrate rewrites it.
externalis removed. A remote module is ameta.scriptsentry, a local one a script undersrc/, and a custom elementElement("tag", …).wf migratedoes all three; a call into a remote moduleexternaltyped isAnyafterwards.- Every
.jsundersrc/ships, on every page.wf migratelists the ones a project already has.
- Your own scripts. Top-level
function,class,const,let,varandwindow.x = …are names.wfcalls directly. A call is held to the parameters (T10) and to the JSDoc types (T01);@returnstypes the result; a class is constructed. Collisions with another script, a declaration or a name the language owns are errors. An ES module undersrc/stops the build; an unreadable file is linked with a warning. meta.scripts: libraries by URL, linked before the project's scripts, their origins added to the policy;module: true, as:imports an ES module;globalsputs a library's names in scope asAny.mount:,update:,cleanup:on every element and component call, andwf:renderondocumentonce per drawing.Element("tag-name", …)places any custom element, in the SPA, the static paint and the template engine.class:takes a map and a list, and anifwith noelseisnullwhen it fails.V04flags a borrowedwf-class.- Editor: completion, hover and go-to-definition for script names and
for the classes
.cssfiles define, insideclass:; the server watches.jsand.cssfiles. wf auditlists the scripts and their names;wf typestheir signatures; actingwf testpages link them.
- What an
ifbranch,matcharm, list item or slot fill made is disposed of with its page. A route change used to leave its effects, timers andHostcleanups running. - An
IconButtonwhoseclass:reads state kept its own classes; it used to have them replaced.
scripts/check.sh(just check) runs formatting, clippy and the build one by one, as CI does;--fixformats first,--testruns the tests.just lintandjust fmt-checknow match CI too.- Editors: the VS Code extension (0.4.0) and the Zed extension (3.2.0)
drop
externaland re-read the project when a.jsor.cssfile changes.
Six fixes, all found by moving a nineteen-screen application (https://github.com/monzeromer-lab/Halyard) from WebFluent 2 to 4. Nothing here is breaking.
- A store's
derivedmay call anactiondeclared below it. The checker inferred each derived value as it met it, so an action declared lower in the file read as undeclared (T13) — though a store's members are mutually visible and the runtime binds its actions first. Half a store's derived values failed to compile for no reason. - A
sortcomparator is handed two elements. It was typed likemap's(element, index), so the second parameter read as aNumberand a comparator over records failed withT01. - A layout's arguments read the route's parameters. They were compiled
with no page parameters in scope, so
layout: Shell(crumb: "Build {hash}")on/app/builds/:hashcompiledhashtolocation.hash. A11resolves anifon aBoolprop from the call. A card that titles itselfh2under a page title andh3inside a section was judged on both branches, so the heading-outline lint reported theh3one against a page whose call saysh2.- A select's value takes once its options arrive. The runtime applied
it after the children passed to
el(), but codegen appendsSelect.Optionchildren as later statements, so a select bound to state still showed its first option. - An animation the page never painted settles at its end, not its
start. When the clock ran out,
settled()committed the animation's current state — and a page that painted no frame is still on its first — so a mount animation in a background tab or a prerender left the element atopacity: 0for good.
Grid(columns: { base: 1, md: 2, lg: 3 }) was pre-rendered with the class
its breakpoints live under, and the page script then drew the grid again
without it — with data-cols="[object Object]" in its place — so every
responsive grid fell back to one column the moment the page hydrated. The
same went for direction:, gap: and the other layout props written per
breakpoint. The live page carries the class, and no map is written as an
attribute.
A page that names layout: Shell did not count Shell as something it
draws, so a style { } block written the same way in Shell and in one
page's body was put in that page's own stylesheet — and every other page
framed by Shell drew the element unstyled. A layout is part of its
page now, and such a rule is shared.
4.0 made a page that reads a non-public env name a compile error — and
then wrote the whole env map into app.js as const env = {…} anyway, so
a value like STRIPE_SECRET in webfluent.app.json was downloaded by every
reader. The bundle now carries only the names a page may read: those
beginning PUBLIC_ and those public_env lists. If a site built with 4.0
or 4.0.1 had a secret in env, rotate it.
Name offline in the config and wf build writes a service worker that
stores the site for when the network is gone — the shell, the routes
precache names with their own chunks, and a fallback page for the rest —
and fetches what it did not write by the policies cache names. It is
versioned by a hash of everything the build wrote, so a deploy that changes a
byte is a new version; the page reads update.available and calls
update.apply(), which takes it and reloads once. With sync, a write
made with no network is kept in IndexedDB and sent in order when the
connection returns — by Background Sync where the browser has it, so a
closed tab still sends it — and network.queued says how many wait. Under
wf serve the worker takes itself away.
It is held to a real Chrome in CI (tests/browser/offline.mjs, 24 checks
across a pre-rendered and a single-page build): the site stored, a stored
route served with the server gone and the fallback for one that is not, a
write kept, counted and received exactly once, and an update offered and
taken with one reload.
peer link = rtc(signal: …) opens a WebRTC data channel straight to another
page, with the handle and the match of a socket — connecting, open,
closed(c), error(e), .send, .messages, .last. The language supplies
the channel, not a server: the offer, the answer and the routes each side
can be reached by go 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; ice: names STUN or TURN servers. A page that leaves
closes its peer, and the other side reads closed at once. Held to two
pages in a real Chrome in CI (tests/browser/peer.mjs).
The guide is 48 chapters in six parts — Start, the basics, Building, Shipping, Reference and Help — with a tutorial that ends in a deployed site, chapters for forms, real-time, offline, accessibility, testing, deploying, environments, performance and JavaScript interop, and reference chapters for every command, config key, diagnostic, built-in, design token, runtime function and keyword. It is held to the compiler: every example is compiled, every page example is run and clicked through, every diagnostic example draws its code, every link lands, and a test fails when the language has something the guide does not name.
Button(huge) and on click { cuont = count + 1 } built, and threw a
ReferenceError in the browser the first time the page ran. A name that no
state, const, prop, action, store, api, external or browser global
declares is now T13, reported where it is written, with the spelling to
check. A template's names are its data's, so wf render is not held to it.
A template that named a component nothing declares rendered
<!-- unknown component -->, and a flag a component does not take was
dropped, both with exit status 0 — an invoice with a typo went out that way.
The render now stops on everything a build stops on except T13. It also
takes --token NAME=VALUE for a design token over the theme's, reads an
indented .wfx template, and finds a data file beside the template, as
the guide always said it did.
npm install webfluent — a thin wrapper around wf render, at the
compiler's version, published by the release once the repository has an npm
token. withTokens() reached nothing before and does now; withTheme() is
only sent when set, instead of naming a default theme; renderSlides() is
new; a template that does not compile throws what wf said. Its tests run
in CI against the wf the job built.
title: "{posts.find(p => p.slug == slug)?.title ?? slug} — Blog" was
written into every file as those characters: only a bare parameter name was
filled in. A title may splice any expression over the route's parameters
and the program's constants and data; the build works it out for each file
and the router again on each visit.
env.NAME reads the config's env map, then a .env file beside it, then
the shell — so a pipeline can give staging and production their own values
without editing a file. The shell supplies a name the config or .env
declares, or a public one.
"defaultLocale" in webfluent.app.json was dropped without a word; the
build now warns and names the key it meant (default_locale). wf init -t static itself wrote the misspelling, and so did the documentation site.
- Pagination:
resource list = Api.items(page: page, paginate: .page), thenlist.items,list.loadMore(),list.hasMore. The compiler emitted the option and the checker refused it; the runtime now has it. - A route's parameters in its title:
title: "{slug} — Blog"gives each pre-rendered file, and the live tab, that route's value. ?lang=aropens a page in that locale — the address the build'shreflangalternates always pointed at.- Every standard DOM event in
on …:dblclick,pointerdown,scroll,paste,dropand the rest, without a warning. on open { }on a socket, as a peer and a channel had it.- A class a module exports is constructed when an
externalcalls it, so Chart.js and most modern libraries work throughexternal+Host. wf initwrites a.gitignore;wf generate pagegives the page a description.
meta.favicon was never linked from a pre-rendered page, and the
single-page shell linked it as written, so under a base_path it pointed at
the host's root. It is linked from every page, resolved like any other
asset, and typed so an SVG icon is read as one. meta.touch_icon is new:
the 180×180 PNG a phone puts on its home screen, which iOS reads instead of
an SVG.
A Sidebar is a column of fixed height that scrolls, and its items shrank
to fit instead: with more than a screenful, every link was squeezed to 18px,
too small to tap. They keep their size and the column scrolls. On a phone a
closed sidebar waits off its own edge — in a right-to-left page it slid the
wrong way and covered half the screen.
action move(by: Number) { n = n + by } on a page or a component compiled
by as a signal, _by(), which does not exist: every such action threw the
first time it ran. A store's actions were never affected.
Writing every example out as a real page and running it found programs the checks accepted and the browser rejected:
head { meta(content: post?.title) }read aderivedbefore it existed.Text("{rows.state}")showed a signal's source; a resource'sstate,data,errorand a socket'smessagesare now read as their values.navigatorandlocationcompiled as signals.app { state … }never declared its state.now(every: 1.seconds)called anownothing declared.- An action handed over as a value,
addEventListener("scroll", track), was read as a signal. - A type an
externaldeclares could not annotate a value. Backend.avatar.progresswas refused by the checker.- Two declarations on one line —
theme T { color-primary: #0F766E radius-md: 14px }— were read as one, setting the first token to both. S04never reported two pages on one route;A12counted anh1in each branch of anifas two.- A test that clicks failed in any project with a dark theme.
- The CSP check took a code sample's
onClick=for an attribute. - Warnings printed as
Warning: Error: …. - The Node binding did not look where the installer puts
wf.
meta.site_url written as the site's whole address — https://x.github.io/docs
beside a base_path of /docs — gave every canonical link, link preview and
sitemap entry the base path twice: /docs/docs/. It is read once now, so
either spelling means one address.
Text("{viewport.width}"), a clock on now, network.online — any text
that read one of the browser's values was computed once and never moved.
Only conditions were drawn live: the check that decides whether a text
follows its values knew signals and translations and none of these.
Every runtime module shares one closure, and the network module declared a
lines of its own — the reader for a streamed response. Wherever that
module was built in, the later declaration won: text.lines() handed back
an async generator, so the static paint showed the lines and the live page
replaced them with undefined. The stream reader has its own name now.
A string written inside a splice keeps its own escapes:
"n={("a\"b").length}" refused to build, because the escape was undone
before the splice was read. And the older spelling {a ?? \"x\"}, which
the compiler has always said it accepts, was shown as text rather than
read as a splice.
They painted nothing until the script ran. The static paint now computes
both as the browser's URL does — values decoded, a key replaced where it
stood, the search written back form-encoded.
Behind these, three checks: every method's value at build time is compared with the runtime's, in Node, for 61 samples — anything the build cannot know says why; every runtime function the compiler can call must exist; and no two runtime modules may declare the same name.
Written for a phone first: every rule is the phone's, and md, lg and
xl add to it. The search is in the phone's menu, every link and control a
reader taps is at least 24px (44px in a drawer), a wide table or code block
scrolls inside its own box, and no page scrolls sideways at any width from
320px to 1920px, in English or Arabic — each checked on every route in a
real browser. It has a favicon, a home-screen icon and a link-preview card,
drawn as SVG in site/art/ and exported by scripts/site-art.sh. The search index is its own file, fetched the first time the
search box is used, instead of 20 kB carried by every page.
- Zed: the grammar reads WebFluent 4 —
api, connections,validate, refined types, the language's literals — where it used to mark them as errors, and a call with a bare-name argument (remove(i)) no longer fails to parse. It is held to every example in the guide. A string that begins"/// …"— a sample of code — is text, not a doc comment. - Neovim, Helix and Emacs: the guide's CLI chapter has the configuration for each.
- VS Code 0.3.0: attached to this release as a
.vsix. It is bundled — the 0.2.0 package could not start — findswf-lsp.exeon Windows, and downloads the server from the latest release when it is not onPATH.
The static paint resolved t(…) only when it was the whole of a text.
Text(t("name")) painted; "Hello, {t("name")}", "{n} " + t("items"),
an if on a translation, and a translated prop that a component spliced
into its own text all painted empty, and stayed empty until the page's
script ran — so a reader without JavaScript, and a crawler, saw nothing
there. A static or server-rendered page with more than one locale almost
certainly had some.
The build-time evaluator now answers t("key") and t("key", { … })
wherever they appear, from the default locale, with the same plural
picking, placeholder filling and fallback to the key that Text(t(…))
always had. Nothing to change in your source: rebuild, and the text is in
the HTML.
The Security chapter was missing from the navigation and the two chapters after it were numbered one short; the Styling chapter was behind the guide by five rows of the design-token table; an Arabic reader saw each page's breadcrumb and prev/next titles in English; and the header read 3.0. The site is now held to the guide by two tests, so its pages cannot fall behind the chapters they are generated from again.
Thirteen things the language could not do, or did badly. The runtime is now modular and a static page carries 3.7 kB gzipped of it where it used to carry 18.3 kB.
Read this section before upgrading. wf migrate does the mechanical part
and names the rest with the file and the line — run it first:
wf migrate .env.NAME is replaced at build time and the value lands in the bundle, so
only a public name may be read from a page, a component, a store or an
api block: one beginning PUBLIC_, or one the new public_env list
names. Any other name is a compile error at the line that reads it; the
values still reach wf render, which runs on a server.
wf migrate adds every name your pages already read to public_env, so
the build keeps working, and prints the list — read it, because an API
key in there was a leak before and is a leak now.
Button("x", onclick: expr) is the one place an attribute value is
executed. Write Button("x") { on click { … } }. No automatic rewrite: the
old value was a string of JavaScript.
href:, src:, to:, poster: and navigate() take http, https,
mailto, tel, sms, ftp or a relative URL. A literal is refused where
it is written; a value that only exists at run time is refused at the
moment it is used. javascript:, data:, vbscript:, blob: and
file: have no replacement.
Nothing is constructed at boot. The order stores are declared in no longer
matters — a derived that read a store declared below it used to read
undefined — and a store nothing reads costs nothing. If a derived of
yours had a side effect that ran at boot, it now runs when something reads
it; store X(eager: true) restores the old timing.
localStorage is shared between tabs, so a write in one now arrives in
the others. sync: false keeps a value to its own tab.
WF.store(def) is WF.store(name, define, options), and WF.host(…) —
which was a Url's host — no longer builds a Host; that is WF.attach.
Only a hand-written script is affected.
wf initwrites"csp": true. An existing project opts in.- The guide renumbered: the components reference is chapter 20 and the cookbook 21, with the new security chapter at 19.
- A layout no longer reflows on its own below 768px — that landed in the
responsive work;
.stacksis how aRow,GridorColumnasks to, andwf migrateadds it.
28 feature modules, and a build carries only the ones its program reaches
— read from the bundle it just wrote, so the set cannot fall behind the
compiler. wf build --stats prints what the output weighs and which
modules it holds. build.budget warns over a gzipped size;
build.runtime: "full" ships everything for a program the scan cannot
see.
Date, Time, DateTime, Duration, Money, Url, Email, Color,
Uuid, File, Secret — each a plain JSON value at run time, with
@2026-03-14, 3.days and €12.99 as literals, arithmetic that runs at
build time as well as in the browser, and refinements (Number(1..=30),
String(minLength: 8)) checked at every literal. uuid() is where a
Uuid comes from.
api Backend(base:) { … get users(page: Number) -> [User] } — or
api B from "openapi.json" — compiles to one object of typed, cached,
cancellable endpoints: typed errors, retry with backoff, SWR caching with
ETag revalidation, dedupe, upload progress, pagination. socket,
stream and channel for a connection the page holds open, closed by the
scope that opened it.
validate email { required email async "Taken" { … } }. The control
bound to that state shows the message without being told to, with the
role="alert" / aria-invalid / aria-describedby plumbing. Form(bind:)
gives valid, errors, touched, pending, values, reset(),
submit() and apply(serverErrors).
image hero = "hero.jpg" reads the file at build time and writes it at
every width a page asks for. Image(hero, sizes:, placeholder: .blur) is
a <picture> with width/height from the real file, so nothing shifts.
PDF and slides embed the picture rather than a grey rectangle.
Grid(columns: { base: 1, md: 2, lg: 3 }) compiles to one class and one
media query per step, so the right layout is in the first paint. The
breakpoints are design tokens.
An interrupted animation gives way to the one that replaced it; a spring
is sampled into a linear() easing. New: on: .enterView, shared: for
an element carried across a route change, .expand to the height of the
content, count: on a number, and sequence { step(after: "120ms") { … } }.
Nothing the DOM must end up as depends on an animation finishing — a
page that is not painting still opens the box, arrives at the number and
completes the route change.
store Cart(scope: .app | .session | .route), and a persist policy:
in:, version:, sync: and migrate 1 -> 2 { … }, so a value an older
build wrote is carried forward rather than discarded. wf serve gets a
stores panel: the tree, every action with the state on each side of
it, and a click to put one back.
Beside the breaking changes above: Unsafe.Html(markup) is the one door
for markup, sanitize(html) is an allow-list that runs at build time and
in the browser from the same list, rel="noopener noreferrer" comes with
any target:, meta.integrity emits SRI, and the build reads its own
output back and holds it to the policy it ships. wf audit prints every
Unsafe.*, every other origin, everything kept on the reader's machine,
every env name, the policy and the dependency list. A new chapter 19
covers the threat model and a deployment checklist.
wf test ran a body through the template engine and read the result,
which meant no test in the language could exercise a button: a click runs
a handler, which runs an action, which changes a store, which repaints,
and a renderer does none of that.
A test with an interaction in it is now compiled into a real page, served, and run in a headless browser — same compiler, same runtime, same output a reader gets.
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"
}
click "Save", type "Ada" into "Name", press "Escape" in "Search" and
expect run in the order written — which is the whole meaning, since what
a click did is only visible in the expect that follows it. Everything is
found the way a reader finds it: a button's text, a control's label, its
placeholder, its aria-label — the same names the accessibility checks
hold a page to. A handler that throws fails the test even where the
expects would have passed.
A test that only looks is still rendered and snapshotted, and needs no browser.
wf build && wf verifyIt starts a headless Chrome, serves the output and opens every route the
project has, failing 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 reports the first contentful paint, the node count and the bytes
each route fetched; --budget MS fails a route that paints too slowly
and --json is the same report for a pipeline. It also says which
built-ins no page drew — the classes the pages carried are the list of
what actually ran.
The DevTools Protocol client is written here, like the PDF writer and the gzip encoder: it speaks to a process the build started, on the loopback address, so it needs no TLS, no compression and no fragmentation, and a dependency would be larger than the problem.
external Chart from "…" { fn … type … } is a typed import, checked at
every call site, emitted as a real ESM module with its origin in the
policy. Host(mount:, update:, cleanup:) is a node with a lifetime — the
ref + effect + cleanup written by hand, with the cleanup impossible
to forget. external element places somebody else's custom element, and
output_type: "elements" publishes yours: PriceTag becomes
<price-tag>, usable from React, Vue, Svelte, Rails, WordPress or a plain
page.
Raw strings #"…"#, block strings """…""" with the source's indentation
removed, and formatted splices {total:.currency}.
A store's action and a page's handler are compiled by two different
emitters, and each kept its own table of what a WebFluent method becomes in
JavaScript. The tables drifted. Inside a store action, items.remove(i)
compiled to store.items.remove(i), word.toUpper() to
store.word.toUpper(), items.contains(x) to store.items.contains(x),
and every one of the 39 scalar methods — due.plus(days: 5),
total.times(2), site.host() — to a method call on the value itself. A
Date is a string at run time and Money a map, so each of these threw
the first time its action ran. The page painted correctly until then, which
is why nothing caught them: the source parses, type-checks and lints clean,
and only the generated JavaScript is wrong.
There is one table now, read by both emitters, so a method added to one cannot go missing from the other.
items = items.remove(i) on a page state compiled to a set inside a
set. The inner one removed the item; the outer one then set the list to
what set hands back, which is nothing. The item went, and the list went
with it. The removal now evaluates to the new list, so both the statement
items.remove(i) and the assignment are correct.
toUpper, toLower and contains were missing from the build-time
evaluator, which knew only toUpperCase, toLowerCase and includes. A
page that used them painted an empty element, and it stayed empty for a
reader without JavaScript and for a crawler.
- A
derivedthat read a store declared later in the file readundefined. - A page
statenamedkey,value,event,eorparamscompiled to a bare identifier: it rendered as nothing and never updated. Input(bind: Store.member)compiled to a control with no binding at all — a silent no-op.- The static paint wrote
style="display:none"for a hiddenshow, which a strictstyle-srcforbids. - The documentation site was painting
�and�in inline code. frame-ancestorswas shipped in a<meta>tag, where a browser ignores it.- An
apiblock'son request/on response/on errorhooks compiled to empty functions: everything written in one was generated into a string and thrown away. - A handler's parameter in an
apihook and in asocket,streamorchannel(on message(m)) compiled to_m()— a signal that was never made, so the handler threw on the first message. matchover asocket,streamorchannelcalled the handle as a function, which threw and took the whole page's first paint with it.if let x = …inside an action, a handler or aneffectread the bound name as a signal, so the branch that had just checked it was not null used a name that did not exist.T06only held a store to its members where the body wroteuse, and never inside another store or anapiblock — soStore.mispelt()in any of those built without a word.U04did not see the reads in anapiblock'sheaders { }or hooks, and reported the store members only those reach as unread.form.pending,form.errors,form.touchedandform.apply(…)wereT05: the checker's idea of a form handle was missing half of it.
Every page of every example — the fixtures, both wf init templates, the
documentation site and a real 130-file project — is loaded in a headless
Chrome on every change: 124 pages, zero errors, and every one of the
52 built-ins that can render in a browser is drawn by at least one of
them. just browser runs the sweep; it reports nothing to do where there
is no browser.
It is also how a real bug in the dashboard fixture's own store was
found the moment it had a test that clicked: resolve changed a field of
an item in place, so the list stayed the same list and nothing repainted.
That sweep is how three of the fixes above were found: a route change that never completed, a box that never opened and a number that never moved, each of them waiting on an animation frame that a page which is not painting never gets.
New codes: P01/P02 (a persisted value's version chain), P03 (a
route-scoped store that persists), V03 (an Unsafe.Html, and whether it
is sanitised), T12 (a Secret where it would escape).
Todos.remove(id) called the action the store declares. The JavaScript
back end matched the method table on the name alone, without looking at
what it was called on, so it compiled to Todos.splice(id, 1) — a store
is an object of state and actions, never a list, so the handler threw
Todos.splice is not a function on the first click. The page painted
perfectly until then, which is why nothing caught it.
A store's own members now come first, so an action may be named after a
list method. The shadowing was not limited to remove: every name in the
table — push, filter, map, take, contains, first, last — hid
an action of the same name. Todos.items.remove(0) still reaches the
list, since that names the list rather than the store.
remove on a list held by state, persist or a store's member spliced
it in place, so the signal kept the array it already had: nothing reading
it repainted and a persisted list was never written. It now goes through
the signal, as push does, via the new WF.removeAt(list, index).
A Sidebar.Item(to:) reached through a loop pre-rendered as a bare <li>
with the destination dropped: the components reference on the site, whose
index is for c in shown { if … { Sidebar.Item(to: …) } }, shipped a
navigation panel with no links in it. The static back end read to: as a
literal only, so a destination that needs the loop's binding — "/docs/{p}"
— resolved to nothing. It now reads it through the build-time scope.
The published site was also rebuilt: its bundle still carried the
<li to="…"> from before the 3.2.0 fix, so clicking a component in the
reference sidebar did nothing at all, silently.
install.sh and install.ps1 asked for Rust target triples —
wf-$VERSION-x86_64-unknown-linux-gnu.tar.gz,
wf-$VERSION-x86_64-pc-windows-msvc.zip — but the release publishes
wf-<version>-<arch>-<os>: x86_64-linux, x86_64-macos,
aarch64-macos, x86_64-windows. Every run downloaded a 404 page and
handed it to tar. install.sh also rejected macOS outright, despite
the release carrying both Mac builds, and appended its PATH line again
on every run.
Both now resolve the right asset, cover Apple Silicon and Intel Macs,
fail with a readable message instead of a corrupt unpack (curl -f,
unpacked through a temporary directory), and add the PATH line once.
Linux arm64, which has no prebuilt binary, is told to use Cargo.
cargo install webfluent is documented in the guide and the README,
which had been pointing at a 0.2.0-alpha Debian package.
tests/cookbook.rs extracts the three applications from
md-docs/20-cookbook.md — the guide itself, not a copy — builds them and
runs them against the fake DOM in tests/js/cookbook.test.mjs, clicking
through add, toggle, remove and clear. tests/docs_parse.rs now runs the
JavaScript back end over every guide block and rejects a bundle that calls
a store member the store does not declare, which parsing and type-checking
could not see. A navigation item inside a for or an if is held to being
a link in every backend.
A twenty-chapter developer guide, from wf init to three complete
applications: getting started, the language basics, pages and routing,
elements, state, events and forms, control flow, components, stores,
types, expressions, styling, motion, data, internationalisation, content,
outputs, tooling, a components reference generated from the registry
(scripts/components-reference.py), and a cookbook. Every code block is
parsed, semantically checked, type-checked and held to the linters by
tests/docs_parse.rs, and the cookbook's applications build and were
walked through in a browser. The site's documentation will be rewritten
from it.
Code(…, language: "wf") — or json, bash, css — and a Markdown
fence in one of those languages are coloured, at build time by
codegen::highlight and in the browser by WF.highlight, the same
tokens either way: keywords, capitalised names, strings, numbers and
colours, name: props, $tokens, comments and a shell prompt, each a
wf-tok-* span taking its colour from the theme's syntax-* tokens. The
baseline theme now carries those tokens and term-bg/term-ink/term-dim,
and a code block is painted on term-bg — a console stays dark in both
themes — with white-space: pre and a horizontal scroll. sun and moon
join the icons; an icon: that reads state follows it.
site/ is written from md-docs/ by scripts/site-from-guide.py: one page
per chapter under site/src/pages/guide/ (routes /docs/guide/<chapter>),
framed by DocsShell — the header with search over every chapter and
section, the chapter nav with its numbers, the article at a reading
measure, the headings rail and the links to the chapters either side —
with prose as Markdown (inline code, links and lists render), fences as
CodeBlock (coloured, with a Copy button), shell blocks as Terminal and
pipe tables as Table. The components reference is data: wf registry --json becomes site/src/registry.json (scripts/site-data.py), an
index page and one page per built-in at /docs/reference/<name> with its
signature, props, flags and cases, events and parts under tabs, a filter
over the index, and the entries nearby. The landing page, the chapter
page, the reference and the phone layout follow the design canvas
"WebFluent — site and documentation" to the pixel, in the MO design
system: graphite for structure, blue for intent, cyan for data, Inter and
JetBrains Mono, a console that stays dark in both themes, a theme toggle,
light and dark, and Arabic chrome with the chapters kept left-to-right.
- A string splice takes any expression:
"{format(total, .currency)}","{[1, 2].length}","{a ?? "none"}"and"{if ok { "yes" } else { "no" }}"— a splice may hold a string of its own, as long as the group closes on the line. A brace group that is not an expression ("{key: value}"in prose) stays text; one with a slip in it is an error that names the splice. () => expr: a lambda of no parameters, forsetTimeout(() => go(), 300).- A record construction carries the fields it left out — each field's
default,
nullfor an optional — soTodo(id: "a", title: "b")holdsdone: falsewherever it goes. await fetch(url, options)in an action or a handler is the parsed body, and a failed response throws (WF.request): the same request aresourcemakes. The browser's own iswindow.fetch.items.push(x)on astate, apersistor a store's member sets a new list, so what reads it repaints and a persisted one is written.Modal(visible:)/Dialog(visible:)take any expression that reads state — a store's member, a condition — and write a state or a store member back when the browser closes the dialog itself.- A page's
guard:andredirect:work: a route whose guard is false sends the reader to its redirect (default/) instead of painting. - A page's own
metainhead { }replaces the standard tag of the samepropertyorname, so a post page has oneog:title, its own. - The registry lists the icons the runtime draws (
registry::ICONS, pinned to the runtime's table); anIcon("…")oricon:that names another is a warning, and the language server completes from the same list.
- The static paint and the template engine bind an
if letname: the branch used to read it as unknown and paint an empty heading. - A store action's
try/catch,forandelse ifused to fall through to the page emitter and read_x()signals a store does not have. Form(bind: form)declared the handle twice — as a form and as a ref — which the browser refused.- A
Checkbox,RadioorSwitchdrops nothing now:aria-*,data-*,name,idanddisabledreach the<input>, and radios bound to one state share anameso the arrow keys move between them. navigate("/?filter=done")and aLink(to:)with a query string route by the path and keep the query readable throughquery; a link to the route with a query is the current one.- The single-page shell addresses its assets from the site root, so a deep
link served by the SPA fallback (
/deploys/8f2c) loadsapp.jsrather than the shell again. WF._basePathis a getter again; a static build's links no longer start withundefined.- A bare
slotfollowed byon key(…)on the next line names no sloton. - The vocabulary lint knows a page's route parameters, and its
constanddatanames. - A page framed by a
layout:is judged with the layout's outline: theh1a layout draws is the page's (A12), and a heading level skipped across the boundary is seen (A11). Grid(columns:)is adata-colsattribute the stylesheet reads — a build withbuild.cspon used to have its grids blocked as inline styles — and acolumns:that reads state follows it.wf serveserves a site built withbuild.base_pathunder that path, as its host will, and sends/there.- A Markdown link or image to a site-relative path takes the base path, at build time and in the browser.
- A
Breadcrumb.Itemwithouttopaints as a<span>, not an<li>outside a list. - The skip link hides by a transform, not an off-canvas offset that a right-to-left page could scroll to.
- A
:parampage's static files carry their own route as the canonical link and sharing URL, not the pattern with:slugin it. - An
IconButtonwith aclass:keeps the engine's classes; itsicon:,label:and title follow state. - A
Sidebar.ItemorBreadcrumb.Itemwith ato:reached through aforor anifis a link in the browser too; it used to be a bare<li to="…">that went nowhere.
forin actions, handlers and effects — with an index, over a range (1..10,1..=10), or a keyed list;try { } catch e { }; destructuringlet { a, b } = mandlet [x, y] = l.- Optional chaining
a?.b,a?.m(),a?.[i]: null when the base is, the rest of the chain with it, typed asT?. if let x = v { a } else { b }as a value.- Regular expressions
/pattern/flags, withtest,exec, and a string'smatch,replace,replaceAll,split,search; a/after an operand still divides. - Spread
[...a, b],{ ...m, k: v }. const NAME = valueat the top level, andenv.Xfrom the config's"env"map, fixed at build time.- Record composition
type Admin = User { role: String }; enums whose cases carry a payload,enum Status { idle, failed(reason: String) }, written.failed("x")and matched.failed(r) { … }(a match expression binds one name);["failed", "x"]at run time, a bare case its name. - Structural shapes: a map literal is typed by the fields it was written
with, so
form.namis aT05and a literal given where a record is wanted is checked field by field; a field only some items of a list have isT?. - Helpers
sortBy,groupBy,unique,take,first,last,flatMap,sumon lists andcapitalize,truncateon strings — in the browser and at build time. format(value, .style, option)—.number,.integer,.decimal,.currency,.percent,.compact,.date,.time,.datetime,.relative, or a date pattern such as"yyyy-MM-dd HH:mm"— andago(date), in the i18n locale when the project has one and the document's language otherwise; the static paint and the template engine format from a table of the common locales and currencies.- The type checker places an error at the expression it names when the source is at hand; hover shows a record's inherited fields and an enum's payloads; the tree-sitter grammar and the Zed extension know every new form.
wf fmtformats a project's sources: indentation by block depth, spacing around braces, trailing blanks and blank-line runs — held to the file's tokens, comments kept;wf fmt --checkfor CI.--to wfx|wfstill changes the layout.wf serverebuilds on save, reloads the page, and shows a failed build's diagnostics over the page until the next build passes.- Plural forms:
t("items", { count: n })picksitems.one,items.otherand the locale's other categories from the translation file. - A route change with
Router(transition:)plays through the View Transitions API where the browser has it, the class-based animation standing in elsewhere. wf docs: a self-contained component gallery of every built-in and of the project's own declarations.Markdown(text): a small Markdown rendered the same way at build time and by the runtime, the text escaped first; a.mdfile undersrc/is a page, its front matter the page's attributes.data posts = "posts.json": a JSON file as a constant, read at build time;paths:on a:parampage renders the static page once per value and lists each in the sitemap.wf test:test "name"(data: { … }) { elements expect "text" expect not "text" }declarations undertests/, rendered through the template engine with the project's declarations at hand, held to their expectations and to snapshots (--updateaccepts a new render).- The language server renames — a component, a store, a store member, a
local — across the project, inside interpolations too, and offers
Extract componenton a selection of elements, with what they read as typed props and the stores they useused again. - Unused warnings
U01–U05: state, derived values, actions, components and store members the program declares and never reads, in the build's output and the editor.
- Scoped slots:
slot row(item: Todo, index: Number), used asrow(item: it, index: i), filled asrow(t, i) { … }; the fill is drawn again when a value it is handed changes. - Parts of a component:
part Header(_ text: String) { … }insidecomponent Panel, called asPanel.Header("…"). - A component's enum props are written to its root element as
data-<prop>="<case>". ref: nameon an element: a handle read as the element itself (name.focus()).every(ms) { },after(ms) { }andeffect { … cleanup { … } }; what a page, a branch, a list item, a match arm or a slot creates is disposed of when it leaves.persist name = valuein a page, a component or a store: kept in the browser's storage across visits.viewport,query,hashandtheme: the browser as values, kept current; a declared name shadows them.on key("ctrl+k") { }on an element or at the top of a page.animation Name { from { } to { } }keyframes, played withanimate: .Nameor written into a style; container queries pass through.- Dark mode:
"theme": { "dark": "Night" }applies a second theme underprefers-color-scheme: darkand onsetTheme("dark"), kept across visits. Form(bind: form):form.valid,form.values,form.reset(); an async action'sname.pending.- A page's
head { meta(…) link(…) script(…) }, painted at build time and kept current on the live page. - The template engine binds a component's positional props and defaults, and every component is registered before a page renders; the static paint substitutes props through every expression and into branches, loops and matches.
The one grammar, written by indentation. A .wfx file opens a block with
a line indented deeper than the one before it and closes it with a dedent;
inside parentheses, brackets and any braces you write, the layout is free,
so a multi-line argument list, a map literal and a one-line
{ on click { save() } } read as they always did. Nothing else changes:
the parser, the checks, the output, the language server, the template
engine and wf generate read and write both layouts, and a project may
hold both.
wf fmt --to wfx rewrites a project's .wf files as .wfx, and --to wf
the other way — a change of layout and nothing else, held to a
byte-identical build across every fixture, the documentation site, every
wf init template and Halyard, and refused for a file whose indentation
does not already follow its braces. wf migrate --wfx migrates a
WebFluent 2 project straight into the indented layout.
The Zed extension gains a second grammar and language for .wfx,
generated from the same grammar.js, with its own indent queries; the
VS Code extension opens .wfx files. Specified in spec/SYNTAX_V2.md
§8a.
The registry as JSON — every built-in with its positional prop, props and
their cases, flags, events, slots, parts and attribute families, plus the
universal props — and a project's own declarations the same way: enums,
records, components with their props, events and slots, stores with their
members, pages with their routes. The structured edit operations speak the
grammar by name: SetProp/RemoveProp, SetFlag/RemoveFlag,
SetHandler/RemoveHandler, SetSlot/RemoveSlot, beside the older
AddModifier/SetArg that stay as aliases; two insertions at one place
land in the order they were asked for; a .wfx file is edited through its
braced spelling (apply_edits_to). WebFluent Studio reads both.
The order files are read in decides the numbering of a page's elements,
and it followed the file system's own order — so the same project could
build differently on another disk. It is alphabetical now, App.wf first.
The grammar release. WebFluent's surface syntax grew by accretion until it
broke its own rules — a misspelled modifier did nothing, one { } meant
five things, a style value was a string in which a bare word was a token
or a variable depending on a lookup, routes were declared twice, a project's
own components could not declare a variant, an event or a slot. WebFluent 3
is one language with one lexeme per meaning, a registry that describes every
built-in once, a type checker, motion on any element, and wf migrate,
which rewrites a project mechanically: every fixture, the documentation
site, every wf init template and the Halyard demo (129 files) migrate to a
byte-identical build.
This release is a hard cut. wf build and the language server read only
the new grammar; a file in the old one is refused with a pointer to
wf migrate. See Migrating at the end.
enum Tone { neutral, info, danger }
type Todo { id: String, title: String, done: Bool = false, tone: Tone = .neutral }
/// A todo, as one row.
component TodoRow(_ label: String, todo: Todo, compact: Bool = false) {
event toggle(id: String)
slot trailing
Row(align: .center, gap: .sm) {
style {
padding: 6px 0
background: $surface
--accent: {todo.color}
&:hover { background: $surface-hover }
}
transition { background: 150ms ease-out }
on click { emit toggle(todo.id) }
Text(label).bold
trailing
}
}
page Home(path: "/", title: "Todos", layout: AppShell(crumb: "Todos")) {
use Todos
resource list = fetch("/api/todos")
match list {
loading { Spinner }
error(e) { Alert(e.message).danger }
ready(v) {
for t in v by t.id {
TodoRow(t.title, todo: t, exit: .fadeOut).fadeIn {
on toggle(id) { Todos.toggle(id) }
trailing { Badge("new").info }
}
}
}
}
Button("Add").primary.lg { on click { Todos.add(draft) } }
}
app { Navbar(brand: "Todos") { Navbar.Links { Link("Home", to: "/") } } Router(transition: .fade) }
- Lowercase declarations:
page,component,store,theme,app, and the newtypeandenum. There are no keyword tokens:type:andon = !onare what they look like. - Flags:
.primary.lgafter the parentheses — aBoolprop or a case exactly one enum prop has; a case two props share is named,tone: .info. A word nothing declares is an error that lists what the component takes.Input(…).text,Heading("x").h1,Spacer.sm. - One positional argument, the component's declared positional prop,
then named ones. An enum prop takes a case:
Row(gap: .md). - Parts under their owner:
Table.Head/Body/Row/Cell,Select.Option("Label", value: "v"),Tabs.Page,Card.Header. - One block, in order:
style,transition,onhandlers, slot fills, children.on click { }andon click(e) { }; aButton's block holds what it shows,on clickwhat it does. - Style values are CSS:
padding: 1rem 2rem,background: $surface,width: {pct}%,&:hover { },@media (…) { },--name: value. A$tokenis validated against the theme;$mdonpaddingis$spacing-md. - Components declare typed props (
_ name: String,active: Bool = false),events fired withemit, andslots filled by name. - Pages own their routes:
Routertakes no children; routes match by specificity.layout: Shell(args)wraps a page in a component with a default slot. resourceandmatchreplace thefetchblock;for … by keykeys a list;if let x = expr { }unwraps;??,T?,[T],null,awaitin actions and handlers,ifandmatchas values,.case,$tokenin expressions.Store.load(id)at the top of a page is set-up code;let xis a local in a handler or action.
The full grammar, with why it changed and the migration table, is
spec/SYNTAX_V2.md.
wf migrate [path] [--check] [--stdout] rewrites every .wf under src/
in place — spelling only: the migrated project builds to what it built
before. What needs a decision is reported as a note. --check reports
without writing; --stdout prints one file's result.
src/registry describes every built-in once — its positional prop, props
with their types and enum cases, flags, events, slots, parts, attribute
families — and the compiler's checks, the linters, the language server's
hover and completion, the tree-sitter vocabulary check and the tests all
read it. The secondary tone reaches Badge, Text and Icon;
Spinner has sizes and no tone.
Gradual and structural: everything it cannot resolve is Any, which agrees
with everything, so every existing project checks without an annotation,
and each annotation — state x: [Todo], a prop's type, a type's fields,
resource x: [Deploy] — narrows what it can say. It reports, as errors with
hints: a value of the wrong type (T01), a case an enum lacks (T02), a
value that may be null read without if let, ?? or a != null check
(T04), a field or method a record lacks (T05), a member a store lacks
(T06), a list or record as a condition (T07), a for over a non-list
(T08), an emit that does not match its event (T09), a call with the
wrong arguments (T10), a match with nothing to match (T11), and a
bind: on a control of the wrong kind. Specified in spec/TYPES.md.
The universal props — an animation as a flag (.fadeIn), exit:,
.fast/.slow, duration:, delay:, easing:, stagger: — on any
element, built-in or the project's own. On a branch's root they are the
branch's; anywhere else the element plays its own exit before it is
removed, wherever it was. A keyed list slides a moved item from where it
was. Router(transition: .fade | .slide, duration:) plays one page out and
the next in. transition { } blocks take $token timings. prefers-reduced-motion
is honoured by the runtime as well as the stylesheet: no class, no wait.
Errors now: a parse error, a reference to nothing, a flag, case, part,
prop, event, slot or layout nothing declares, an ambiguous flag, a
positional argument out of place, a match arm the value cannot take, a
loose statement in a render block, a clause out of order, the type
checker's findings, and a file in the old grammar. V01 names the flag you
meant (did you mean `.center`?); a named argument a built-in does not
declare is a warning and is written as an attribute.
- Language server: hover and completion are driven by the registry and
the type checker — flags after
., cases aftertone: .,$tokensin a style value, events afteron, a component's events afteremit, the arms amatchstill lacks, a component's slots,layout:candidates, props not yet written, a record's fields and a list's methods after a typed value; hover shows a flag as the prop it sets and a name's inferred type; go to definition followslayout:. - Editors: the tree-sitter grammar is rewritten for the new grammar (a
small external scanner reads style values), with the Zed queries and
snippets and the VS Code TextMate grammar; every
.wfin the repository and in Halyard parses without an error node. - Structured edits (
EditOp) write the grammar of the file they edit:AddModifierappends a flag,SetStylewrites a raw value. - The documentation's
wfblocks are parsed by the test suite (tests/docs_parse.rs), so an example cannot go stale.
- The old grammar is refused.
wf build,wf serve, the template engine, the language server and the Node binding read WebFluent 3 only. Runwf migrate. - A
Button's block of statements is no longer its click handler; writeon click { }. The migrator does. - A word nothing declares is an error, where it used to be the
V01warning;Alert(elevated)and itsV02are gone with the modifier words. - The runtime's public surface is renamed for what each thing does:
h→el,condRender→when,listRender→each,showRender→show,wfFetch→fetchbeside the newresource,createStore→store,createRouter→router,definePage→page,showToast→toast,bindDialog→dialog,bindPopup→popup,tablist→tabs,offCanvas→drawer; newmatch,mark,emit. The compiler and the runtime ship together; nothing outside a build's own bundle should have named these. t("key", name: value)never parsed; interpolation takes a map:t("key", { name: value }). The documentation said otherwise.- Library:
webfluent::parse_source(src, file)is the one way in;Lexer/Parserof the old grammar are#[doc(hidden)]behindwf migrate.Expr::Recordis a new expression;sema::check,sema::types::checkandsema::lowerrun between parsing and codegen, and the linters see the lowered program.
- A list was rebuilt whole on every change, losing focus, scroll and
animation state in items that had not changed;
for … by keykeeps the nodes of the items that stay. - The route table depended on source order; it is now ordered by
specificity, then path, whether the
Routerhad children or not. easing:on an element was accepted and ignored; it sets the timing function, andspring,smooth,bouncy,easeOutand the rest mean the same there as in atransitionblock.- A multi-line call that gained motion props from a migrated
animate(…)clause had them written on the closing parenthesis's line; they take a line of their own. - Lint hints spoke the old grammar (
Heading("Page Title", h1)).
- Install 3.0 and run
wf migratein the project. Read the notes it prints: each names a file and a line where the migrator could not decide — a modifier word no spelling exists for, an imperativefetchto rewrite aslet x = await fetch(…). Commit the result;wf buildis byte-identical for a project that needed no notes. - Expect errors that were warnings. A misspelled flag, a prop a component does not declare, a case an enum lacks: the build stops and says what it takes.
- Annotate what you want checked. Nothing is required;
state items: [Todo] = []andresource r: [Deploy] = fetch(…)are where the type checker starts to see. - If anything of yours named
WF.*runtime functions — a custom stylesheet does not, ascriptinindex.htmlmight — rename per the table above. - Zed users: the extension's 2.0 pins the new grammar; the old one highlights nothing useful on migrated files.
A release about what a built site is like to use and to load. The Halyard
demo (https://github.com/monzeromer-lab/Halyard) was put in front of
Lighthouse and a screen reader, and everything either one found that the
compiler could have prevented, it now prevents; the same site also asked
for stylesheets of its own. Halyard's pages went from 63–71 to 87–99 on
mobile performance and to 100 on accessibility, with every finding that
remained pointed at by wf build. Nothing in this release is breaking.
Any .css file under src/ is part of the build: read in path order,
bundled into styles.css after the engine's rules and before the rules
style { } blocks compile to, minified with the rest. var(--token)
reaches every design token. An element names a rule with class:, which
adds to the engine's classes rather than replacing them, and follows state:
Card(class: "feature feature--first") { … }
Heading("Forms", h1, class: if loud { "hero--loud" } else { "" })
V02 accepts a modifier class one of these files defines
(.wf-alert--elevated makes Alert(elevated) real), in the build and in
the editor.
Input and Select with label:, hint: or error: become a field: the
label is a <label for> the control, the hint and the error are linked by
aria-describedby, the error (a string, empty when there is none) is a
role="alert" hidden until it has text and sets aria-invalid. The static
paint and the client agree, so hydration adopts it.
Input(text, bind: name, label: "Name", hint: "As on your passport", error: nameError)
- A route change settles the reader on the new page: focus moves to its heading (or the main landmark), the viewport returns to the top, the title is announced in a polite live region.
Carouselis a labelled region whose slides are groups; it has named previous/next/dot controls, a pause button, stops for reduced motion and hidden tabs, and hides the slides that are not showing from assistive technology.Tooltipopens on focus as well as hover, closes on Escape, and describes its trigger witharia-describedby.Dropdownmenus follow the ARIA menu pattern:role="menu"withmenuitems, arrow keys, Home/End, Escape, and focus returned to the trigger.
A14— arole:that requires particular children (tablist,menu,list,radiogroup, …) holding something else. A user component is judged by its root element, and a role given through a prop by what the call passed or the prop's default.A15— anaria-labelthat does not contain the control's visible text: its label, the literals in its block, and what a component in the block renders from its literal props (Kbd(text: "⌘K")in a search button).- Every element rule now points at the element's own line and column.
build.minifyis honoured at last: the bundle and the sheets lose their comments and whitespace, and nothing else. Halyard'sapp.js: 691 KB → 212 KB.build.split(on by default) writes each page aspages/<Name>.js, linked besideapp.jsby a static page and fetched by the router the first time its route shows in a single-page build.- The stylesheet carries only the component rules the project uses.
- Literal
style { }declarations compile to shared classes instead of inline styles — one rule for a hundred identical cards, on the static paint before any JavaScript runs. - The rules only one page can reach ship in
pages/<Name>.css, linked by that page and loaded by the router before it draws; what theAppbody or two pages share stays instyles.css. Halyard: 144 KB → 49 KB shared. build.compress(on by default) writes<file>.gzbeside every text output over a kilobyte, with a DEFLATE encoder of the engine's own — within a percent of zlib's default level, no dependency — for hosts that serve a precompressed file when they have one.wf servesends it, and compresses on the way out what has none, so a Lighthouse run against the dev server measures what a deployed site sends.- The static paint evaluates derived values and pure actions at build time, so a seeded list is painted with its rows rather than left to hydration.
- Images decode asynchronously; the first on a page is eager with
fetchpriority="high", the rest lazy, unlessloading:says otherwise.
The Zed extension and the Tree-sitter grammar were rebuilt from scratch and
live in this repository (editors/zed, editors/tree-sitter-webfluent).
The language server reads the whole project the way wf build does — a
store declared in another file resolves, a component called across files
is known — and reports positions in the units each client expects.
class:as an attribute replaced the engine's classes; it adds to them.A14reported a component whose root took its role from a prop as a plain control.wf serveread a query string as part of a file name.- The language server matched a document to its project by URL text rather than by path, and could miss a file with an escaped character in its path.
A release driven by building a real application. The Halyard demo
(https://github.com/monzeromer-lab/Halyard) — a nineteen-screen product design
implemented entirely in .wf — found every place where the language did less
than its documentation promised, and a handful of things a design system needs
that it had no way to say. Each is fixed or added here, with a test. Nothing in
this release is breaking.
A style block may now hold blocks for how an element looks while hovered,
focused, active, disabled, or in an ARIA state, beside its @media blocks:
Button("Save") {
style {
background: "var(--brand)"
hover { background: "var(--brand-hover)" }
focus { outline: "2px solid var(--focus-ring)" }
pressed { color: "var(--text-accent)" }
@media (max-width: 768px) { width: "100%" }
}
}
States: hover focus active disabled placeholder focus-within current pressed selected checked expanded invalid. The ARIA ones key off the element's own
aria-* attribute, so state and paint cannot disagree. These compile into
styles.css under a content-hashed class — one rule per distinct block, shared
by every element that writes it, and nothing injected at run time (CSP-safe).
@media blocks, which used to become a run-time <style>, take the same path.
A hyphenated named argument is an attribute: Trow(aria-selected: isSelected),
Badge("Ready", data-tone: "success"). A value that reads state follows it, and
an aria-* value of false is kept as the string "false", which is a real
ARIA state. A keyword after the hyphen is fine (data-state:).
style { --edge: color } sets a custom property. Any style value that reads
state is now kept in step with it, so style { height: "{pct}%" } moves.
- A component's block reaches it as
children, in both the SPA and SSG backends.AppShell(crumb: "Logs") { … }is one component. - Props are live:
Chip(pressed: showErrors)repaints whenshowErrorschanges. Declared defaults reach the SPA. - A handler written on a component call — the click shorthand or an explicit
on:…block — attaches to the component's root element.
Row/Stack/Gridgap: xs|sm|md|lg|xl,align:andjustify:(six values each) are real utility classes in both stylesheets.- Modifiers
fluid circle multiple ordered xs sm md lg xl header:List(ordered)is an<ol>,Tcell("Build", header)is a<th scope=col>wherever it is written. Table(caption: "…")gives the table a visually hidden caption; aTcellinsideTheadis a<th>.Link(to: "/docs", active: "prefix"): links get.activeandaria-current="page"from the router, on the static paint too.Option("value", "Label")submits the value and shows the label.- Theme token and property names may have digit-leading segments
(
viz-1,radius-2xl).
meta.fonts and meta.stylesheets add <link rel="preconnect"> and
<link rel="stylesheet"> to both HTML shells; the CSP policy widens for those
origins.
Two-parameter lambdas (a, b) => expr; a keyword after a dot (Array.from);
Number, String, Boolean, Map as globals; a map literal as an arrow body;
store derived values may read other derived values and actions.
Icon("home")rendered the word "home"; onlyicon:drew the glyph.IconButtondropped every named argument buticonandlabel, and the static backends painted the label as visible text next to the glyph. It is the accessible name (aria-label,title) only, and the glyph is drawn once.Progress(value: expr)was painted once and never moved.- Lambda parameters inside pages compiled as signal reads (
_x()). - An action parameter named like another action read the store member
(
move(step)besidestep). placeholder,disabled,srcand the other recognised attribute names were set once even when bound to state.- A
<select>bound to state always showed its first option: the value was applied before the options existed (runtime), and a slot inside aSelectcomponent was appended after the value (codegen). - An
else ifchain with a finalelseskipped every branch. - A
SliderorInputwith bothbind:andon:inputkept only one. - A
Button's block that mixed content with an action ran the action at render time; the action statements are its click handler now. - A sub-component dropped its style block, attributes and handlers in the SPA.
createStorebound derived values before actions, so a derived value that called an action threw.- The router never set
document.title. wf builddid not run the V01/V02 vocabulary lint or the semantic check; it does, with per-file locations.- The catch-all page was written to a directory literally named
*. It is404.htmlat the root, and it addresses its assets from the site root, so a dynamic route under a static build hydrates. wf servedid not serve a pre-rendered<route>/index.html.- The heading-outline lints (A11, A12) could not see headings inside
components; A03 did not accept
aria-labelledby; A13 warned about a white label on--color-primaryeven when noprimaryvariant was used. - In structural mode the sheet stacked every
Rowunder 768px; it no longer paints layout decisions the author did not make.
A release about correctness. The language gained themes and SEO; the output gained the accessibility, responsiveness and search metadata it should always have had. Several long-standing bugs are fixed, and the tests that would have caught them now exist.
This release contains breaking changes. See Migrating at the end.
The four built-in palettes (default, dark, minimal, brutalist) are gone
from the compiler. A theme is source now:
Theme Brand {
token color-primary: "#0F766E"
token radius-md: "14px"
}
Declare one and it is used; declare several and select with
"theme": { "name": "Brand" }. Tokens you do not name keep their baseline value.
dark, minimal and brutalist ship as examples/themes/*.wf — copy one into
your src/ and the existing "name" in your config keeps working. A config that
still names a removed palette fails the build with a message pointing at the
replacement, rather than silently rendering as the default.
theme.mode and theme.extends were declared in config and never read by
anything. Both are removed.
They were divs with an .open class, which meant no focus trap, no inert
background, no Escape-to-close and no aria-modal. They now use showModal(),
so the browser supplies all four. If you styled .wf-modal directly, note that
it no longer positions or hides itself — the element does that — and the backdrop
is ::backdrop.
generate_css(tokens)/generate_css_with(tokens, builtin)take resolved tokens rather than a theme name. Usethemes::resolve_tokens(program, config).render_page_html/render_page_html_studiotake aSiteContextinstead of five separate parameters.generate_html(config, program)takes the program, for the page metadata.Template::with_theme(name)selects aThemedeclared in the template rather than naming a built-in palette.
forloops produced bundles that crashed. The loop bound a plain callback parameter but every reference to it was emitted as a signal read, so any non-empty list threwReferenceErroron first render.wf init -t spashipped this.- Zero-width spaces in every static page. The SSG and template renderers
emitted U+200B after every closing tag — invisible in an editor, present in
textContent, in copied text and in what a crawler indexed. Heading("x", h1)did not produce an<h1>underwf build. The static renderers switched the tag; the SPA codegen never did, and emitted awf-heading--h1class no stylesheet defines.style { }blocks were dropped on nineteen components — every one handled by a dedicated emitter, because styling was applied after the dispatch that returned early.- The sidebar rendered transparent — no background at all in structural mode, and surface-on-white behind a 1.23:1 border in the full one.
- Navigation vanished on a phone. The sidebar was
display: nonebelow 768px and navbar links below 480px, with no control to bring either back. Both are off-canvas now, with a toggle, a scrim, Escape-to-close and focus return. - Structural mode had no focus indicator at all, and
.wf-input/.wf-selectremoved the outline without replacing it visibly. theme.builtin: "structural"never reachedwf build— only the studio honoured it.- The language server had not compiled for several releases. Nothing in the
repository built it;
cargo buildat the root builds the root package alone. Its completion list had drifted to 43 missing modifiers and eight invented ones. Link("Home", to: "/")drew a spurious accessibility warning, and both the PDF and slides scaffolds warned on their own first build.- PDF images ignored their
altand drew[Image]regardless;Progress(max: 0)rendered a full bar instead of an empty one.
Set meta.site_url and every page gets a self-referencing absolute canonical,
Open Graph and Twitter card tags, hreflang alternates with x-default, and
JSON-LD (WebSite, Organization, WebPage/Article, BreadcrumbList). Builds
write sitemap.xml and robots.txt.
Page accepts description, image, type and a bare noindex.
Without site_url, tags that need an absolute URL are omitted rather than
guessed at — a relative canonical causes problems later.
Tabs carry the full tablist/tab/tabpanel wiring with roving tabindex and
arrow keys. Dropdowns report aria-expanded and close on Escape. Toasts and
alerts are live regions. Every page has a <main> landmark and a skip link.
Animations respect prefers-reduced-motion. Pointer targets meet the WCAG 2.2
minimum of 24×24.
The type and spacing scales are fluid (clamp()), so a page responds without a
media query — and without losing to an inline style { font-size: … }. Images
default to loading="lazy" and decoding="async". The bundle loads with
defer. The default font stack no longer names webfonts nothing loads.
The static renderer now paints lists and conditionals over seeded data instead of
leaving <!--wf-for--> for the client.
"build": { "csp": true } emits a strict CSP and a _headers file. The dev
server sets nosniff, Referrer-Policy and X-Frame-Options.
A13 (theme contrast, checked at compile time because themes are now source),
S01–S04 (title, description, description length, duplicate route) and V02
(a real modifier whose class no stylesheet defines).
CompiledSite carries the resolved tokens, the declared themes with their spans,
flattened diagnostics, and the reason a theme failed to resolve.
- If your config names
dark,minimalorbrutalist: copy the matchingexamples/themes/*.wfinto yoursrc/. Your existing"theme": { "name": … }then resolves to it. The build tells you this if you forget. - Remove
theme.modeandtheme.extendsif present; they are ignored. - If you styled
.wf-modalor.wf-dialogdirectly, re-check it against a native<dialog>. - Add
meta.site_urlto get canonical URLs, sharing cards and a sitemap. Nothing breaks without it; those tags are simply not emitted. - Expect new warnings.
S02fires on every page with no description. None of them fails a build.