stm-markdown — render untrusted markdown as safe HTML (all source HTML
escaped first).stm-h-demotion — demote child header tags by N levels (runs after
stm-html / stm-markdown).form-submit, form-reset) — programmatically submit or
reset a form by ID from a server directive.statum.beforeEvaluate(fn) /
statum.afterEvaluate(fn) JS API.set for stale slots — server auto-refreshes a client's stale
snapshot (when the client's as_at predates the slot's last update).mkpstm — a reusable statum_page() helper so apps
don't write a custom_target per page.model.md, getting-started.md.stm-markdown attribute — js/statum.jsA built-in minimal markdown parser (no external dependencies, since the client is self-contained). The pipeline is:
< → <, etc.) so
untrusted input cannot inject markup.Supported syntax: headings (#–######), bold (**), italic (*), inline
code (`), fenced code blocks (```), links ([text](url)),
unordered lists (-/*), ordered lists (1.), blockquotes (>), horizontal
rules (---), and paragraphs (blank-line separated).
renderMarkdown(string raw) → string html in the client IIFE.In handleTextHtml, after the stm-text / stm-html checks:
if (el.hasAttribute('stm-markdown')) {
var md = evalExpr(el.getAttribute('stm-markdown'), scope);
if (md !== undefined) el.innerHTML = renderMarkdown(md === null ? '' : String(md));
}
Order matters: stm-markdown runs before stm-h-demotion (B) so the
demotion sees the rendered headers.
stm-h-demotion attribute — js/statum.jsstm-h-dementia="1" demotes every <h1>–<h5> child by N levels: <h1> →
<h2>, <h2> → <h3>, etc. <h6> stays <h6> (HTML caps at 6). The attribute
is read from the same element that carries stm-html / stm-markdown, and must
run after those have set innerHTML.
handleHDemotion(el) called from handleTextHtml after the
stm-html / stm-markdown blocks, when the element has stm-h-demotion.el.querySelectorAll('h1,h2,h3,h4,h5,h6'), read the current level from
the tag name, add N (capped at 6), and replace the element with a new one of
the demoted tag (preserving attributes and children via
document.createElement + replaceWith).src/Model/DirectiveDto.vala, js/statum.js, src/DirectiveBuilder.valaTwo new directive types:
{"type":"form-submit","form":"checkout","action":"/pay","method":"POST"}
{"type":"form-reset","form":"search"}
form — the id of the <form> element to target.form-submit optionally carries action (a URI string) and method ("POST"
or "GET") — if present, the client sets form.action and form.method
before calling form.submit().form-reset calls form.reset().Both are terminal (executed after non-terminal directives, alongside
navigate / post).
DirectiveDto.vala: add FormSubmitDirectiveDto (form, optional action,
optional method) and FormResetDirectiveDto (form), each with a mapper +
from_json dispatch.DirectiveBuilder.vala: add
.form_submit(string form_id, string? action = null, string? method = null)
and .form_reset(string form_id).js/statum.js:
applyDirectives: add case 'form-submit' / case 'form-reset' to the
result.terminal collection.executeTerminal: call doFormSubmit(d) / doFormReset(d):
function doFormSubmit(d) {
var form = document.getElementById(d.form);
if (form) {
if (d.action) form.action = d.action;
if (d.method) form.method = d.method;
form.submit();
}
}
function doFormReset(d) {
var form = document.getElementById(d.form);
if (form) form.reset();
}
js/statum.jsstatum.beforeEvaluate(function() {
// Runs before each render cycle; no arguments.
document.body.classList.add('evaluating');
});
statum.afterEvaluate(function() {
// Runs after render + guard evaluation; DOM is up to date.
highlightCodeBlocks();
});
beforeEvaluate callbacks take no arguments and run before render() in
renderAll. They cannot mutate the scope (the scope is built internally from
held-slot state).afterEvaluate callbacks take no arguments and run after render() +
guard evaluation.var beforeHooks = []; var afterHooks = [];.renderAll():
function renderAll() {
if (guardRedirected) return;
beforeHooks.forEach(function(fn) { try { fn(); } catch(e){} });
try { render(document.body, stateScope()); } catch(e) { console.error('[statum] render error', e); }
afterHooks.forEach(function(fn) { try { fn(); } catch(e){} });
evaluateGuards();
}
Public API: window.statum.beforeEvaluate = function(fn) { beforeHooks.push(fn); };
window.statum.afterEvaluate = function(fn) { afterHooks.push(fn); };
set for stale slots — src/HeldSlotResolver.vala, src/StatumDirectives.vala, endpointsWhen the client sends a slot reference (key=K; as_at=T) and the server's cached
slot has current_state.last_touched > T, the client is holding a stale
snapshot. The server automatically includes a fresh set directive (re-signed
frame) for that slot, so the client refreshes without the handler explicitly
emitting one.
This covers the common case: tab A updates a slot (via an action), tab B (still
holding the old snapshot) sends a request — the server sees tab B's stale as_at
and auto-refreshes it.
ResolvedSlots: add Series<string> stale_keys — slot keys whose
as_at predates last_touched.HeldSlotResolver.resolve_reference: after get_slot(key) succeeds,
compare the client's as_at (parsed from the header) with
slot.current_state.last_touched. If as_at < last_touched, add the key to
stale_keys (the slot is still resolved into held — the handler can use it;
the auto-set just refreshes the client).StatumDirectives.to_result: after appending handler directives, sign
each stale key (state_service.sign_slot(key)) and append a set directive.
This needs StateService injected into StatumDirectives or passed as a
parameter. Prefer passing state_service and stale_keys as additional params
to to_result.resolved.stale_keys to to_result.Edge case: if as_at is absent (malformed reference), skip the staleness check
(treat as fresh).
mkpstm — tools/meson or root meson.buildProvide a reusable meson function so apps register pages without writing a
custom_target each time:
# In the app's meson.build:
statum = import('statum') # or: subdir('../tools/statum_meson')
home_src = statum.page('home', namespace: 'MyApp', deps: ['main.html', 'card.html'])
about_src = statum.page('about', namespace: 'MyApp', deps: ['main.html'])
executable('myapp', ['Main.vala', home_src, about_src], ...)
Meson doesn't support import() for project-local modules, so the practical
approach is a helper function in a shared meson snippet:
# statum_pages.meson (included via configure_file or subdir)
statum_mkpstm = ... # the tool target
function statum_page = function(name, namespace: '', extra_deps: []) -> meson object
Meson functions are not first-class, so the realistic implementation is a template custom_target that the app includes:
# In the app's meson.build, after subdir('tools'):
statum_mkpstm = statum_mkpstm # the tool executable target
home_page = custom_target(
input: 'home.html',
output: 'HomePage.vala',
command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'HomePage', '--ns=MyApp', '@INPUT@'],
depend_files: files('main.html', 'card.html')
)
The improvement: ship a statum_page() meson function in a
tools/statum_meson.build that apps subdir() or copy, reducing per-page
boilerplate to one line:
home_page = statum_page('home', 'HomePage', 'MyApp', files('main.html'))
about_page = statum_page('about', 'AboutPage', 'MyApp', files('main.html'))
where statum_page is defined as:
statum_mkpstm = statum_mkpstm # set by tools/meson.build
function statum_page = ... (not supported)
# Instead, a foreach-driven helper:
# statum_page(name, class, ns, deps) → custom_target
Since meson lacks user-defined functions, the cleanest approach is a
configure_file-generated meson snippet or simply documenting the
one-liner custom_target pattern with a shorter invocation. The practical
outcome: a documented statum_page pattern (one custom_target line per page,
not six) plus a depend_files helper.
Create tools/statum_meson.build defining a helper that apps include:
# tools/statum_meson.build
# Usage: include this, then call statum_page(...)
statum_mkpstm_bin = statum_mkpstm
# Can't define functions in meson, so provide a single helper target:
# statum_page('home', 'HomePage', 'MyApp', files('main.html'))
Given meson limitations, the realistic deliverable is a documented
one-liner pattern + a depend_files convention. The tools already accept
-n (class name) and --ns (namespace), so the command is already concise.
src/Model/DirectiveDto.vala)Two new DTOs, mirroring the existing pattern (discriminator, build_field_properties,
get_mapper, from_json dispatch):
/** Submits a form by ID (optionally overriding its action URI and method). Terminal. */
public class FormSubmitDirectiveDto : DirectiveDto {
public string form { get; set; }
public string? action { get; set; } // optional URI override
public string? method { get; set; } // optional "POST" or "GET" override
public override string discriminator { get { return "form-submit"; } }
protected override Properties build_field_properties() throws GLib.Error { ... }
public FormSubmitDirectiveDto() { }
public FormSubmitDirectiveDto.with_form(string form, string? action = null, string? method = null) { ... }
public static PropertyMapper<FormSubmitDirectiveDto> get_mapper() { ... }
}
/** Resets a form by ID. Terminal. */
public class FormResetDirectiveDto : DirectiveDto {
public string form { get; set; }
public override string discriminator { get { return "form-reset"; } }
protected override Properties build_field_properties() throws GLib.Error { ... }
public FormResetDirectiveDto() { }
public FormResetDirectiveDto.with_form(string form) { ... }
public static PropertyMapper<FormResetDirectiveDto> get_mapper() { ... }
}
Register in from_json:
case "form-submit": return FormSubmitDirectiveDto.get_mapper().materialise(json);
case "form-reset": return FormResetDirectiveDto.get_mapper().materialise(json);
src/DirectiveBuilder.vala)/** Emits a terminal `form-submit` directive (optionally overriding action + method). */
public DirectiveBuilder form_submit(string form_id, string? action = null, string? method = null) {
items.add(new Item() { kind = ItemKind.DIRECTIVE,
directive = new Model.FormSubmitDirectiveDto.with_form(form_id, action, method) });
return this;
}
/** Emits a terminal `form-reset` directive. */
public DirectiveBuilder form_reset(string form_id) {
items.add(new Item() { kind = ItemKind.DIRECTIVE,
directive = new Model.FormResetDirectiveDto.with_form(form_id) });
return this;
}
src/HeldSlotResolver.vala, src/StatumDirectives.vala, endpoints)ResolvedSlots — add:
public Series<string> stale_keys { get; private set; default = new Series<string>(); }
HeldSlotResolver.resolve_reference — after get_slot(key) succeeds, compare
the client's as_at with slot.current_state.last_touched. If the client's
as_at predates the slot's last update, add the key to stale_keys:
if (as_at != null && slot.current_state.last_touched != null
&& ((!)as_at).compare(((!)slot.current_state).last_touched) < 0) {
result.stale_keys.add(slot.id);
}
StatumDirectives.to_result — new overload (or extended signature) that
accepts a StateService + stale_keys and appends fresh set directives:
public static HttpResult to_result(
Lot<Model.DirectiveDto> directives,
Lot<string>? invalid_signatures = null,
StateService? state_service = null,
Lot<string>? stale_keys = null) throws GLib.Error {
// ... existing invalid_signatures handling ...
// Auto-refresh stale slots: sign + append set directives.
if (state_service != null && stale_keys != null) {
foreach (var key in stale_keys) {
var frame = state_service.sign_slot(key);
if (frame != null) {
rendered.add(new Model.SetDirectiveDto.with_frame((!)frame));
}
}
}
// ... render JSON ...
}
Entrypoint + Action endpoints — pass the extra args:
return StatumDirectives.to_result(directives,
resolved.invalid_signatures,
state_service,
resolved.stale_keys);
This means StatumDirectives needs access to StateService at the call site
(passed as a parameter, not injected — keeps StatumDirectives stateless).
The endpoints already inject StateService (entrypoint via the handler, action
via the base class).
model.md, getting-started.mdUpdate:
model.md:
stm-markdown, stm-h-demotion attribute descriptions.form-submit, form-reset to the directive list with
descriptions.getting-started.md:
stm-markdown, stm-h-demotion to the attribute
table; add statum.beforeEvaluate / statum.afterEvaluate (no-arg
callbacks) to the public API; add form-submit / form-reset to the
directive builder table.renderMarkdown(raw) — escape-first minimal markdown parser.stm-markdown attribute in handleTextHtml.handleHDemotion(el) — stm-h-demotion (runs after markdown/html).form-submit / form-reset directive handling (applyDirectives
executeTerminal + doFormSubmit / doFormReset).beforeEvaluate / afterEvaluate hooks + public API.FormSubmitDirectiveDto / FormResetDirectiveDto in DirectiveDto.vala
— two new DTO classes: FormSubmitDirectiveDto (form + optional action +
optional method), FormResetDirectiveDto (form), each with discriminator,
build_field_properties, get_mapper, with_form constructor, and
from_json dispatch entries.DirectiveBuilder.form_submit(form_id, action?, method?) /
.form_reset(form_id) in DirectiveBuilder.vala — fluent terminal directive
emitters.ResolvedSlots.stale_keys + HeldSlotResolver.resolve_reference
staleness detection (compare client as_at vs last_touched; add key to
stale_keys when stale; slot still resolves into held).StatumDirectives.to_result extended signature (optional
StateService + stale_keys) — auto-signs stale keys and appends set
directives; EntrypointEndpoint and StatumAction.handle_request pass
state_service + resolved.stale_keys.statum_page meson pattern.model.md (attributes, directives, stale-set).getting-started.md (attributes, hooks, form directives).stm-markdown renders safe HTML (escaped source + formatted output).stm-h-demotion demotes headers after markdown/html.form-submit / form-reset directives manipulate the form.as_at receives a set.