TEMPLATES.md 22 KB

TEMPLATES.md — Statum template and markup reference

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.

Part I — The pstm preprocessor (build time)

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

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

What never reaches the browser

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.

Part II — The runtime modules

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.

Expression syntax (all modules)

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.

Render cycles

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

Section: statum-core.js

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.

Section: statum-document.js

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

Section: statum-markdown.js

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.
  • Programmatic: statum.markdown.render(src) → HTML string; statum.markdown.renderTo(el, src) — same code path as the binding.

Section: statum-document.js (form bindings)

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.
  • Programmatic: statum.document.reset(formOrEl), statum.document.markDirty(el), statum.document.isDirty(el).

Section: statum-binding.js

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.

Part III — Quick reference: load order and cherry-picking

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.