/** * 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); supersedes * stm-def-{name}-as (kept as an * obsolescent alias, see below) * 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 * * The legacy `stm-def-{name}-as="expr"` spelling (the pre-binding-module * form, still emitted by older templates) is accepted as an exact alias of * `stm-local-{name}` and logs a one-time obsolescence warning per element. * * 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`). * - OBSERVABLE PROPERTIES: `-from-{name}` where {name} is a registered * observable live property (defaults: `value`, `checked` — the events a * form control fires when they change) reads the PROPERTY and listens to * those events: each event updates the bound variables and calls * renderAll() directly. Further properties are registered with * `statum.binding.observe(name, events)` — no module changes needed. * Every other name (and any name the element lacks as a property) falls * back to ATTRIBUTE semantics via 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; /** Elements already warned for legacy stm-def usage. @type {WeakSet} */ var legacyWarned = new WeakSet(); 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. * `stm-def-{name}-as` (the pre-binding spelling) parses as a LOCAL * expression binding with legacy=true: identical semantics, plus an * obsolescence warning at bind time. * @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 }; if ((m = attr.match(/^stm-def-([A-Za-z_$][\w$]*)-as$/))) return { kind: 'local', name: m[1], source: null, attr: attr, legacy: true }; return null; } /** * OBSERVABLE live properties: property name → the standard events that * signal its change. This table — not hard-coded `if` checks — is what * makes `-from-{property}` generic: `value` and `checked` ship by default * (input/select/textarea fire `input`/`change`), and page code registers * further event-driven properties via `statum.binding.observe()` instead * of the module growing special cases. * @type {Object} */ var observables = { value: ['input', 'change'], checked: ['input', 'change'] }; /** * The events that make `name` observable on `el`, or null when `el` has * no such property (fall back to attribute semantics — a `value` binding * on a `
` observes the attribute, not a property the element lacks). */ function observableFor(el, name) { if (!Object.prototype.hasOwnProperty.call(observables, name)) return null; if (!(name in el)) return null; return observables[name]; } /** * Attach the redraw plumbing for one -from- binding. For an observable * property (see `observables`) the listeners are attached ONCE per * (element, property); the VARIABLE registration (propNames + seed) * happens for every binding — several variables may bind the same * property, and each must see updates (review finding V1). Everything * else observes the attribute via MutationObserver. */ function bindSource(el, binding) { var propKey = binding.source; var events = observableFor(el, propKey); if (events) { // 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 listeners 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(); }; for (var i = 0; i < events.length; i++) { el.addEventListener(events[i], 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); // Observable properties read the live property; everything else reads // the attribute. Reading fresh keeps the value correct even between // the last event and the next render. if (observableFor(el, binding.source)) return el[binding.source]; 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 (binding.legacy && !legacyWarned.has(el)) { legacyWarned.add(el); console.warn('[statum] stm-def-' + binding.name + '-as is obsolescent; ' + 'use stm-local-' + binding.name + ' (same semantics)'); } // 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); /** * Public surface: register (or replace) an OBSERVABLE PROPERTY — a live * element property that fires standard DOM events when it changes, so * `-from-{name}` bindings read it as a property and listen to those * events instead of observing the attribute. `value` and `checked` are * registered by default; page code can extend the set: * * statum.binding.observe('selectedIndex', ['change']); * statum.binding.observe('open', ['toggle']); * * @param {string} name property name * @param {string[]} events events that signal the property changed */ window.statum.binding = { observe: function (name, events) { if (typeof name !== 'string' || !name || !Array.isArray(events) || !events.length) { console.warn('[statum] statum.binding.observe: expected a property name and a non-empty event list'); return; } observables[name] = events.slice(); } }; })();