This document is the reference for every tag, attribute and expression form
that is special to Statum: the pstm-* preprocessor syntax handled at build
time by statum-mkpstm, and the stm-* attributes handled at runtime by the
browser modules (statum-core.js, statum-document.js,
statum-markdown.js, statum-binding.js).
Audience: a developer writing Statum pages. For the wire protocol, directives
and the Vala API see model.md; for a tutorial see getting-started.md.
Two namespaces, two phases:
pstm-* — resolved at BUILD time by statum-mkpstm. Nothing with a
pstm- prefix is supposed to reach the browser; the delivered page is a
plain static document. If one leaks through anyway, the client logs a
console warning listing them.stm-* — resolved at RUNTIME by the client modules. These attributes stay
in the delivered HTML and are re-evaluated on every render cycle.statum-mkpstm turns one page file plus any templates and fragments it names
into a Vala StatumPage subclass: a composed, precompressed HTML document
with a baked-in route. Composition is: load templates → merge heads upward →
inline fragments (repeat until none remain) → expand the legacy client
reference → rewrite pstm-res-* → strip every remaining pstm-* → serialise
as HTML → precompress (identity/gzip/zstd/brotli).
CLI: statum-mkpstm -o OUT.vala -n ClassName --ns=Namespace [-r ROUTE]
[-t DIR ...] PAGE.html. The route comes from the page's <pstm-uri> unless
-r overrides it.
<pstm-uri> — the page route (element, page head)Child element of the page's <head> whose text is the route this page is
served at (e.g. <pstm-uri>/dashboard</pstm-uri>). The generated class's
route comes from here; registering a page whose composition dropped or
omits the pstm-uri throws StatumError.CONFIGURATION at startup. Routes are
read BEFORE head merging because merging consumes pstm-uri upward.
<pstm-name> — the page name (element, page head)Names the page for build diagnostics. Structural: consumed during composition, never delivered.
<pstm-title> — title contribution (element, page head)The page's title fragment. Titles CONCATENATE page-first: a page's
<pstm-title> text is prepended to its template's title. Merged like all
head content, then consumed.
<pstm-template> — parent template (element, page head)Names the template file (without extension) this page composes into. The
template is looked up in the page's directory or any -t template dir.
A template is itself an HTML file whose <body> contains a
<pstm-content> outlet. Chains (a template naming a <pstm-template> of its
own) compose recursively. An unknown template name is a build ERROR; a cyclic
chain is not diagnosed — keep chains acyclic.
<pstm-content> — content outlet (element, template body)The element inside a template's <body> that the child's <body> content is
spliced into, replacing the outlet. A template without an outlet is a build
error (there is nowhere to splice).
<pstm-fragment name="…"> — fragment inclusion (element, any body)Includes a named fragment. Fragments live in their own files and are declared
inside <pstm-fragment name="…"> … </pstm-fragment> wrapper elements; the
include element itself carries name="…" naming the fragment to inline.
Inclusion is recursive and runs to a fixed point: a fragment may include
fragments. Nested fragment markup is inlined where the include element sits.
An unknown fragment name is silently left as-is (and then stripped with all
other pstm-* elements) — check your builds for missing content rather than
errors.
A fragment's <head> content is merged into the document head like a
template's. A fragment <body> may carry metadata elements such as
<pstm-input> (consumed, not delivered).
pstm-materialise-as — wrapper element (attribute)On a page's <body> or a fragment's <body> or a <pstm-fragment> include:
wraps the spliced/inlined content in a single element of the given tag
(e.g. pstm-materialise-as="section"). Fragments that declare inputs are
ALWAYS wrapped (a div by default) so the inputs have a stable container.
When the fragment body supplied the tag (rather than the include site), the
fragment body's non-pstm-* attributes are copied onto the wrapper. An
explicit attribute at the include site wins over the fragment's own.
pstm-res-src / pstm-res-href (and any pstm-res-{attr}) — resource URL (attribute)Rewrites to {attr}="/_statum/resource/{value}". <script
pstm-res-src="statum-core.js"> becomes <script
src="/_statum/resource/statum-core.js">. Works for any attribute name
(src, href, ...). The named resource must be registered server-side
(StatumConfigurator.add_resource or the four default client modules).
statum.js expansion (script tags)A <script pstm-res-src="statum.js"> — the pre-split single-file client —
expands at build time into the module tags in canonical load order:
<script src="/_statum/resource/statum-core.js"></script>
<script src="/_statum/resource/statum-document.js"></script>
<script src="/_statum/resource/statum-markdown.js"></script>
<script src="/_statum/resource/statum-binding.js"></script>
One build warning per page names the page and the expansion. Pages that
reference module files directly are untouched and unwarned. A page mixing
the legacy reference with explicit module tags is deduplicated — each module
appears exactly once, in canonical order — and the warning says so. Plain
src="…" scripts are never touched. In new pages, write the four tags
explicitly (see Part III for the load contract).
Every pstm-* attribute and element is stripped after composition. The
delivered page is plain HTML + stm-* runtime attributes. See model.md for
the wire protocol and directives.
Load contract: core first, always. statum-core.js owns the slot
protocol, the directive pipeline, the realtime channel and the seams later
modules register into (the internal bridge; the attribute registry itself is
document-side). The canonical order is core → document → markdown → binding
(markdown and binding are order-independent of each other and need not
co-exist). Trailing modules may be cherry-picked after core (e.g. core +
document for pages without markdown or variables). A module loaded without
core logs console.error('[statum] … requires statum-core.js') and does
nothing else. All modules are ES5, dependency-free, guarded IIFEs.
stm-* attribute values are JavaScript expressions evaluated against a scope
built from state: { typeName: public } for every held slot type, plus loop
variables (statum-binding.js: stm-local-* locals; loop variables from the
walk) in nested scopes. statum.read('counter')
is the programmatic equivalent. Expression compilation is cached. An
expression evaluating to undefined means "not resolved yet" and LEAVES THE
ELEMENT UNTOUCHED (the server-rendered original DOM is preserved — this is
the progressive-enhancement policy); null is stringified to ''.
The scope is a Proxy over the state object with with-semantics — slot
type names are resolved first, then globals. Prefer distinct type names.
Every set/clear (directive, cross-tab broadcast, SSE push, or
statum.clear()) triggers renderAll(): beforeEvaluate hooks → the
document walk → afterEvaluate hooks → guard evaluation. The walk visits
every element; each registered attribute handler runs for elements carrying a
matching attribute. The walk is stateless — no binding remembers its previous
value; anything your handler writes is rewritten on the next cycle unless the
handler itself guards (see stm-prefill's dirty tracking for the pattern).
The slot protocol and nothing else — core is HEADLESS and owns no
stm-* attributes at all: no DOM scanning, no bindings. It keeps the
state store and persistence, X-Statum-Slot assembly, entrypoint/action
requests, directive dispatch, the SSE/SharedWorker realtime channel, the
alert/confirm/notify override mechanism, and the internal bridge that later
modules register presentation hooks into. A page can run core alone as a
pure state layer under any other templating framework: point
statum.config at your endpoints before init() and consume
read/getSnapshot/state/act yourself.
(The four <body> endpoint overrides — stm-entrypoint, stm-slots,
stm-channel, stm-worker — are read by the DOCUMENT module at load and
written into core's mutable statum.config; see that section. They are
inert on a core-only page, which sets statum.config directly.)
Directives (server → client, application/vnd.statum+json): set, clear,
invalidate, subscribe, unsubscribe, notify, transmit, error,
navigate, reenter, post, form-submit, form-reset. reenter
re-fetches the entrypoint in place (no page reload), after form directives
and before navigate/post. See model.md for payloads.
Public JS API (on window.statum): the essentials are version, config,
init, read, getSnapshot, getFrame, state, clear, act,
addStateListener / removeStateListener, beforeEvaluate /
afterEvaluate, Error, and the onConfirmHandler / onNotifyHandler /
onErrorHandler getter/setter pairs (the override mechanism for
stm-confirm / notify / failures — frontend-demo installs toast handlers
through it). registerAttribute is DOCUMENT API (next section) — it is
absent on a core-only page.
The render walk and the structural/value bindings. Installs the render
driver and owns EVERY stm-* attribute that touches the DOM, including the
ones that used to be listed under core:
stm-entrypoint, stm-slots, stm-channel, stm-worker
(on <body>): endpoint overrides. Document reads them at load and writes
them into core's mutable statum.config before init runs (defaults
/_statum/entrypoint, /_statum/slots, /_statum/channel,
/_statum/resource/statum-worker.js).stm-preloader / stm-content: boot visibility. While the
entrypoint loads, stm-preloader elements are shown and stm-content
elements hidden; once ready, the reverse. Set hidden on stm-content
yourself in the HTML to avoid a flash of unbound template.stm-guard (a <link rel="stm-guard" require="…" href="…">, plus the
pre-entrypoint attribute on the link): a client-side UX gate — when
require evaluates false, the page redirects to href. Re-checked every
render cycle; guards without pre-entrypoint only evaluate after the
entrypoint has loaded. This is UX sugar, NOT security.statum.registerAttribute(name, fn, {after, before}) — the extension
seam, provided by this module. name is an exact attribute name or, ending
with ., a prefix matcher. fn(el, scope) runs once per element per render
cycle for elements carrying a match; iterate el.attributes yourself when
several match a prefix. after/before name another registered attribute
and position the handler relative to it; an anchor that is not (yet)
registered warns and keeps registration order — order is resolved at
registration time, so a module that wants to be after stm-html should load
after statum-document. Duplicate names warn and are ignored. Handler
exceptions abort that render cycle (caught by renderAll's single try/catch)
— same contract as the built-ins. Registering against core alone is safe:
core queues the registration until document installs the registry.
Walk bindings:
stm-if / stm-else-if / stm-else: conditional chains among
siblings. Branches are toggled with the hidden attribute (they stay in
the DOM and are re-evaluated every cycle). stm-else-if/stm-else are
owned by their chain head's stm-if.stm-for-{name}-in (with optional stm-key): loop over an array
expression. The element is a HOLDER: its original server-rendered content
is hidden once data arrives and clones of the holder are inserted after it,
one per item, with {name} bound to the item in child scopes. Unresolved
(undefined) source: instances are cleared and the holder's original
content restored (progressive enhancement). Keyed loops (stm-key, an
expression over the item, usually a field) REUSE DOM nodes for unchanged
keys, preserving focus/selection/input state; unkeyed loops rebuild
wholesale. data-stm-active="1" + hidden mark an active holder.stm-local-{name}="expr" and the other variable bindings
(stm-global-*, stm-declare-*, -from-* forms) belong to the BINDING
module — see its section; they are listed here only because they extend
the scope the walk builds.stm-text / stm-html: set text content / inner HTML from an
expression. undefined leaves content untouched; otherwise stringified
(null → ''). Note (known issue C1): on editable controls stm-text
clobbers user input every cycle — use stm-prefill for form fields.stm-class.{name}: predicate per class. Truthy → classList.add,
falsy → remove.stm-attribute.{name}: boolean → set/remove (preferring the IDL
property when one exists); any other defined value → setAttribute;
undefined → remove. Note (known issue C2): for value this writes the
ATTRIBUTE (the default), not the live property — server-wins semantics;
live form values want stm-prefill.stm-disabled: truthy → disable the element and descendant controls.stm-action: binds the element's primary event (click, or submit
for a <form>) to an action object resolved from state. The action object
(shape { uri, method, private }) is authored server-side and embedded in
slot data. Companions:
stm-data-{key}: string data collected into the action's payload.stm-confirm: gates the action behind the confirm handler
(statum.onConfirmHandler; default window.confirm).stm-busy-class: classes applied to the action element while busy.stm-on-error / stm-on-success: class names applied on
failure / success respectively (mirrored: each clears the other; a new
attempt clears both).
The element's __stmAction promise (and __stmActionFailed flag) are
stashed per trigger for stm-clear-on-success and stm-clear-on-error
(both await the same stash).The CommonMark 0.31.2 engine (escape-first: raw source HTML is always
neutralised; javascript:/data:/vbscript: URLs are filtered) plus its
bindings:
stm-markdown: renders the expression's markdown to safe HTML into the
element (innerHTML). Runs after stm-html.stm-h-demotion: demotes child header tags (h1→h2 …) by N levels,
capping at h6. Runs after stm-markdown so rendered headers exist. Both
live in this module — a core+document page sees them do nothing.statum.markdown.render(src) → HTML string;
statum.markdown.renderTo(el, src) — same code path as the binding.Form-state bindings (hosted in the document module since the former
statum-forms.js was folded into it):
stm-prefill: writes an expression into form controls as PROPERTIES
every cycle: text-like inputs + textarea → .value (null → '');
checkbox → .checked (truthiness); radio → .checked = (el.value ===
String(value)) — give every member of a radio group the same
stm-prefill expression; select (single) → .value; select (multiple) →
per-option .selected when the value is an array. file inputs are
skipped (script assignment is forbidden by browsers). DIRTY TRACKING:
capture-phase input/change mark the element dirty; a dirty element is
NEVER overwritten by prefill; the form's reset event un-dirties its
controls (also the server form-reset directive's path). Dirty state is
per-element, so keyed-loop forms keep their own dirty state per instance.stm-clear-on-success (on a <form> with its own stm-action): when
the form's action promise resolves successfully, reset exactly THAT form
(values back to defaults, un-dirtied). On failure the fields stay. A form
carrying it without stm-action warns once.stm-clear-on-error (on a <form> with its own stm-action): the
mirror — when the form's own action FAILS (HTTP error or error
directive), reset exactly that form. A DECLINED stm-confirm does not
fire it: decline is cancellation, not error. A form carrying it without
stm-action warns once.stm-on-success (on an action element): classes applied when the
action succeeds — the mirror of stm-on-error. The two are mirrored: a
success clears the error classes, a failure clears the success classes,
and a new attempt clears both at its start.statum.document.reset(formOrEl),
statum.document.markDirty(el), statum.document.isDirty(el).Client-side variables usable in any other stm-* expression. All are
LAZY-CACHED: a binding's expression evaluates at most once per render pass,
only when something reads the variable; the cache resets each pass. Cycles
(a reading b reading a) log [statum] variable cycle: a → b → a and
the variable reads as undefined for the pass — never a page crash.
stm-global-{name}="expr": a page-wide variable, visible in any
expression anywhere — including elements that precede its declaring
element in document order (the pass-start sweep exposes lazy getters).
Duplicate declarations: last in document order wins, one warn per pass.stm-global-{name}-from-{source}: page-wide variable sourced from a
live property or an attribute (see "observables" below); changes trigger
a redraw.stm-local-{name}="expr": subtree-scoped variable (element +
descendants). Replaces the removed stm-def-{name}-as.stm-local-{name}-from-{source}: subtree-scoped sourced variable;
changes trigger a redraw.stm-update-attribute.{name}="expr": set the attribute only when the
expression's EVALUATED value differs from what this binding last wrote.
Unlike stm-attribute.{name} (which writes every render cycle), local or
scripted changes to the attribute SURVIVE between renders — the binding
rewrites only when its own output genuinely changes. Typical use: an
expander whose open attribute is server-driven but user-toggled:
<details stm-update-attribute.open="section.open">
<summary>Section</summary>
…
</details>
The server's value lands once; user toggles persist until the state
expression's value actually changes. Coercion: undefined never touches
the attribute, null/false remove it, true sets it bare, anything
else is stringified. A first evaluation that finds the attribute already
holding the value adopts it as the baseline instead of writing.
stm-declare-{name}: declares {name} for a subtree WITHOUT a
value, so a descendant binds it (stm-local-{name}=… or
stm-local-{name}-from-…) and SIBLINGS of that binder can read it:
<div stm-declare-username>
<form>
<input type="text" stm-local-username-from-value>
</form>
<div class="alert" stm-if="username.length < 3">
<p>Username is too short</p>
</div>
</div>
Observables — how -from-{source} reads its source. Two spellings,
one mechanism. -from-{property} and the explicit -from-attribute.{name}
both consult the OBSERVABLES REGISTRY: property name → the standard DOM
events that signal its change (defaults value and checked with
input/change). When the mapping covers the name and the element has the
property, the variable reads the live PROPERTY and listens to those events:
every event updates the bound variables and redraws immediately (per
keystroke for value), with a MutationObserver supplementing so scripted
attribute writes are never missed. Further event-driven properties register
without touching the module:
statum.binding.observe('selectedIndex', ['change']);
statum.binding.observe('open', ['toggle']); // <details>
Any name outside the mapping (or absent as a property on the element) is
read as an ATTRIBUTE and observed via MutationObserver — prefer the
explicit -from-attribute.{name} spelling for those, aligned with
stm-attribute.{name}. REDRAW GUARD: an observed change only redraws when
the variable's derived value actually changed — the walk itself writes
attributes (stm-attribute.*, hidden, data-stm-active), so a naive
observer would loop forever.
Declared-but-unbound variables read undefined (an expression like
x.length then degrades to undefined for that expression via the
evaluator's error policy — the page stays alive). Load order: after core and
document; order-independent of markdown, and need not co-exist with it.
Canonical: core → document → markdown → binding (markdown and binding are
order-independent of each other and need not co-exist). Cherry-pick trailing
modules only. Without markdown: no stm-markdown, no stm-h-demotion.
Without binding: no stm-global-* / stm-local-* / stm-declare-*
variables — and note stm-def-{name}-as (their obsolescent alias) is inert
without the binding module too. Core alone is
HEADLESS — it applies state and directives with no DOM at all (point
statum.config at your endpoints before init), so a non-document templating
framework can use it as its state layer. Without document there is no render
walk, no stm-* bindings, no statum.registerAttribute (it is document
API), no statum.document, no boot visibility, no guards. All modules assume
core; loading any module first logs an error and does nothing.