# 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 hooks** — `statum.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 updates** — `model.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 (`<` → `<`, 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: ```js 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 `

`–`

` child by N levels: `

` → `

`, `

` → `

`, etc. `

` stays `
` (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: ```json {"type":"form-submit","form":"checkout","action":"/pay","method":"POST"} {"type":"form-reset","form":"search"} ``` - `form` — the `id` of the `
` 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)`: ```js 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 ```js 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()`: ```js 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 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 `mkpstm` — `tools/meson` or root `meson.build` ### Design Provide a reusable meson function so apps register pages without writing a `custom_target` each time: ```meson # 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: ```meson # 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: ```meson # 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: ```meson 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: ```meson 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: ```meson # 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): ```vala /** 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 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 get_mapper() { ... } } ``` Register in `from_json`: ```vala case "form-submit": return FormSubmitDirectiveDto.get_mapper().materialise(json); case "form-reset": return FormResetDirectiveDto.get_mapper().materialise(json); ``` #### DirectiveBuilder methods (`src/DirectiveBuilder.vala`) ```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: ```vala public Series stale_keys { get; private set; default = new Series(); } ``` **`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`: ```vala 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: ```vala public static HttpResult to_result( Lot directives, Lot? invalid_signatures = null, StateService? state_service = null, Lot? 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: ```vala 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`.