WebFluent has no package manager of its own, and does not need one to use
the JavaScript ecosystem. A .js file is part of the project the way a
.css file is: put it under src/ and its functions are there.
| You want to… | Use |
|---|---|
| call JavaScript of your own | a .js file under src/ |
| use a library from a CDN | meta.scripts in the config |
| hand an element to a script, and take it back | mount:, update:, cleanup: on any element |
| draw into a node a script made — a tooltip, a popup | Portal(into: node) { … } |
| let a script find elements itself | the wf:render event |
| place somebody else's custom element | Element("tag-name", …) |
| use your components from React, Vue or a plain page | output_type: "elements" |
A plain browser script, written as any <script src> file is written — no
export, no import, no build step:
// src/tilt.js
/**
* Tilt an element toward the pointer.
* @param {HTMLElement} node
* @param {number} [max] how far, in degrees
*/
function initTilt(node, max = 15) {
const move = (e) => {
const r = node.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5;
node.style.transform = `rotateY(${x * max}deg)`;
};
node.addEventListener("pointermove", move);
return {
setMax(m) { max = m; },
destroy() { node.removeEventListener("pointermove", move); },
};
}page Gallery(path: "/gallery", title: "Gallery", description: "Cards that lean.") {
state max = 15
Heading("Gallery").h1
Card(class: "tilt",
mount: (n) => initTilt(n, max),
update: (tilt) => tilt.setMax(max),
cleanup: (tilt) => tilt.destroy()) {
Text("Move the pointer over me.")
}
Slider(bind: max, min: 0, max: 45, label: "How far")
}
- The file runs as written. It is copied to
build/js/tilt.jsbyte for byte and linked with<script defer>on every page, before the compiled code — so a stack trace points at your line. - Its names are in scope. Every top-level
function,class,const,letandvar, and anything assigned towindow.x, is a name.wfcode calls directly. What a function or an IIFE keeps inside stays private, as it does in the browser. - A call is checked.
initTilt(n, 1, 2)is aT10where it is written — the script takes one or two arguments — and its JSDoc types the rest (below). Nothing about it is a guess: a name the file does not declare is still aT13. - A class is constructed. WebFluent has no
new; a call to a class a script declares compiles to one. - One name, one meaning. Two scripts declaring one name, or a script
name that is also a component, store,
constor a name the language or the browser owns (format,log,fetch), is an error naming both. - An ES module is refused. A file with a top-level
importorexportcannot run from a<script src>, so the build stops on it and says so. A published module is loaded from the config (below).
wf serve rebuilds when a script changes, wf audit lists what each one
declares, wf types prints each function's signature, and the editor
completes, documents and jumps to them.
JSDoc is a comment, so the file stays exactly what the browser runs. The checker reads it:
| JSDoc | WebFluent |
|---|---|
string · number · boolean |
String · Number · Bool |
object · { a: T } |
Map, or a shape of the fields written |
T[] · Array<T> |
[T] |
?T · T | null · [name] |
T?, or a parameter a call may leave out |
Promise<T> |
T, as an action that awaits it reads it |
(v: T) => R |
a function — an action or a lambda may be passed |
HTMLElement, anything else |
Any |
With no comment at all every parameter is Any, so adding a script never
breaks a build; each tag only narrows what a call may pass.
When how an element looks depends on something only a script can see — an
intersection, a scroll position, a library's event — hand the script an
action. An action is a plain function at run time, and the class stays a
class: the compiler can read:
// src/visible.js
/**
* Say whether `node` is on screen, each time that changes.
* @param {HTMLElement} node
* @param {(v: boolean) => void} onChange
*/
function watchVisible(node, onChange) {
const io = new IntersectionObserver(([e]) => onChange(e.isIntersecting));
io.observe(node);
return { destroy: () => io.disconnect() };
}page Story(path: "/story", title: "Story", description: "It fades in.") {
state visible = false
action setVisible(v: Bool) { visible = v }
Heading("Story").h1
Card(class: { "is-visible": visible },
mount: (n) => watchVisible(n, setVisible),
cleanup: (w) => w.destroy()) {
Text("Here I am.")
}
}
One owner per class: a class class: names is WebFluent's, and a class a
script adds with classList.add is the script's — class: only ever takes
off what it put on.
The compiler never fetches anything, so it cannot read a file on another origin. A library is a URL the config lists, and your own scripts use it:
{ "meta": {
"scripts": ["https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"],
"integrity": {
"https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js": "sha384-…"
}
} }// src/charts.js
/**
* A bar chart of `values`, one bar a quarter.
* @param {HTMLCanvasElement} canvas
* @param {number[]} values
*/
function drawSales(canvas, values) {
return new Chart(canvas, {
type: "bar",
data: { labels: ["Q1", "Q2", "Q3", "Q4"], datasets: [{ label: "Sales", data: values }] },
});
}page Sales(path: "/sales", title: "Sales", description: "How it is going.") {
state values: [Number] = [12, 19, 7, 15]
Heading("Sales").h1
Host(tag: "canvas",
mount: (c) => drawSales(c, values),
cleanup: (chart) => chart.destroy())
}
- Order. Each entry is linked with
<script defer>in the order listed, before your own scripts and the compiled code, so a library is there when the code that uses it runs. - Out of order.
{ "src": "…", "async": true }loads a script nothing on the page waits for — analytics, a tag manager — with<script async>. Deferred scripts run in order, so a large one listed plainly would hold the compiled code, and the page's first full paint, until it had arrived; anasyncone runs whenever it arrives instead. A module, or a script whoseglobals.wfcalls, cannot beasync(E111). - After the page.
{ "src": "…", "load": "after" }has no tag in the HTML at all: the runtime asks for it once the page has loaded and drawn its first frame. A browser fetches everything a page names while it loads, and a page's speed is measured against what it asked for before it first painted — so a large analytics tag named in the HTML costs a phone's Largest Contentful Paint more than a second even withasync. Its origin still joins the policy. A reader who leaves within the first second is not counted; the same rules asasyncapply (E111). - Integrity.
meta.integritypins the file; a library from another origin without a hash draws a warning. - The policy. The Content-Security-Policy is widened by exactly the origins listed, so a declared library is never blocked by the policy that ships beside it.
- A module.
{ "src": "https://…/confetti.mjs", "module": true, "as": "Confetti" }imports an ES module and puts its exports onwindow.Confetti. Most CDNs also serve a plain build, which needs neither. - Calling it from
.wfdirectly.{ "src": "…", "globals": ["Chart"] }puts the names a library defines in scope, eachAny— the compiler cannot see their shape. A class among them is constructed when called.
Every element and every component call takes the three:
mount: (node) => …runs each time the element is made — the first paint, a route change, anifbranch arriving, a list item added. What it returns is the handle the other two get.update: (handle) => …runs again whenever the state it reads changes.cleanup: (handle) => …runs when the element leaves — its page, its branch or its list item — so nothing a library made outlives it.- On a component call they belong to the component's root element, unless the component declares a prop of that name itself.
- A mount that throws takes itself out and logs the error; the page stays.
Host is the same lifetime on a bare node: Host(tag: "canvas", …) makes
the element a library wants — div by default, or span, canvas, svg,
section, figure, pre, p, ul, table.
Some libraries make a node and leave it empty for the page to fill: an
editor's tooltip, a map's popup, a chart's legend. Portal(into: node) { … } draws its block there. The library keeps the node's place, its keys
and its focus; the page keeps what is in it.
CodeMirror, loaded from meta.scripts as CM, with a hover tooltip whose
words the page draws:
// src/editor.js
// CodeMirror places each tooltip and keeps its keys; the node it makes is
// left empty, and the page draws what is in it.
const tooltips = new Map();
let made = 0;
/**
* The node CodeMirror made for a tooltip, or null once it has gone.
* @param {string} id
* @returns {HTMLElement|null}
*/
function editorHost(id) {
return tooltips.get(id) ?? null;
}
/**
* An editor in `node`. `opened(id, text)` hears a tooltip open, with the
* words under the pointer; `closed(id)` hears it close.
* @param {HTMLElement} node
* @param {(id: string, text: string) => void} opened
* @param {(id: string) => void} closed
*/
function startEditor(node, opened, closed) {
const hover = CM.hoverTooltip((view, pos) => ({
pos,
create() {
const id = `hover-${++made}`;
const dom = document.createElement("div");
tooltips.set(id, dom);
opened(id, view.state.doc.lineAt(pos).text);
return { dom, destroy() { tooltips.delete(id); closed(id); } };
},
}));
return new CM.EditorView({ parent: node, extensions: [CM.basicSetup, hover] });
}page Edit(path: "/edit", title: "Edit", description: "An editor whose tooltip the page draws.") {
state tip: String? = null
state words = ""
action opened(id: String, text: String) {
tip = id
words = text
}
action closed(id: String) {
if tip == id { tip = null }
}
Heading("Edit").h1
Host(mount: (n) => startEditor(n, opened, closed), cleanup: (view) => view.destroy())
if let id = tip {
Portal(into: editorHost(id), data-popup: "hover") {
Text(words)
}
}
}
- Where it goes is read again.
into:is read like any other expression: when a state or a list item it reads changes, it is read again. A tooltip that replaces another has a new id, so the block follows it to the new node. - It moves; it is not drawn again. Named another node, the same box
goes there with its nodes and their state — a field half typed in, a
list scrolled halfway.
nullkeeps it aside, and when a node is named again the same nodes come back. - It belongs to where it is written. Its handlers, its
emit, the state it reads and changes, itsmount:andcleanup:are the page's or the component's it is written in; the node is only where they are drawn. Amount:inside it runs once the block is drawn and in place, so it meets its node in the document when the node it goes into is there. - It goes with its scope. When the page, the branch or the list item it is written in leaves, the box leaves the node with it. The node is the library's, and stays.
- The box adds nothing. It is a
divwithdisplay: contents, so the node lays out the block as if the box were not there; anid:, aclass:,data-*andaria-*are the box's. - A handle is somewhere too.
Portal(into: panel)draws into the element aref: panelnames, even one written further down the page. Where it goes is read once more when the drawing is over and the page is in the document, so an element of the page's own found bydocument.getElementByIdis found too. A handle is not a state: an element it names only later, in anifthat opens afterwards, is not followed. - Events go up from where it is drawn. A press inside the block bubbles
through the node it is in and that node's parents, not through the
elements around the place it is written: an
on clickon aStackthat holds the portal does not hear it. Put the handler on the portal, or inside its block. showdoes not hide it.showhides what is drawn in its own place, and a portal's block is drawn somewhere else. Take it away withif, or keep it aside withinto: null.- Pre-rendered, it is a place. The static paint has a marker where a portal is and nothing of its block; the live page draws it once its script has run. A test that looks at one is run in a browser (Testing).
A list the library keeps — an editor's completions, each row a node it
made — is a keyed for of portals. A press on a row should leave the focus
in the editor, and on mousedown(e) { e.preventDefault() } does that while
on click still runs:
for row, i in Completions.rows by i {
Portal(into: editorHost(row.id), data-popup: "row") {
Text(row.label) {
on mousedown(e) { e.preventDefault() }
on click { Completions.pick(i) }
}
}
}
Left out, into: is a C01.
The page is drawn after your scripts run, drawn again over a pre-rendered
one, and drawn anew on every route, so a script that does its own
querySelectorAll listens for the drawing to land:
// src/tilt-all.js
document.addEventListener("wf:render", (e) => {
// e.detail.route is the route just drawn, e.detail.params its parameters.
for (const el of document.querySelectorAll(".tilt-me:not([data-tilted])")) {
el.dataset.tilted = "";
initTilt(el);
}
});It fires once per drawing, not for an if branch or a list item arriving
later — that is what mount: is for.
page Pricing(path: "/pricing", title: "Pricing", description: "Plans.") {
state loaded = false
Heading("Pricing").h1
Element("stripe-pricing-table", publishableKey: "pk_live_…", pricing-table-id: "prctbl_1") {
on ready { loaded = true }
}
}
Element places any custom element by its tag — lower case, with a hyphen,
as every custom element's is. A named argument is an attribute
(publishableKey is written publishable-key) and follows state like any
other value; an on handler hears the events it fires, whatever their
names; the block is its children; class:, style { } and mount: work as
on anything else. The script that defines it — your own, or one in
meta.scripts — upgrades it when it runs.
{ "build": { "output_type": "elements", "elements": ["PriceTag", "Rating"] } }Each name becomes a standards-based custom element: PriceTag is
<price-tag>, and a one-word component is prefixed so its tag has the
hyphen a custom element needs (Rating is <wf-rating>). The build writes
elements.js, styles.css and a page listing the tags it published.
<script src="/elements.js" defer></script>
<link rel="stylesheet" href="/styles.css">
<price-tag label="Pro" amount="29" sale></price-tag>An attribute is a prop, read when the element is connected and again
whenever it changes: a Number prop is read as a number, a Map or a list
as JSON, and a Bool is true when the attribute is present — as HTML reads
disabled — and false when it is ="false", so a framework that writes
the string still works. An event the component declares is dispatched as a
CustomEvent that bubbles. What the component created goes when the
element leaves the document.
React, Vue, Svelte, Angular, Rails, WordPress and a plain page can all place a tag. That is the whole reason this is the answer rather than an adapter per framework: the shared interface between frameworks is the platform.
The runtime is window.WF: a script under src/, the console, or a
library's callback can use it — WF.navigate("/about"),
WF.toast("Saved", "success"), WF.storeSnapshot("Cart"). The
runtime reference lists every function.
Two things to know:
- A build keeps only the runtime modules its own code reaches. A script that
calls a module nothing else uses needs
"build": { "runtime": "full" }. - A script from another origin must be listed in
meta.scripts, which widens the policy for it. An inline<script>is refused by the build's CSP check (Security).
Before 4.2, JavaScript was described with external declarations; 4.2
removed them. wf migrate rewrites a project: a remote module becomes a
meta.scripts entry (its calls keep working, typed Any), a local module
becomes a plain script under src/ with its signatures as JSDoc and
X.f(…) rewritten to f(…), and external element call sites become
Element("tag-name", …).
Desktop apps: the same pages in a Tauri or an Electron window.