} */
var instancesByHolder = new WeakMap();
/** Loop instance nodes; skipped by the render walk (owned by their holder). */
var instanceNodes = new WeakSet();
/** 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); });
}
/**
* Evaluate an `stm-if` / `stm-else-if` / `stm-else` chain beginning at `el`,
* showing the matching branch and hiding the rest.
*
* Branches are toggled with the `hidden` attribute rather than detached, so
* every branch stays in the DOM and is re-evaluated on each render. (Detaching
* the `stm-if` element would remove it from the render walk, leaving an
* already-attached `stm-else` branch skipped and never refreshed.)
* @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) b.el.removeAttribute('hidden');
else b.el.setAttribute('hidden', '');
});
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-attribute.{name}`: a boolean result toggles the attribute, any
* other defined value sets it, and `undefined` removes it.
*
* For booleans, prefer the element's IDL property when one exists (e.g.
* `checked`, `disabled`, `hidden`) — setting the content attribute is
* unreliable for these once the control is "dirty".
*/
function handleAttribute(el, scope) {
var attrs = el.attributes;
for (var i = 0; i < attrs.length; i++) {
var attr = attrs[i];
if (attr.name.indexOf('stm-attribute.') === 0) {
var name = attr.name.slice('stm-attribute.'.length);
if (!name) continue;
var value = evalExpr(attr.value, scope);
if (value === undefined) {
el.removeAttribute(name);
} else if (typeof value === 'boolean') {
if (name in el && typeof el[name] === 'boolean') el[name] = value;
else if (value) el.setAttribute(name, '');
else el.removeAttribute(name);
} else {
el.setAttribute(name, value === null ? '' : String(value));
}
}
}
}
/**
* Apply `stm-disabled`: when the predicate is truthy, disable the element and
* its descendant form controls; otherwise enable them.
*/
function handleDisabled(el, scope) {
if (!el.hasAttribute('stm-disabled')) return;
setDisabledCascade(el, !!evalExpr(el.getAttribute('stm-disabled'), scope));
}
/** Set the `disabled` attribute on an element and its descendant controls. */
function setDisabledCascade(el, on) {
var nodes = [el].concat(Array.prototype.slice.call(
el.querySelectorAll('button,input,select,textarea,fieldset')));
nodes.forEach(function (n) {
if (on) n.setAttribute('disabled', ''); else n.removeAttribute('disabled');
});
}
/**
* 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.textContent = 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);
handleAttribute(el, scope);
handleDisabled(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.
*
* On un-busy the disabled state is not "restored" to a captured snapshot
* (that would clobber a `stm-disabled` value that `renderAll` applied during
* the response); instead the nodes are re-enabled and `stm-disabled` is
* re-evaluated, which re-disables any that should remain disabled.
*/
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) {
nodes.forEach(function (n) { n.setAttribute('disabled', ''); });
busyClasses.forEach(function (c) { el.classList.add(c); });
} else {
nodes.forEach(function (n) { n.removeAttribute('disabled'); });
busyClasses.forEach(function (c) { el.classList.remove(c); });
reapplyDisabled(el);
}
}
/** Re-apply `stm-disabled` to an element and any descendants that carry it. */
function reapplyDisabled(el) {
var scope = el.__stmScope || stateScope();
if (el.hasAttribute('stm-disabled')) handleDisabled(el, scope);
var scoped = el.querySelectorAll('[stm-disabled]');
Array.prototype.forEach.call(scoped, function (n) {
handleDisabled(n, n.__stmScope || scope);
});
}
/** 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;
// stm-confirm: gate the action behind a confirm() dialog (literal message).
if (el.hasAttribute('stm-confirm') && !window.confirm(el.getAttribute('stm-confirm'))) 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 with the `hidden` attribute. While
* loading, `stm-preloader` elements are shown and `stm-content` elements
* hidden; once ready, the reverse.
*
* To avoid a flash of un-bound template before the script runs, authors may
* pre-set `hidden` directly on `stm-content` in the HTML (e.g.
* ``); the library removes it once ready. The library
* also sets `hidden` itself during loading as a fallback for authors who do
* not pre-set it.
* @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();
})();