Skip to content

Latest commit

 

History

History
458 lines (393 loc) · 17.9 KB

32-javascript-interop.md

File metadata and controls

458 lines (393 loc) · 17.9 KB

32. JavaScript interop

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"

Your own script

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.js byte 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, let and var, and anything assigned to window.x, is a name .wf code 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 a T10 where 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 a T13.
  • 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, const or 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 import or export cannot 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.

Types from JSDoc

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.

A script reports; WebFluent owns the class

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.

A library from a CDN

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; an async one runs whenever it arrives instead. A module, or a script whose globals .wf calls, cannot be async (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 with async. Its origin still joins the policy. A reader who leaves within the first second is not counted; the same rules as async apply (E111).
  • Integrity. meta.integrity pins 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 on window.Confetti. Most CDNs also serve a plain build, which needs neither.
  • Calling it from .wf directly. { "src": "…", "globals": ["Chart"] } puts the names a library defines in scope, each Any — the compiler cannot see their shape. A class among them is constructed when called.

mount:, update:, cleanup: — on any element

Every element and every component call takes the three:

  • mount: (node) => … runs each time the element is made — the first paint, a route change, an if branch 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.

Portal — drawing into a node a script made

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. null keeps 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, its mount: and cleanup: are the page's or the component's it is written in; the node is only where they are drawn. A mount: 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 div with display: contents, so the node lays out the block as if the box were not there; an id:, a class:, data-* and aria-* are the box's.
  • A handle is somewhere too. Portal(into: panel) draws into the element a ref: panel names, 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 by document.getElementById is found too. A handle is not a state: an element it names only later, in an if that 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 click on a Stack that holds the portal does not hear it. Put the handler on the portal, or inside its block.
  • show does not hide it. show hides what is drawn in its own place, and a portal's block is drawn somewhere else. Take it away with if, or keep it aside with into: 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.

wf:render — for a script that finds elements itself

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.

Somebody else's custom element

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.

Publishing your components as custom elements

{ "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.

WF from a script of your own

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).

Coming from external

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", …).

Next

Desktop apps: the same pages in a Tauri or an Electron window.