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).
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>
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 and stm-local-* locals 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": local variable (statum-binding.js). The
expression is evaluated LAZILY (at most once per render pass, only when
read) against the parent scope; the variable is visible to this element
and its descendants (a child scope) and shadows slot types/outer
variables. Multiple locals per element; same-element locals cannot
reference each other. Replaces the removed stm-def-{name}-as
(mkpstm's generated fragment-parameter attribute included).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.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-{attribute}: page-wide variable whose value
is the element's attribute; attribute changes trigger a redraw.stm-local-{name}="expr": subtree-scoped variable (element +
descendants). Replaces the removed stm-def-{name}-as.stm-local-{name}-from-{attribute}: subtree-scoped attribute-sourced
variable; attribute changes trigger a redraw.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>
Property form: from-value and from-checked on
input/select/textarea read the live PROPERTY, not the attribute — an
input/change listener updates the variable and redraws immediately (per
keystroke). Every other attribute name (and every non-form-control element)
uses a MutationObserver. REDRAW GUARD: an observed attribute 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 the removed stm-def-* is inert everywhere). 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.