/*! * Statum - client-side state library. * * An HTMX-inspired state layer. The library manages signed "snapshots" of * application state (one per slot type), keeps them fresh via the server's * `transmit_after` / `invalid_after` timestamps, and binds them to the DOM * through `stm-*` attributes. See model.md for the full specification. * * Distribution: a single IIFE that attaches `window.statum` (no dependencies, * no build step). Targets modern evergreen browsers (uses fetch, * BroadcastChannel, queueMicrotask, URLSearchParams, FormData). */ (function () { 'use strict'; /** Content-Type used by every Statum directive response. */ var STATUM_CONTENT_TYPE = 'application/vnd.statum+json'; /** Request/response header carrying one slot's reference (or base64 frame). */ var SLOT_HEADER = 'X-Statum-Slot'; /** Request header carrying an action's opaque `private` blob. */ var PRIVATE_HEADER = 'X-Statum-Private'; /** Session-cookie marker used to detect a fresh browser session. */ var SESSION_COOKIE = '_statum_sdc'; /** BroadcastChannel name used to sync session/device state across tabs. */ var CHANNEL_NAME = 'statum'; /** localStorage / sessionStorage key prefix for persisted frames. */ var STORAGE_PREFIX = 'statum:'; // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- /** * Endpoint URLs. These defaults may be overridden per-page with the * `stm-entrypoint` and `stm-slots` attributes on the `` element. * * @type {{entrypointUrl: string, slotsUrl: string}} */ var config = { entrypointUrl: '/_statum/entrypoint', slotsUrl: '/_statum/slots' }; // --------------------------------------------------------------------------- // State store // --------------------------------------------------------------------------- /** * In-memory store, keyed by slot type. Each value is `{frame, snapshot}` * where `snapshot` is the parsed `frame.content`. This map is the single * source of truth for rendering; persistent backends merely hydrate it. * * @type {Map} */ var store = new Map(); /** Listeners, keyed by type name. @type {Map>} */ var listeners = new Map(); /** @type {BroadcastChannel|null} */ var channel = null; /** Whether {@link init} has already run. */ var initialized = false; // --------------------------------------------------------------------------- // Small utilities // --------------------------------------------------------------------------- /** * Parse an ISO 8601 timestamp to epoch millis, or `undefined`. * @param {string|undefined} s * @returns {number|undefined} */ function parseTime(s) { if (!s) return undefined; var t = new Date(s).getTime(); return isNaN(t) ? undefined : t; } /** * Base64-encode a UTF-8 string (safe for frames containing non-Latin1 data). * @param {string} str * @returns {string} */ function b64encode(str) { var bytes = new TextEncoder().encode(str); var bin = ''; var chunk = 0x8000; for (var i = 0; i < bytes.length; i += chunk) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(bin); } /** Read a cookie value, or `null` if absent. @param {string} name */ function getCookie(name) { var escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1'); var match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)')); return match ? decodeURIComponent(match[1]) : null; } /** Set a session cookie (no expiry => cleared when the browser closes). */ function setCookie(name, value) { document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax'; } // --------------------------------------------------------------------------- // Expression evaluation // --------------------------------------------------------------------------- /** * Compiled-expression cache. Expressions are evaluated as JavaScript against * a scope object (the page's HTML is trusted, per the model). * @type {Map} */ var exprCache = new Map(); /** * Compile (and cache) an expression string into a function of `scope`. * Uses `with` so that arbitrary type names and loop variables are resolved * as bare identifiers. Returns a function that yields `undefined` on any * runtime or compile error. * @param {string} expr * @returns {Function} */ function compileExpr(expr) { var cached = exprCache.get(expr); if (cached !== undefined) return cached; var fn; try { // Function-constructor bodies are non-strict by default, so `with` is // permitted. The inner try/catch turns reference errors into `undefined` // so a missing path simply yields no value. fn = new Function('scope', 'with(scope){try{return (' + expr + ');}catch(e){return undefined;}}'); } catch (e) { fn = function () { return undefined; }; } exprCache.set(expr, fn); return fn; } /** * Evaluate an expression against a scope. * @param {string} expr * @param {object} [scope] * @returns {*} `undefined` if the expression is empty or fails. */ function evalExpr(expr, scope) { if (expr == null || expr === '') return undefined; try { return compileExpr(expr)(scope || {}); } catch (e) { return undefined; } } /** * Build a child scope that inherits the parent (so type bindings remain * visible) and adds one own loop variable. * @param {object} parent * @param {string} name * @param {*} value * @returns {object} */ function childScope(parent, name, value) { var scope = Object.create(parent || null); scope[name] = value; return scope; } // --------------------------------------------------------------------------- // StatumError // --------------------------------------------------------------------------- /** * Error type thrown for HTTP failures and `error` directives. * @param {string} [message] * @param {string} [code] * @param {object} [data] */ function StatumError(message, code, data) { this.name = 'StatumError'; this.message = message || ''; this.code = code || undefined; this.data = data || undefined; } StatumError.prototype = Object.create(Error.prototype); // --------------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------------- /** * Pick the storage backend for a scope. * @param {string} scope * @returns {Storage|null} `null` for in-memory scopes. */ function storageFor(scope) { if (scope === 'device' || scope === 'session') return localStorage; if (scope === 'flow') return sessionStorage; return null; // page / transient } /** * Persist a slot's frame to the backend matching its scope, removing any * stale copy from the other backend first (in case the scope changed). * @param {string} type * @param {{frame: object, snapshot: object}} entry */ function persistEntry(type, entry) { var scope = entry.snapshot.slot && entry.snapshot.slot.scope; removePersisted(type); var storage = storageFor(scope); if (!storage) return; try { storage.setItem(STORAGE_PREFIX + type, JSON.stringify(entry.frame)); } catch (e) { /* storage full or unavailable; remain in-memory only */ } } /** Remove a persisted frame from both backends. @param {string} type */ function removePersisted(type) { try { localStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {} try { sessionStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {} } /** Load every persisted frame from a backend into the in-memory store. */ function loadFrom(storage) { for (var i = 0; i < storage.length; i++) { var key = storage.key(i); if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue; try { var frame = JSON.parse(storage.getItem(key)); var snapshot = JSON.parse(frame.content); var type = snapshot.slot && snapshot.slot.type; if (type && !store.has(type)) store.set(type, { frame: frame, snapshot: snapshot }); } catch (e) { /* corrupt entry: ignore */ } } } /** Hydrate the store from localStorage and sessionStorage. */ function hydrate() { try { loadFrom(localStorage); } catch (e) {} try { loadFrom(sessionStorage); } catch (e) {} } // --------------------------------------------------------------------------- // Session-cookie initialization // --------------------------------------------------------------------------- /** * Detect a fresh browser session using the `_statum_sdc` session cookie. * On a fresh session, clear all `session`-scoped persisted frames, then set * the marker. Session cookies are shared across tabs, so a tab opened * mid-session sees the marker and leaves session data intact. */ function initSession() { if (getCookie(SESSION_COOKIE) !== null) return; clearSessionSlotsFromStorage(); setCookie(SESSION_COOKIE, '1'); } /** Remove all `session`-scoped frames from localStorage. */ function clearSessionSlotsFromStorage() { var toRemove = []; for (var i = 0; i < localStorage.length; i++) { var key = localStorage.key(i); if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue; try { var frame = JSON.parse(localStorage.getItem(key)); var snapshot = JSON.parse(frame.content); if (snapshot.slot && snapshot.slot.scope === 'session') toRemove.push(key); } catch (e) {} } for (var j = 0; j < toRemove.length; j++) localStorage.removeItem(toRemove[j]); } // --------------------------------------------------------------------------- // Listeners & cross-tab broadcast // --------------------------------------------------------------------------- /** * Fire all listeners for a type. * @param {string} type * @param {object|null} frame * @param {object|null} snapshot */ function notify(type, frame, snapshot) { var set = listeners.get(type); if (!set) return; var event = { type: type, frame: frame, snapshot: snapshot }; set.forEach(function (cb) { try { cb(event); } catch (e) { console.error('[statum] listener error', e); } }); } /** * Broadcast a state change to other tabs. Only `session`/`device` scopes are * shared (flow/page/transient are per-tab). * @param {object} message * @param {string} [scope] */ function broadcast(message, scope) { if (!channel) return; if (scope !== 'session' && scope !== 'device') return; try { channel.postMessage(message); } catch (e) {} } /** Open the BroadcastChannel and wire inbound messages. */ function setupChannel() { if (typeof BroadcastChannel === 'undefined') return; try { channel = new BroadcastChannel(CHANNEL_NAME); } catch (e) { channel = null; return; } channel.onmessage = function (ev) { var msg = ev.data; if (!msg || msg.kind !== 'set' && msg.kind !== 'clear') return; if (msg.kind === 'set' && msg.frame) { try { var snapshot = JSON.parse(msg.frame.content); var type = snapshot.slot && snapshot.slot.type; if (type) { var entry = { frame: msg.frame, snapshot: snapshot }; store.set(type, entry); persistEntry(type, entry); notify(type, msg.frame, snapshot); renderAll(); } } catch (e) { /* ignore malformed */ } } else if (msg.kind === 'clear' && msg.type) { clearType(msg.type, { broadcast: false }); renderAll(); } }; } // --------------------------------------------------------------------------- // State mutation // --------------------------------------------------------------------------- /** * Remove a type from the store, un-persist it, fire listeners, and * (optionally) broadcast the clear to other tabs. * @param {string} type * @param {{broadcast?: boolean}} [opts] */ function clearType(type, opts) { var entry = store.get(type); if (!entry) return; var scope = entry.snapshot.slot && entry.snapshot.slot.scope; store.delete(type); removePersisted(type); notify(type, null, null); if (opts && opts.broadcast) broadcast({ kind: 'clear', type: type }, scope); } /** * Apply a `set` directive: store/refresh a slot's snapshot. * @param {object} directive `{ snapshot_frame }` */ function applySet(directive) { var frame = directive.snapshot_frame; if (!frame || !frame.content) return; var snapshot; try { snapshot = JSON.parse(frame.content); } catch (e) { return; } var type = snapshot.slot && snapshot.slot.type; if (!type) return; var entry = { frame: frame, snapshot: snapshot }; store.set(type, entry); persistEntry(type, entry); notify(type, frame, snapshot); var scope = snapshot.slot && snapshot.slot.scope; broadcast({ kind: 'set', frame: frame }, scope); } /** * Apply a `clear` directive. The directive carries a slot key, so the type * holding that key is resolved and removed. * @param {object} directive `{ key }` */ function applyClear(directive) { var key = directive.key; var typeToRemove = null; store.forEach(function (entry, type) { if (entry.snapshot.slot && entry.snapshot.slot.key === key) typeToRemove = type; }); if (typeToRemove) clearType(typeToRemove, { broadcast: true }); } // --------------------------------------------------------------------------- // Snapshot freshness & request headers // --------------------------------------------------------------------------- /** * Categorize a held snapshot relative to the current time. * @returns {'silent'|'always'|'invalid'|'stale'|'fresh'} * - `silent`: unidirectional (`transmit_after` undefined) - never sent. * - `always`: `transmit_after === as_at` - send the base64 frame each time. * - `invalid`: past `invalid_after` - drop and refetch. * - `stale`: past `transmit_after` but not yet invalid - pre-flight first. * - `fresh`: still cached server-side - send the `as_at` reference. */ function entryCategory(entry) { var snap = entry.snapshot; var transmit = snap.transmit_after; if (transmit === undefined || transmit === null) return 'silent'; if (transmit === snap.as_at) return 'always'; var now = Date.now(); var invalidAt = parseTime(snap.invalid_after); var transmitAt = parseTime(transmit); if (invalidAt !== undefined && now >= invalidAt) return 'invalid'; if (transmitAt !== undefined && now >= transmitAt) return 'stale'; return 'fresh'; } /** * Drop locally-invalid snapshots and pre-flight any stale ones. Pre-flighting * posts the stale frames to the slot endpoint and applies the returned `set` * directives, refreshing their `as_at` values before the real request runs. * * Single-flight: concurrent callers share one in-flight preparation. * @returns {Promise} */ var preparePromise = null; function prepareSlots() { if (preparePromise) return preparePromise; preparePromise = doPrepareSlots().then(function () { preparePromise = null; }, function () { preparePromise = null; }); return preparePromise; } async function doPrepareSlots() { var stale = []; store.forEach(function (entry, type) { var cat = entryCategory(entry); if (cat === 'invalid') { clearType(type, { broadcast: false }); } else if (cat === 'stale') { stale.push(entry.frame); } }); if (stale.length) { var directives = await postSlots(stale); applyDirectives(directives, { originElement: null }); } } /** * Build the `X-Statum-Slot` header values for every held slot, using the * freshness rules. Assumes {@link prepareSlots} has just run. * @returns {string[]} */ function buildSlotHeaders() { var headers = []; store.forEach(function (entry) { var snap = entry.snapshot; var cat = entryCategory(entry); if (cat === 'silent' || cat === 'invalid') return; if (cat === 'always') { headers.push(b64encode(JSON.stringify(entry.frame))); } else { headers.push('key=' + snap.slot.key + '; as_at=' + snap.as_at); } }); return headers; } // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- /** True if a response is a Statum directive response. */ function isStatum(res) { return (res.headers.get('Content-Type') || '').indexOf(STATUM_CONTENT_TYPE) !== -1; } /** * POST an array of frames to the slot endpoint (the pre-flight target) and * return its directive array. This request intentionally carries no slot * headers (it is itself the refresh). * @param {object[]} frames * @returns {Promise} */ async function postSlots(frames) { var headers = new Headers(); headers.set('Accept', STATUM_CONTENT_TYPE); headers.set('Content-Type', 'application/json'); var res = await fetch(config.slotsUrl, { method: 'POST', headers: headers, body: JSON.stringify(frames), credentials: 'same-origin' }); if (!isStatum(res)) return []; try { return await res.json(); } catch (e) { return []; } } /** * Apply the directives from a response. Set/clear run first, then `render`, * then terminal navigation (`navigate`, then `post`). Returns any error * directive encountered without throwing, so callers can decide. * @param {object[]} directives * @param {{originElement?: HTMLElement}} [ctx] * @returns {{error?: StatumError}} */ function applyDirectives(directives, ctx) { var result = { error: undefined }; var list = Array.isArray(directives) ? directives : []; var navigateDirective = null; var postDirective = null; for (var i = 0; i < list.length; i++) { var d = list[i]; if (!d || typeof d !== 'object') continue; switch (d.type) { case 'set': applySet(d); break; case 'clear': applyClear(d); break; case 'error': result.error = new StatumError(d.message, d.code, d.data); if (ctx && ctx.originElement) applyErrorClasses(ctx.originElement, true); break; case 'navigate': navigateDirective = d; break; case 'post': postDirective = d; break; } } renderAll(); if (navigateDirective) doNavigate(navigateDirective); if (postDirective) doPost(postDirective); return result; } /** Execute a `navigate` directive as a full page load. */ function doNavigate(d) { if (d.uri) window.location.href = d.uri; } /** Execute a `post` directive as a real (navigating) form POST. */ function doPost(d) { if (!d.uri) return; var form = document.createElement('form'); form.method = 'POST'; form.action = d.uri; form.style.display = 'none'; var data = d.data || {}; Object.keys(data).forEach(function (name) { var input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = data[name] == null ? '' : String(data[name]); form.appendChild(input); }); document.body.appendChild(form); form.submit(); } /** * Handle a fetch response: if it is a Statum response, apply its directives; * otherwise throw on HTTP errors and no-op on other successful responses. * @param {Response} res * @param {{originElement?: HTMLElement}} [ctx] */ async function handleResponse(res, ctx) { if (!isStatum(res)) { if (!res.ok) throw new StatumError('HTTP ' + res.status); return; } var directives; try { directives = await res.json(); } catch (e) { directives = []; } var result = applyDirectives(directives, ctx); if (result.error) throw result.error; } // --------------------------------------------------------------------------- // Entrypoint & action requests // --------------------------------------------------------------------------- /** * Fetch the entrypoint for the current URL, applying its directives. Carries * slot headers (and pre-flights) like any other request. * @returns {Promise} */ async function loadEntrypoint() { await prepareSlots(); var headers = new Headers(); headers.set('Accept', STATUM_CONTENT_TYPE); buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); }); var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href); var res = await fetch(url, { headers: headers, credentials: 'same-origin' }); await handleResponse(res, { originElement: null }); } /** * Perform an action request. * * @param {object} action `{ uri, method, private }` * @param {object} [data] Key/value parameters. Encoded as query string for * GET/HEAD, otherwise as `application/x-www-form-urlencoded`. * @param {HTMLElement} [originElement] Element that triggered the action, so * that `stm-on-error` classes can be applied on an `error` directive. * @returns {Promise} Resolves once the request completes and its * directives have run; rejects on HTTP failure or an `error` directive. */ async function performAction(action, data, originElement) { await prepareSlots(); var headers = new Headers(); headers.set('Accept', STATUM_CONTENT_TYPE); buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); }); if (action.private) headers.set(PRIVATE_HEADER, action.private); var method = (action.method || 'GET').toUpperCase(); var params = data || {}; var init = { method: method, headers: headers, credentials: 'same-origin' }; var url = action.uri; if (method === 'GET' || method === 'HEAD') { var qs = new URLSearchParams(params).toString(); if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs; } else { init.body = new URLSearchParams(params).toString(); headers.set('Content-Type', 'application/x-www-form-urlencoded'); } var res = await fetch(url, init); await handleResponse(res, { originElement: originElement }); } // --------------------------------------------------------------------------- // Rendering: loops, conditionals, bindings // --------------------------------------------------------------------------- /** Templates captured from loop holders. @type {WeakMap} */ var templates = new WeakMap(); /** Instance nodes managed by a loop holder. @type {WeakMap} */ var instancesByHolder = new WeakMap(); /** Loop instance nodes; skipped by the render walk (owned by their holder). */ var instanceNodes = new WeakSet(); /** If-chain anchors for detached branches. @type {WeakMap} */ var ifAnchors = new WeakMap(); /** Elements whose `stm-action` has been wired, to avoid double-binding. */ var wired = new WeakSet(); /** * Find a `stm-for-{name}-in` attribute on an element. * @returns {{varName: string, expr: string}|null} */ function getStmFor(el) { var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { var match = attrs[i].name.match(/^stm-for-(.+)-in$/); if (match) return { varName: match[1], expr: attrs[i].value }; } return null; } /** Remove any `stm-for-*` and `stm-key` attributes from an element. */ function stripLoopAttrs(el) { var toRemove = []; for (var i = 0; i < el.attributes.length; i++) { var name = el.attributes[i].name; if (/^stm-for-(.+)-in$/.test(name) || name === 'stm-key') toRemove.push(name); } toRemove.forEach(function (n) { el.removeAttribute(n); }); } /** Ensure an element is in the DOM (reattaching at its remembered anchor). */ function ensureAttached(el) { var anchor = ifAnchors.get(el); if (anchor && anchor.parentNode) { anchor.parentNode.insertBefore(el, anchor); anchor.parentNode.removeChild(anchor); ifAnchors.delete(el); } } /** Detach an element, remembering its position with a comment anchor. */ function ensureDetached(el) { if (!el.parentNode) return; if (!ifAnchors.has(el)) { var anchor = document.createComment('#stm-if'); el.parentNode.insertBefore(anchor, el); ifAnchors.set(el, anchor); } el.parentNode.removeChild(el); } /** * Evaluate an `stm-if` / `stm-else-if` / `stm-else` chain beginning at `el`, * attaching the matching branch and detaching the rest. * @returns {Element|null} The active branch element (or `null`). */ function handleIfChain(el, scope) { var branches = [{ el: el, expr: el.getAttribute('stm-if') }]; var cur = el.nextElementSibling; while (cur) { if (cur.hasAttribute('stm-else-if')) { branches.push({ el: cur, expr: cur.getAttribute('stm-else-if') }); cur = cur.nextElementSibling; } else if (cur.hasAttribute('stm-else')) { branches.push({ el: cur, expr: null }); break; } else { break; } } var activeEl = null; for (var i = 0; i < branches.length; i++) { var b = branches[i]; if (b.expr === null || !!evalExpr(b.expr, scope)) { activeEl = b.el; break; } } branches.forEach(function (b) { if (b.el === activeEl) ensureAttached(b.el); else ensureDetached(b.el); }); return activeEl; } /** Toggle `stm-class.{name}` classes from their predicate expressions. */ function handleClass(el, scope) { var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { var attr = attrs[i]; if (attr.name.indexOf('stm-class.') === 0) { var cls = attr.name.slice('stm-class.'.length); if (cls && !!evalExpr(attr.value, scope)) el.classList.add(cls); else el.classList.remove(cls); } } } /** * Bind `stm-text` / `stm-html`. Per the "leave original DOM content" policy, * an unresolved value (`undefined`) leaves the element untouched; otherwise * the value is stringified (`null` becomes the empty string). */ function handleTextHtml(el, scope) { if (el.hasAttribute('stm-text')) { var text = evalExpr(el.getAttribute('stm-text'), scope); if (text !== undefined) el.innerText = text === null ? '' : String(text); } if (el.hasAttribute('stm-html')) { var html = evalExpr(el.getAttribute('stm-html'), scope); if (html !== undefined) el.innerHTML = html === null ? '' : String(html); } } /** * Expand a loop holder: reconcile its instances against the current array. * Keyed loops (`stm-key`) reuse DOM nodes for unchanged keys to preserve * focus/selection/input state; unkeyed loops rebuild wholesale. */ function handleLoop(el, scope) { var info = getStmFor(el); if (!info) return; var template = templates.get(el); if (!template) { template = el.cloneNode(true); stripLoopAttrs(template); templates.set(el, template); } var raw = evalExpr(info.expr, scope); if (raw === undefined) { // No value for the source path yet: clear instances and restore the // server-rendered holder content (progressive enhancement). var prev = instancesByHolder.get(el) || []; prev.forEach(function (n) { n.remove(); }); instancesByHolder.set(el, []); if (el.getAttribute('data-stm-active') === '1') { el.removeAttribute('data-stm-active'); el.removeAttribute('hidden'); } return; } var arr = Array.isArray(raw) ? raw : []; // Activate: hide the holder so only rendered instances are visible. if (el.getAttribute('data-stm-active') !== '1') { el.setAttribute('data-stm-active', '1'); el.setAttribute('hidden', ''); } var keyExpr = el.getAttribute('stm-key'); var next = []; if (keyExpr) { var oldByKey = new Map(); (instancesByHolder.get(el) || []).forEach(function (n) { if (n.__stmKey !== undefined) oldByKey.set(n.__stmKey, n); }); var used = new Set(); arr.forEach(function (item) { var child = childScope(scope, info.varName, item); var key = evalExpr(keyExpr, child); var node = (key !== undefined && !used.has(key)) ? oldByKey.get(key) : undefined; if (node) { used.add(key); processElement(node, child); } else { node = template.cloneNode(true); node.__stmKey = key; instanceNodes.add(node); processElement(node, child); } next.push(node); }); oldByKey.forEach(function (n, key) { if (!used.has(key)) n.remove(); }); } else { (instancesByHolder.get(el) || []).forEach(function (n) { n.remove(); }); arr.forEach(function (item) { var child = childScope(scope, info.varName, item); var node = template.cloneNode(true); instanceNodes.add(node); processElement(node, child); next.push(node); }); } // Place instances in order, immediately after the holder. var ref = el; next.forEach(function (node) { if (node !== ref.nextSibling) ref.parentNode.insertBefore(node, ref.nextSibling); ref = node; }); instancesByHolder.set(el, next); } /** * Process one element's bindings (and recurse). Loop holders are handled by * {@link handleLoop}; `stm-else-if` / `stm-else` are owned by their `stm-if`. */ function processElement(el, scope) { if (getStmFor(el)) { handleLoop(el, scope); return; } if (el.hasAttribute('stm-else-if') || el.hasAttribute('stm-else')) return; if (el.hasAttribute('stm-if')) { var active = handleIfChain(el, scope); if (active) processInner(active, scope); return; } processInner(el, scope); } /** Bind the non-control-flow attributes and recurse into children. */ function processInner(el, scope) { el.__stmScope = scope; // latest scope, used when an action fires handleClass(el, scope); handleTextHtml(el, scope); wireAction(el); render(el, scope); } /** * Walk an element's children, processing each. Loop instance nodes are * skipped here (they are owned and processed by their holder). */ function render(root, scope) { if (!root || !root.children) return; // Snapshot the children: processing mutates the DOM (detaching branches, // inserting loop instances) and a live HTMLCollection would shift indices. var children = Array.prototype.slice.call(root.children); for (var i = 0; i < children.length; i++) { var el = children[i]; if (instanceNodes.has(el)) continue; processElement(el, scope); } } /** Re-render the whole document against the current state. */ function renderAll() { try { render(document.body, stateScope()); } catch (e) { console.error('[statum] render error', e); } } // --------------------------------------------------------------------------- // HTML API: actions // --------------------------------------------------------------------------- /** * Collect data for an action trigger: `stm-data-{key}` attributes plus, for a * form, its named inputs. * @param {HTMLElement} el * @returns {object} */ function collectData(el) { var data = {}; var attrs = el.attributes; for (var i = 0; i < attrs.length; i++) { var name = attrs[i].name; if (name.indexOf('stm-data-') === 0) data[name.slice('stm-data-'.length)] = attrs[i].value; } if (el.tagName === 'FORM') { var fd = new FormData(el); fd.forEach(function (value, key) { data[key] = value instanceof File ? value.name : String(value); }); } return data; } /** * Toggle the busy state of an action element: disables the element and its * descendant form controls and applies `stm-busy-class` classes while busy, * restoring prior state when not. */ function setBusyState(el, busy) { var nodes = [el].concat(Array.prototype.slice.call( el.querySelectorAll('button,input,select,textarea,fieldset'))); var busyClasses = (el.getAttribute('stm-busy-class') || '').split(/\s+/).filter(Boolean); if (busy) { el.__stmDisabled = []; nodes.forEach(function (n) { if (!('disabled' in n)) return; el.__stmDisabled.push([n, n.disabled]); n.disabled = true; }); busyClasses.forEach(function (c) { el.classList.add(c); }); } else { (el.__stmDisabled || []).forEach(function (pair) { pair[0].disabled = pair[1]; }); el.__stmDisabled = []; busyClasses.forEach(function (c) { el.classList.remove(c); }); } } /** Apply or remove `stm-on-error` classes on an action element. */ function applyErrorClasses(el, on) { if (!el || !el.hasAttribute || !el.hasAttribute('stm-on-error')) return; var classes = (el.getAttribute('stm-on-error') || '').split(/\s+/).filter(Boolean); classes.forEach(function (c) { on ? el.classList.add(c) : el.classList.remove(c); }); } /** * Wire an element's primary action once. The listener resolves the action * object against the element's latest render scope, so loop instances bind to * their own item. */ function wireAction(el) { if (!el.hasAttribute('stm-action') || wired.has(el)) return; wired.add(el); var eventName = el.tagName === 'FORM' ? 'submit' : 'click'; el.addEventListener(eventName, function (event) { event.preventDefault(); runElementAction(el); }); } /** * Resolve and perform the action attached to an element, managing busy and * error-state classes around the request. * @param {HTMLElement} el */ async function runElementAction(el) { var scope = el.__stmScope || stateScope(); var action = evalExpr(el.getAttribute('stm-action'), scope); if (!action || typeof action.uri !== 'string') return; applyErrorClasses(el, false); // clear error state on a new attempt var data = collectData(el); setBusyState(el, true); try { await performAction(action, data, el); } catch (err) { // stm-on-error is applied by the directive path for `error` directives; // ensure it is also applied for other failures (network, HTTP). applyErrorClasses(el, true); } finally { setBusyState(el, false); } } // --------------------------------------------------------------------------- // Boot visibility (stm-preloader / stm-content) // --------------------------------------------------------------------------- /** * Toggle pre/post-entrypoint visibility. While loading, `stm-preloader` * elements are shown and `stm-content` elements hidden; once ready, the * reverse. Uses the `hidden` attribute so it does not fight inline styles. * @param {boolean} ready */ function setBootState(ready) { var preloaders = document.querySelectorAll('[stm-preloader]'); var contents = document.querySelectorAll('[stm-content]'); for (var i = 0; i < preloaders.length; i++) { ready ? preloaders[i].setAttribute('hidden', '') : preloaders[i].removeAttribute('hidden'); } for (var j = 0; j < contents.length; j++) { ready ? contents[j].removeAttribute('hidden') : contents[j].setAttribute('hidden', ''); } } // --------------------------------------------------------------------------- // Public state accessors // --------------------------------------------------------------------------- /** Build the scope used for expression evaluation: `{ typeName: public }`. */ function stateScope() { var scope = {}; store.forEach(function (entry, type) { scope[type] = entry.snapshot.public; }); return scope; } /** Read a type's `public` data (what the attributes bind to). */ function read(type) { var entry = store.get(type); return entry ? entry.snapshot.public : undefined; } /** Read a type's full snapshot object. */ function getSnapshot(type) { var entry = store.get(type); return entry ? entry.snapshot : undefined; } /** Read the frame wrapping a type's snapshot. */ function getFrame(type) { var entry = store.get(type); return entry ? entry.frame : undefined; } /** Build an object of `{ typeName: public }` for every held type. */ function state() { var result = {}; store.forEach(function (entry, type) { result[type] = entry.snapshot.public; }); return result; } /** * Clear state. With a type argument, clears that type only; without, clears * all held state. * @param {string} [type] */ function clear(type) { if (type === undefined) { Array.from(store.keys()).forEach(function (t) { clearType(t, { broadcast: true }); }); } else { clearType(type, { broadcast: true }); } renderAll(); } /** * Perform an action. `action` may be an action object or a string path that * resolves to an action object within the current state. * @param {object|string} action * @param {object} [data] * @returns {Promise} */ function act(action, data) { var resolved = action; if (typeof action === 'string') resolved = evalExpr(action, stateScope()); if (!resolved || typeof resolved.uri !== 'string') { return Promise.reject(new StatumError('Invalid action')); } return performAction(resolved, data, null); } /** Attach a state listener for a type. */ function addStateListener(type, callback) { if (typeof callback !== 'function') return; if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(callback); } /** Remove a previously-attached state listener. */ function removeStateListener(type, callback) { var set = listeners.get(type); if (!set) return; set.delete(callback); if (set.size === 0) listeners.delete(type); } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- /** * Initialize Statum: read URL overrides, run the session-cookie check, * hydrate persisted state, open the cross-tab channel, fetch the entrypoint, * and render. Safe to call multiple times. * @returns {Promise} */ async function init() { if (initialized) return; initialized = true; var body = document.body; if (body) { if (body.hasAttribute('stm-entrypoint')) config.entrypointUrl = body.getAttribute('stm-entrypoint'); if (body.hasAttribute('stm-slots')) config.slotsUrl = body.getAttribute('stm-slots'); } try { initSession(); } catch (e) {} try { hydrate(); } catch (e) {} setupChannel(); setBootState(false); renderAll(); // bind against any persisted/hydrated state immediately try { await loadEntrypoint(); } catch (err) { console.error('[statum] entrypoint load failed', err); } finally { setBootState(true); } } function autoInit() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** @namespace statum */ window.statum = { /** Library version. */ version: '0.1.0', /** Mutable endpoint configuration. */ config: config, /** (Re)initialize the library. Usually runs automatically on load. */ init: init, /** Read a type's `public` data. */ read: read, /** Read a type's full snapshot object. */ getSnapshot: getSnapshot, /** Read the frame wrapping a type's snapshot. */ getFrame: getFrame, /** Build `{ typeName: public }` for every held type. */ state: state, /** Clear one type (`clear("x")`) or all state (`clear()`). */ clear: clear, /** Perform an action object or named action. */ act: act, /** Attach a state-changed listener for a type. */ addStateListener: addStateListener, /** Remove a previously-attached listener. */ removeStateListener: removeStateListener }; autoInit(); })();