feature-plan.md 17 KB

Feature Plan — Client attributes, form directives, hooks, stale-set, meson generator

Features

  1. stm-markdown — render untrusted markdown as safe HTML (all source HTML escaped first).
  2. stm-h-demotion — demote child header tags by N levels (runs after stm-html / stm-markdown).
  3. Form directives (form-submit, form-reset) — programmatically submit or reset a form by ID from a server directive.
  4. Before/after evaluation hooksstatum.beforeEvaluate(fn) / statum.afterEvaluate(fn) JS API.
  5. Automatic set for stale slots — server auto-refreshes a client's stale snapshot (when the client's as_at predates the slot's last update).
  6. Meson generator for mkpstm — a reusable statum_page() helper so apps don't write a custom_target per page.
  7. Documentation updatesmodel.md, getting-started.md.

A. stm-markdown attribute — js/statum.js

Design

A built-in minimal markdown parser (no external dependencies, since the client is self-contained). The pipeline is:

  1. Escape all HTML entities in the raw string first (<&lt;, etc.) so untrusted input cannot inject markup.
  2. Apply markdown formatting rules that produce safe HTML tags.

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

Implementation

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


B. stm-h-demotion attribute — js/statum.js

Design

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

Implementation

  • Add handleHDemotion(el) called from handleTextHtml after the stm-html / stm-markdown blocks, when the element has stm-h-demotion.
  • Walk 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).

C. Form directives — src/Model/DirectiveDto.vala, js/statum.js, src/DirectiveBuilder.vala

Design

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

Implementation

  • 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();
      }
      

D. Before/after evaluation hooks — js/statum.js

Design

statum.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.
  • Both are called on every render cycle (initial render, entrypoint hydration, set/clear, realtime events).

Implementation

  • Module-level 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); };


E. Automatic set for stale slots — src/HeldSlotResolver.vala, src/StatumDirectives.vala, endpoints

Design

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

Implementation

  • 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.
  • Entrypoint + Action endpoints: pass resolved.stale_keys to to_result.

Edge case: if as_at is absent (malformed reference), skip the staleness check (treat as fresh).


F. Meson generator for mkpstmtools/meson or root meson.build

Design

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

Implementation

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

Server / Vala API changes

New directive DTOs (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);

DirectiveBuilder methods (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;
}

Stale-slot auto-set (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).


G. Documentation updates — model.md, getting-started.md

Update:

  • model.md:
    • HTML API: add stm-markdown, stm-h-demotion attribute descriptions.
    • Directives: add form-submit, form-reset to the directive list with descriptions.
    • Note the automatic stale-set behaviour in the Snapshot / Request Headers sections.
  • getting-started.md:
    • JS API Reference: add 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.
    • HTML Templates: mention the meson page pattern.

Tasks

Client (js/statum.js)

  • JS-1 renderMarkdown(raw) — escape-first minimal markdown parser.
  • JS-2 stm-markdown attribute in handleTextHtml.
  • JS-3 handleHDemotion(el)stm-h-demotion (runs after markdown/html).
  • JS-4 form-submit / form-reset directive handling (applyDirectives
    • executeTerminal + doFormSubmit / doFormReset).
  • JS-5 beforeEvaluate / afterEvaluate hooks + public API.

Server (Vala)

  • V-1 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.
  • V-2 DirectiveBuilder.form_submit(form_id, action?, method?) / .form_reset(form_id) in DirectiveBuilder.vala — fluent terminal directive emitters.
  • V-3 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).
  • V-4 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.

Build

  • M-1 Document / provide the statum_page meson pattern.

Docs

  • D-1 Update model.md (attributes, directives, stale-set).
  • D-2 Update getting-started.md (attributes, hooks, form directives).

Testing

  • T-1 stm-markdown renders safe HTML (escaped source + formatted output).
  • T-2 stm-h-demotion demotes headers after markdown/html.
  • T-3 form-submit / form-reset directives manipulate the form.
  • T-4 Stale-slot auto-set: a request with an old as_at receives a set.