TEMPLATES.md 19 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).

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 three 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>

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

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

Slot protocol, HTTP, directives, realtime, boot, public JS API. Attributes it owns:

  • stm-entrypoint, stm-slots, stm-channel, stm-worker (on <body>): override the four endpoint URLs (defaults /_statum/entrypoint, /_statum/slots, /_statum/channel, /_statum/resource/statum-worker.js). Read once at init.
  • 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.
  • stm-on-error (on action elements): space-separated class names applied while the action's request is failing / after an error directive.
  • stm-class.{name}: class-list predicates shared by core's busy-state logic and the document walk (see below for semantics).
  • stm-disabled: predicate disabling the element and its descendant form controls.

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, the onConfirmHandler / onNotifyHandler / onErrorHandler getter/setter pairs (the override mechanism for stm-confirm / notify / failures — frontend-demo installs toast handlers through it), and registerAttribute (below).

statum.registerAttribute(name, fn, {after, before}) — the extension seam. 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.

Section: statum-document.js

The render walk and the structural/value bindings. Installs the render driver. Attributes:

  • 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: see core section. The element's __stmAction promise (and __stmActionFailed flag) are stashed per trigger for stm-clear-on-success.

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

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