/** * statum-binding.js — client-side variables for Statum pages. * * Load position: after statum-core.js and statum-document.js. This module * and statum-markdown.js are order-independent and need not co-exist. * * ES5-style, zero dependencies, guarded IIFE. Attributes: * * stm-global-{name}="expr" page-wide variable, usable in any * expression anywhere on the page * stm-global-{name}-from-{attribute} page-wide variable sourced from an * element attribute; changes to that * attribute trigger a redraw * stm-local-{name}="expr" subtree-scoped variable (element + * descendants); replaces the removed * stm-def-{name}-as * stm-local-{name}-from-{attribute} subtree-scoped attribute-sourced * variable; changes trigger a redraw * stm-declare-{name} declares {name} for a subtree so a * descendant can bind it and SIBLINGS * of that binder can read it * * Semantics (operator-confirmed): * - LAZY-CACHED: a binding's expression evaluates at most once per render * pass, only when something reads the variable; the result is cached for * the pass and the cache resets each pass. * - CYCLES are an error, not a crash: re-entrant evaluation of the SAME * binding logs '[statum] variable cycle: a → b → a' (variable names) and * the variable reads as undefined for that pass. Distinct bindings that * shadow a name are not cycles (an inner `x` may reference an outer `x`). * - `from-value` / `from-checked` on input/select/textarea read the live * PROPERTY (not the attribute): an input/change listener updates the * variable and calls renderAll() directly, per keystroke. Every other * attribute name, and every non-form-control element, uses a * MutationObserver. * - Redraw guard: an observed attribute change only redraws when the * variable's derived value actually changed — the walk itself writes * attributes (stm-attribute.*, hidden, data-stm-active) and a naive * observer would loop forever. * - A stm-global is visible page-wide on the FIRST pass it is declared in, * even at elements that precede its declaring element in document order * (the pass-start sweep exposes lazy getters; each reader resolves the * binding on demand, at most once per pass). * - Shadowing: a variable shadows slot types and outer variables with the * same name inside its scope. Duplicate stm-global-{name}: last in * document order wins, one console.warn per pass. */ (function () { if (!window.statum || !window.statum._bridge) { console.error('[statum] statum-binding.js requires statum-core.js'); return; } if (!window.statum.registerAttribute) { console.error('[statum] statum-binding.js requires statum-document.js'); return; } var bridge = window.statum._bridge; var evalExpr = bridge.evalExpr; var renderAll = bridge.renderAll; // Scope-chain marker for declared slots. Expressions evaluate through the // evalScope proxy, whose `with` semantics only surface string keys, so // this key is invisible to page code. var DECLARED = '__stmDeclared'; /** Per-pass cache: binding key -> value. Reset at every pass start. */ var passCache = {}; /** Evaluation stack for cycle detection: {key, name}, innermost last. */ var evalStack = []; /** * Per-(element, source) plumbing registries: which property listeners / * attribute observers are already attached, so several bindings may share * one element without one starving the other (review finding V1). * @type {WeakMap} element -> { source: true } */ var propBound = new WeakMap(); var observed = new WeakMap(); /** Variables bound to each (element, property): element -> { name: prop }. */ var propNames = new WeakMap(); /** Live property values: element -> { varName: value }. @type {WeakMap} */ var liveProps = new WeakMap(); /** Stable per-element identity for cache keys. @type {WeakMap} */ var elIds = new WeakMap(); var elSeq = 0; function elId(el) { var id = elIds.get(el); if (id === undefined) { id = ++elSeq; elIds.set(el, id); } return id; } /** * Resolve one binding for this pass: evaluate at most once, cache, and * detect re-entrant evaluation of the SAME binding (a cycle). * @param {string} key unique per binding site (stable across passes) * @param {string} name variable name, for diagnostics * @param {Function} compute () => value */ function resolve(key, name, compute) { if (Object.prototype.hasOwnProperty.call(passCache, key)) { return passCache[key]; } for (var i = 0; i < evalStack.length; i++) { if (evalStack[i].key === key) { var chain = []; for (var j = i; j < evalStack.length; j++) chain.push(evalStack[j].name); console.error('[statum] variable cycle: ' + chain.concat(name).join(' → ')); return undefined; } } evalStack.push({ key: key, name: name }); try { var value = compute(); passCache[key] = value; return value; } finally { evalStack.pop(); } } /** * Parse a binding attribute name into {kind, name, source, attr}; * source is the attribute/property name for -from- forms, else null. * @param {string} attr */ function parseBinding(attr) { var m; if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-(.+)$/))) return { kind: 'global', name: m[1], source: m[2], attr: attr }; if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)$/))) return { kind: 'global', name: m[1], source: null, attr: attr }; if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-(.+)$/))) return { kind: 'local', name: m[1], source: m[2], attr: attr }; if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)$/))) return { kind: 'local', name: m[1], source: null, attr: attr }; return null; } /** Live-property form: from-value/from-checked on a form control. */ function isPropSource(el, source) { if (source !== 'value' && source !== 'checked') return false; var tag = el.tagName; return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'; } /** * Attach the redraw plumbing for one -from- binding. The listener/observer * is attached ONCE per (element, source); the VARIABLE registration * (propNames + seed) happens for every binding — several variables may * bind the same source, and each must see updates (review finding V1). */ function bindSource(el, binding) { var propKey = binding.source; if (isPropSource(el, propKey)) { // Register this variable against the property (always). var names = propNames.get(el) || {}; names[binding.name] = propKey; propNames.set(el, names); var seed = liveProps.get(el) || {}; if (!Object.prototype.hasOwnProperty.call(seed, binding.name)) { seed[binding.name] = el[propKey]; liveProps.set(el, seed); } // Attach the shared listener once per (element, property). var bound = propBound.get(el); if (bound && bound[propKey]) return; if (!bound) bound = {}; bound[propKey] = true; propBound.set(el, bound); var onChange = function () { var n = propNames.get(el); if (!n) return; var changed = false; var store = liveProps.get(el) || {}; for (var name in n) { var key = n[name]; if (store[name] !== el[key]) { store[name] = el[key]; changed = true; } } if (changed) liveProps.set(el, store); if (changed) renderAll(); }; el.addEventListener('input', onChange); el.addEventListener('change', onChange); return; } var seen = observed.get(el); if (seen && seen[propKey]) return; if (typeof MutationObserver !== 'function') return; if (!seen) seen = {}; seen[propKey] = true; observed.set(el, seen); var last = el.getAttribute(propKey); new MutationObserver(function () { var now = el.getAttribute(propKey); if (now === last) return; // same-value write: never redraw last = now; renderAll(); }).observe(el, { attributes: true, attributeFilter: [propKey] }); } /** * Build one binding's value computation. Expression forms evaluate * against the PARENT scope (a binding cannot reference itself or a * same-element sibling, matching the stm-def semantics this replaces). * -from- forms read their live source (attaching plumbing on first use). */ function makeCompute(el, binding, parentScope) { if (binding.source === null) { return function () { return evalExpr(el.getAttribute(binding.attr), parentScope); }; } return function () { bindSource(el, binding); if (isPropSource(el, binding.source)) { var store = liveProps.get(el); if (store && Object.prototype.hasOwnProperty.call(store, binding.name)) { return store[binding.name]; } } return el.getAttribute(binding.source); }; } /** * Expose a lazily-resolved variable on a scope. `slot`, when present, is * a declared slot (see buildScope): resolution goes through the slot so * the NEWEST binder in the subtree supplies the value, and the cache key * is the SLOT's (one evaluation per pass no matter which getter is read). */ function defineVar(scope, name, key, compute, slot) { if (slot) slot.compute = compute; Object.defineProperty(scope, name, { configurable: true, enumerable: true, get: function () { if (slot) return resolve(key, name, slot.compute); return resolve(key, name, compute); }, set: function (v) { // expressions never assign; escape hatch only Object.defineProperty(scope, name, { configurable: true, writable: true, value: v }); } }); } /** * Walk UP a scope chain for a declared slot named `name`. * @returns {{key: string, compute: Function}|null} */ function findDeclared(scope, name) { var s = scope; while (s) { if (Object.prototype.hasOwnProperty.call(s, DECLARED) && s[DECLARED][name]) { return s[DECLARED][name]; } s = Object.getPrototypeOf(s); } return null; } /** * Scope construction seam (document's walk calls this for every element; * core defaults to identity). Elements carrying stm-local-* or * stm-declare-* get a child scope with lazy getters; others pass through * unchanged. */ function buildScope(el, scope) { var attrs = el.attributes; var child = null; var declaredHere = null; for (var i = 0; i < attrs.length; i++) { var attrName = attrs[i].name; var binding = parseBinding(attrName); if (binding && binding.kind === 'local') { if (!child) child = Object.create(scope || null); // If an ancestor declared this name, bind into its slot so the // declarer's whole subtree (including siblings of this element) // reads this binding. var slot = findDeclared(child, binding.name); var key = slot ? slot.key : 'local:' + binding.name + ':' + elId(el) + ':' + attrName; defineVar(child, binding.name, key, makeCompute(el, binding, scope), slot); } else if (attrName.indexOf('stm-declare-') === 0) { var dName = attrName.slice('stm-declare-'.length); if (!dName) continue; if (!child) child = Object.create(scope || null); if (!declaredHere) { declaredHere = {}; Object.defineProperty(child, DECLARED, { value: declaredHere }); } if (!declaredHere[dName]) { var dSlot = { key: 'declare:' + dName + ':' + elId(el), compute: function () { return undefined; } }; declaredHere[dName] = dSlot; defineVar(child, dName, dSlot.key, dSlot.compute, dSlot); } } } return child || scope; } /** * Pass start (document's walk driver calls this with the root scope): * reset the per-pass cache and expose every stm-global-* on the root * scope so they are visible page-wide. Later declarations overwrite * earlier ones (getters redefine); duplicates warn once per pass. */ function passStart(rootScope) { passCache = {}; var seenByName = {}; var els = document.querySelectorAll('*'); for (var i = 0; i < els.length; i++) { var el = els[i]; var attrs = el.attributes; for (var j = 0; j < attrs.length; j++) { var name = attrs[j].name; if (name.indexOf('stm-global-') !== 0) continue; var binding = parseBinding(name); if (!binding) continue; if (seenByName[binding.name] && seenByName[binding.name] !== el) { console.warn('[statum] stm-global-' + binding.name + ' declared more than once; the last declaration in document order wins'); } seenByName[binding.name] = el; defineVar(rootScope, binding.name, 'global:' + binding.name, makeCompute(el, binding, rootScope)); } } } // --- installation ------------------------------------------------------- bridge.buildScope(buildScope); bridge.onPassStart(passStart); })();