Documentation hub — cell-authoring contract#

This is the implementation reference for writing a hub post. The reader-facing explanation lives in the hub index's "How the interactive cells work" section; this file is the fuller contract for whoever writes the next post.

Front matter#

---
title: "Post title"
description: "One-sentence description."
layout: hub.njk
series: docs-hub
series_order: N
vocab: foaf | wikidata | schema.org | skos | none
status: draft | published
tests: tests/hub/postNN_test.mjs
---

layout: hub.njk is required — it's what wires in the Observable runtime and the fenced-block cell convention. The rest of the fields are read by nothing at build time today; they're bookkeeping (the series plan doc, docs/designissues/2026-07-05-docs-hub-plan.md, is the source of truth for the series map) but keep them, since the next post's author will grep for them.

Fence conventions#

Cell chrome: Edit + Run#

Every mounted cell auto-runs on page load exactly as before, but now also gets a small toolbar (an Edit toggle and a Run button) inserted between the code and the output box. DOM order after mountCell() runs is:

<pre><code class="language-observable-js">...</code></pre>   <!-- original source, static view -->
<textarea class="observable-cell-editor" hidden>...</textarea>  <!-- editable view, seeded from the pre -->
<div class="observable-cell-toolbar">
  <button class="observable-cell-edit-btn">Edit</button>
  <button class="observable-cell-run-btn">Run</button>
  <!-- + a one-line hint span, first cell of the page only -->
</div>
<div class="observable-cell" data-hub-cell="N">...</div>   <!-- Inspector output -->

Only convert a code sample to a live cell when it actually computes something a reader benefits from seeing run (a parse, a query, an entailment closure) — not every fenced block needs to be live.

Closed cells and the fold wrapper#

A fence line can carry flags after the language, separated by spaces:

observable-js closed observable-js closed title="Library: circuit helpers"

closed makes the cell mount collapsed. title="..." (the value can contain spaces) sets its summary line. parseCellFlags(info) (docs/web/hub/reactive-cells.mjs) parses this text into {closed: boolean, title: string | null}; an unrecognized word is ignored, so a future flag can be added without breaking a post written before it existed. Three places call it on the same string — everything after the observable-js word: docs/.eleventy.js's fence renderer at build time, hub.njk's mountCell() at mount time, and tests/hub/_helpers.mjs's extractObservableCellsWithFlags() in the Node pinning tests.

docs/.eleventy.js wraps markdown-it's own fence rule: for a fence whose info string starts with observable-js and carries more words, it renders the block exactly as the default rule would, then adds a data-hub-cell-flags="<escaped rest of the info string>" attribute to the <code> element. The class stays exactly language-observable-js — markdown-it's default fence renderer only ever takes the first word of the info string for the class, so every existing pre > code.language-observable-js selector keeps matching.

mountCell() wraps a cell's four blocks — the static <pre>, the editor <textarea>, the toolbar, the output container — in a <details class="observable-cell-fold">, with a leading <summary class="observable-cell-fold-summary"> ahead of them, same order inside as before:

<details class="observable-cell-fold" data-hub-cell-flags="...">
  <summary class="observable-cell-fold-summary">Cell name</summary>
  <pre><code class="language-observable-js">...</code></pre>
  <textarea class="observable-cell-editor" hidden>...</textarea>
  <div class="observable-cell-toolbar">...</div>
  <div class="observable-cell" data-hub-cell="N">...</div>
</details>

open is set on every cell's <details> except one flagged closed. The summary text is the title flag when given; otherwise Cell <name> for a named cell (analyzeCell(source).name), otherwise Cell <index + 1>. A native <details>/<summary> is focusable and toggles on Enter and Space on its own; mountCell() adds no key handling for this.

A cell whose run rejects — the same .observable-cell-error state described above — also opens its <details> and adds observable-cell-fold-error to it, so a collapsed cell that fails is never hidden from a reader. A later successful run removes the class again; it leaves the <details> open, and closing it again is up to the reader.

Library cells at the end of a post#

hub.njk mounts a page's cells in two passes. Pass 1 walks every observable-js cell and registers its declared name. Pass 2 mounts each cell and hands it to the vendored Observable runtime, which computes the dependency order from the registered names and runs each cell once its inputs resolve, regardless of where either cell sits in the document. An earlier cell can call a function a later cell defines — a helper library, or any block of supporting definitions, does not have to precede its first use. The one rule: the defining cell must be a named cell (name = ...); an anonymous cell has no name for anything else to reference.

Post 47's "Closed cells and library cells at the end" section is the worked example: an early cell calls notebookHelpers.count(rows), and notebookHelpers is a closed, named cell at the very end of the file.

Named cells: declare once, reference everywhere#

This is the default authoring style for every post in the series, not an optional extra. A cell of the form name = <expression> or name = { ...statements...; return v; } declares a named reactive variable; any later cell that mentions name becomes a dependent, and the vendored Observable runtime topologically orders the cells and re-runs whatever needs re-running when an upstream value changes. A cell without a leading name = (starting with const, return, a bare expression, …) stays anonymous, exactly as before — naming is additive, not a requirement on every cell.

The rule this replaces: earlier drafts of several posts had each live cell redeclare its own copy of the same Turtle/JSON text (const ttl = \…`pasted into every cell that needed it), so editing the data meant editing it N times and no cell could build on another's parsed result. That redundancy is the defect, not a style choice — when two or more cells in a post need the *same* data (byte-identical, not just similar), declare it once in a named cell — usually the first cell that introduces it, or a small dedicated cell right where the data first appears in the prose — and have every other cell reference the bare name instead of repeating the literal. Name an intermediate computed value (a parsedDataset`, a shared helper function) the same way, but only where a later cell actually reuses it — don't introduce a name nobody reads. Where two cells' data genuinely differs (even if structurally similar), leave them independent; forcing a shared name onto deliberately-different examples is the wrong direction.

Post 26 is the worked example of the whole chain: ttl = \…`names the source text,graph = fn.parse(ttl)names the parsed dataset (a promise-valued cell — the runtime awaits it before any dependent reads it),results = fn.query(graph, …)names the query result,table = pretty(results)and a final anonymous chart cell both readresults/further-derived names. Tap **Edit** on the ttl` cell there and change the data: only the cells that actually depend on it re-run, not the whole page.

The compiler that infers each cell's name and its cross-cell inputs is reactive-cells.mjs — shared, byte-for-byte, between docs/_includes/hub.njk (the browser) and tests/hub/_helpers.mjs's runReactivePost() (the Node pinning harness), so a post's tests exercise the identical dependency inference the page runs. Read its header comment before writing a named cell — it documents the exact traps:

A post's tests/hub/postNN_test.mjs pins the cross-cell wiring directly: runReactivePost(cells, bindings) builds the same headless Observable-runtime module the browser builds, and post.value(post.names[i]) reads a named (or anonymously-indexed) cell's resolved value through its full dependency chain — proving the references actually resolve, not merely that each cell happens to run in isolation.

Cell bindings (CELL_BINDINGS)#

Every live cell's function body receives these bindings by parameter name. This list and the new Function(...) parameter list in hub.njk are mirrored deliberately — extend both together if a future post needs a new binding.

Name What it is
Factoidal the raw npm package entry (npm/factoidal/browser.js) — query(dataString, sparqlString, {dataFormat, entail, output}) returns a raw SPARQL-JSON results object (or a raw string for non-JSON output), toRdf()/canonicalize() dump N-Quads text, queryDataset() handles multi-named-graph/multi-engine queries. No Dataset, no typed bindings — this is the CLI's own shape, one call in, one string/JSON out.
fn the typed cell-facing API: fn.parse(text, {format}) -> Promise<Dataset>, fn.query(dataset, sparql, {entail}) -> Promise<Bindings[] | boolean | Dataset> — the same external contract npm/factoidal/index.js's Node-side typed API exposes (parse()/query()/Dataset.size/iteration/toNQuads()), reshaped from Factoidal's raw calls by a small adapter defined inline in hub.njk (see "Why fn is an adapter, not an import" below). Use this for any cell that parses a document or runs SELECT/ASK/CONSTRUCT and wants to work with terms/bindings rather than raw JSON.
d3 vendored d3 7.9.0, for hand-rolled charts.
Plot vendored @observablehq/plot 0.6.17, for declarative charts.
html vendored @observablehq/stdlib's tagged-template HTML helper.
md vendored @observablehq/stdlib's tagged-template Markdown helper.
pretty opt-in prettier rendering for a cell's return value — see "The pretty() rendering option" below. Returns a DOM node in the browser; nothing else in the contract changes if a cell never calls it.
L vendored Leaflet 1.9.4 (third_party/leaflet/, BSD-2-Clause, no CDN) — window.L as set by the classic <script> load in hub.njk's head. Use L.map()/L.circleMarker()/L.geoJSON() for vector map cells (no default marker-icon PNGs are vendored, so L.marker() with the stock icon will 404 — use L.circleMarker instead; a vendored GeoJSON basemap lives under web/hub/assets/geo/). L.tileLayer() against an external tile host is live-mode only (task #105): gate it on data-hub-mode === "live" — the strict page's CSP blocks the tile request, and hub.njk styles the layers-control toggle with a text glyph so the un-vendored images/layers.png is never requested either.

The fn typed surface in full#

fn.parse(text: string, options?: {format?: string, baseIRI?: string, lenient?: boolean}) -> Promise<Dataset>
  // strict by default (issue #344): a syntax error rejects with a ParseError
  // (line/column/offset); {lenient: true} recovers, with dataset.diagnostics

fn.query(dataset: Dataset, sparql: string, options?: {entail?: 'none'|'RDFS'|'OWL-RL'})
  -> Promise<Map<string, Term>[]>   // SELECT
   | Promise<boolean>               // ASK
   | Promise<Dataset>                // CONSTRUCT / DESCRIBE

fn.shaclValidate(data: Dataset|string, shapes: Dataset|string, options?: {format?: string})
  -> Promise<{conforms: boolean, report: Dataset}>
  // report is SHACL_Validation.validation_report_to_graph's graph: one
  // sh:ValidationResult per violation (sh:focusNode, sh:resultPath,
  // sh:resultMessage, sh:sourceConstraintComponent). Throws if the
  // loaded bundle predates the SHACL export -- see "Capability checks"
  // below for the try/catch pattern a cell should use instead of
  // assuming this always resolves.

// Dataset:
dataset.size                        // number of quads
[...dataset]                        // iterate {subject, predicate, object} quads
dataset.toNQuads()                  // N-Quads/N-Triples text

// Term (subject/predicate/object, and Map values from query() rows):
term.termType                       // 'NamedNode' | 'BlankNode' | 'Literal'
term.value                          // IRI, blank-node label, or literal lexical form
term.language                       // '' unless termType === 'Literal' and it has a language tag
term.datatype.value                 // datatype IRI, Literal terms only

This is intentionally the same shape npm/factoidal/index.js's parse()/query() expose — a post's code sample is written once against that typed API (and pinned by a node:test file importing the real npm/factoidal module directly), then dropped into an ```observable-js fence with factoidal. renamed to fn. and any import statement removed (cell bodies are plain function bodies, not ES modules — no import/export inside a fence).

Capability checks#

Not every fn method is guaranteed to work against every loaded engine bundle — fn.shaclValidate (and the raw Factoidal.shaclValidate/ shexValidate/owlClosure/etc. it's built on) needs the npm-entry ABI bundle, which an older or stale build might not expose. A cell that calls one of these should try/catch it and produce an explanatory value on failure rather than let the whole cell render as a hard .observable-cell-error:

try {
  const result = await fn.shaclValidate(data, shapes);
  return { available: true, conforms: result.conforms };
} catch (err) {
  return { available: false, note: err.message };
}

This is the same pattern npm/factoidal/lib/api.js's own capabilities() probe uses server-side (per-function typeof checks); a cell doesn't have access to capabilities() directly (it's Node-only, npm/factoidal's typed API, not exposed on browser.js), so try/catch around the call itself is the client-side equivalent.

Why fn is an adapter, not an import#

npm/factoidal/index.js (and fn.js, the FP dataset wrapper it in turn wraps) is CommonJS and uses node:fs/node:crypto/require() — none of which exist in a browser. npm/factoidal/browser.js is the one browser-safe ESM entry point; its raw, CLI-shaped calls (query, toRdf, canonicalize, and the per-engine wrappers such as xsltTransform/queryHdt) are the primitives fn is built on, not what a hub cell calls directly. Rather than fork the engine, re-mirror a browser build of the whole typed API, or change npm/factoidal itself wholesale (out of this wave's territory — npm/factoidal and lib/api.js are verified-library-adjacent surfaces with their own test suite), fn is a small reshaping layer defined directly in docs/_includes/hub.njk: for parse/query it feeds the browser entry's toRdf()/query() results through a short N-Quads/SPARQL-JSON parser to produce Dataset/Map-shaped values; for the engine wrappers (XSLT, MathML, XForms, JSON Schema, Schematron, TOAN, matrix, HDT, SHACL, canonicalize, update, capabilities) it delegates straight through to the matching browser-entry function, unwrapped. It does not import npm/factoidal/lib/api.js directly — the browser entry (browser.js/fn.js/index.d.ts under npm/factoidal/, mirrored to docs/npm/factoidal/) is the only shared surface.

Trap: */ inside a fn-wrapper JSDoc comment breaks EVERY post#

hub.njk's fn object and its supporting functions live inside ONE inline <script type="module"> block that every hub post's page shares. A JS block comment (/** ... */) closes at the FIRST */ it contains — same trap as F*'s nesting (* ... *) comments (CLAUDE.md's "F* Syntax Traps"), except a JS block comment does not nest at all, so it is even easier to trip. Writing a glob-ish name like rhoDf*/ sigmoid inside a doc comment (post 42's toCottas/openCottas wrapper, 2026-08-25) closes the comment right there; every line after it becomes literal top-level code, and the whole shared script fails to parse with a generic Unexpected token '*' — on EVERY post's page, not just the one being edited, since they all load the same script. Two fixes, either is enough: write the name without the slash (rhoDf-family, not rhoDf*/), or use // line comments instead of /** */ for prose that might contain */. Verify a hub.njk edit by extracting the ACTUAL rendered <script type="module">…</script> block from a built page (anchor on the line that is exactly <script type="module">, not a textual mention of that string inside the CSP <!-- --> comment near the top of the file — an easy false-negative) and running node --check on it.

Trap: an open double-brace inside a JSDoc type annotation breaks the whole Eleventy build#

Same family as the */ trap above, a different delimiter and a different parser. Nunjucks — the template engine hub.njk is rendered through, and (via docs/.eleventy.js's markdownTemplateEngine: "njk") every hub post's Markdown file too — scans the raw file text for its own tag delimiters before anything downstream ever sees JavaScript, a comment, or a Markdown code span; it does not know a /** ... */ block is a comment or that backticks mark inline code. A TypeScript-style inline object-type JSDoc annotation — a curly-brace type that itself opens with another curly brace, to describe an object shape — reads to Nunjucks as the start of one of its own tags. The build fails with a generic (./_includes/hub.njk) [Line N, Column M] expected variable end, on every page the site builds. This is not caught by node --check, nor by any check (including the previous section's own verification recipe) that strips Nunjucks delimiters out before checking the JavaScript underneath — that never asks whether Nunjucks itself can parse the file. Caught the same day it was written (2026-09-16, the fn.serialize doc comment, danbri/factoidal#687) only because tests/web-demos/hub_browser_all.sh runs the real Eleventy build; a JS-only syntax check would have shipped it.

Fix: never place two brace characters next to each other in this file, or in a hub post's Markdown, outside a genuine Nunjucks tag — not even inside a JSDoc comment, a Markdown code span, or a code fence. Split a JSDoc object-typed parameter into one line giving its own type as the plain word object, plus one line per field giving that field's own type. The only reliable check is running the actual Eleventy build, or searching the file by hand for two open-brace or two close-brace characters (or an open-brace immediately followed by a percent sign, or a percent sign immediately followed by a close-brace) in immediate succession, and confirming every match is a genuine, already-valid Nunjucks tag.

Trap: a function call wrapped directly around a dotted chain hides the chain's own name from the reactive-cell analyzer#

reactive-cells.mjs's collectLocals() finds an arrow function's own parameter list with a pattern that looks for "open paren, anything that is not a close paren, close paren, arrow" — it has no notion of nested parentheses, so it cannot tell where a parameter list actually starts; it only knows where the first close paren after some open paren happens to land. For a cell that writes pretty(rows.map((el) => ...)) that mismatch is invisible: the analyzer's naive scan starting at pretty's own open paren runs forward until the first close paren it meets, which is the arrow's own, and — because an arrow token happens to follow it — the scan stops there and treats everything in between (rows.map((el) as if it were one parameter list, adding rows, map AND el as local bindings. el really is the parameter; rows and map are not, and if rows happens to be the exact name of another cell — as model was, in model.elements.map((el) => ...) — that cell silently drops out of the dependency graph. The runtime compiles and runs the cell anyway, with no model parameter on the generated function, and the browser throws ReferenceError: model is not defined — a node --test run of runReactivePost() reproduces the exact same wrong inputs list (this is shared, not browser-only code), so it did not need the browser sweep to be caught, only a closer look at the reactive-cell analyzer's own computed inputs for each cell.

The failure only shows up when the wrapping call's own close paren is not the first one the naive scan reaches — pretty(Object.values(x). map((c) => ...)) is safe, because Object.values(x)'s own close paren comes first, is not followed by an arrow, and the failed attempt moves the scan on to the real map((c) => ...) correctly. pretty(x.method( (p) => ...)), with nothing else between the outer call and the arrow's own parameter list, is exactly the unsafe shape. Fix: never pass a chained method call straight to an outer function call when the chain starts with a cell's own name — compute the chain into a local const first (const rows = model.elements.map((el) => ...); return pretty(rows);), so the cell name never sits inside the same mis-scanned span as an arrow's parameter list. Found and fixed the same day as the two traps above, hub post 55, danbri/factoidal#687; not specific to that post — any existing or future post with this shape carries the same defect.

The pretty() rendering option#

Every cell can return raw arrays/objects and let the Inspector render its default collapsed-JS-tree view — that's still the default, and most cells (posts 03-05, and any cell not explicitly converted) do exactly that. pretty(value) is an opt-in alternative for the common shapes a query cell tends to produce: wrap the cell's return value in pretty(...) and get a small styled HTML table instead of a JS-tree the reader has to expand.

Shape dispatch (checked in this order):

Input shape Rendering
Array of Map (SPARQL bindings rows — Map keys are variable names) table, one column per variable, in first-seen order across all rows
Dataset-like (has .size and is Symbol.iterator-able over quads) a triples/quads table: s/p/o columns, plus g if any quad has a graph
Array of plain objects table from the union of keys across all objects, in first-seen order
Plain object a two-column key/value table
Scalar (string/number/boolean/bigint) a small styled value span
Anything else (null, undefined, an empty array, a DOM node, a lone Map not inside an array, ...) returned unchanged — the Inspector's own renderer handles it exactly as if pretty() had never been called

Term values (NamedNode/BlankNode/Literal) inside a table cell are shortened for display: an IRI compacts to its trailing path/fragment segment with the full IRI in a title tooltip (hover on desktop; the raw value is still there for anyone who needs it); a blank node is shown _:-prefixed; a literal is quoted, with an @lang or ^^datatype suffix when present (datatype/language also folded into the tooltip). Tables sit inside a scrollable wrapper — both axes, so a wide table doesn't force the page to scroll horizontally at a 390px viewport (same overflow-x pattern .observable-cell already uses) and a tall table scrolls internally instead of pushing the rest of the page down — with a small caption line above the grid giving the row (or field) count, e.g. "12 rows".

Browser vs. test duality. hub.njk's pretty() returns an actual DOM node — the Inspector's contract for a DOM-Node return value is to insert it as-is, so this composes with the existing Inspector machinery with no special-casing. tests/hub/_helpers.mjs exports a pretty stub with the same shape dispatch but no DOM dependency: it returns a plain, JSON-serializable structure instead — {kind: 'table', columns: [...], rows: [[...], ...]} or {kind: 'value', value} — so a pinned node:test can assert on table shape (result.columns, result.rows.length, cell contents) without a browser. Both implementations satisfy the same shape-dispatch contract above; a cell written against pretty() runs unchanged against either.

Reader-added cells#

Below the pinned cells, hub.njk's initUserCells() appends a "Your own cells" section with an + Add a cell button. Clicking it mounts a fresh live cell — an editable <textarea> plus a Run, Remove and the same Output: [Auto] [Table] toggle — that evaluates through the same compileCell() / CELL_BINDINGS path the pinned cells use, so fn, Factoidal, Plot, d3, html, md and pretty are all in scope. Each user cell gets its own Observable runtime Variable (userCell<seq>); Run (re-)defines it in place, Remove calls variable.delete() to tear it out of the runtime and drops the DOM block. The pinned cells are never touched — this only adds cells on top. Nothing a reader types is persisted or sent anywhere; it runs in their browser and disappears on reload.

Testing discipline#

Every live cell's source must also be pinned in that post's test file under tests/hub/postNN_test.mjs. The pinning tests extract the exact fenced source out of the shipped post file (extractObservableCells() in tests/hub/_helpers.mjs) and execute it. A post whose cells are all anonymous (no cross-cell references) can run each cell standalone via runObservableCell() — the same new Function(...CELL_BINDINGS, body) construction hub.njk's mountCell() uses. A post with named, cross-referencing cells (the norm — see "Named cells" above) instead uses runReactivePost(cells, bindings), which builds the same headless Observable-runtime module hub.njk builds in the browser, wiring each cell's inputs through the identical reactive-cells.mjs compiler; post.value(post.names[i]) then reads a cell's resolved value through its full dependency chain, so the test proves the cross-cell references actually resolve rather than merely that each cell runs alone. Either way, the test runs the literal string that ships on the page, not a hand-copied approximation that can drift. The Node-side fn binding used in tests is the real npm/factoidal typed API (imported the same way npm/factoidal/test/*.js does), not the browser adapter — since both implementations satisfy the same external contract above, one cell source works correctly executed against either.

The browser-side correctness check (does fn's adapter actually work against the real F*-extracted engine in a real browser) is tests/web-demos/hub_posts_smoke.sh, which drives headless Chromium over each built post page and asserts every .observable-cell on the page computes without .observable-cell-error and produces a value. It also drives one interaction pass, on the first post's first cell: click the Edit toggle, overwrite the textarea with a trivially different deterministic expression (return 6 * 7;), click Run, and assert the output box updates to 42 with no .observable-cell-error — i.e. that the toolbar's Edit/Run chrome and module.redefine() re-run path actually work end to end, not just that the auto-run-on-load path does. The 390px no-horizontal-overflow check (document.documentElement.scrollWidth <= clientWidth) runs on every page after the toolbar has mounted, so it also covers the toolbar/editor chrome added by this change.

Constraints every cell must respect#