/** * 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 = []; /** Elements whose property listener is attached. @type {WeakSet} */ var propBound = new WeakSet(); /** Elements with a live MutationObserver. @type {WeakSet} */ var observed = new WeakSet(); /** 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 (idempotent). */ function bindSource(el, binding) { if (isPropSource(el, binding.source)) { if (propBound.has(el)) return; propBound.add(el); var store = liveProps.get(el) || {}; store[binding.name] = el[binding.source]; liveProps.set(el, store); var onChange = function () { var s = liveProps.get(el); if (!s || s[binding.name] === el[binding.source]) return; // no change s[binding.name] = el[binding.source]; renderAll(); }; el.addEventListener('input', onChange); el.addEventListener('change', onChange); return; } if (observed.has(el) || typeof MutationObserver !== 'function') return; observed.add(el); var last = el.getAttribute(binding.source); new MutationObserver(function () { var now = el.getAttribute(binding.source); if (now === last) return; // same-value write: never redraw last = now; renderAll(); }).observe(el, { attributes: true, attributeFilter: [binding.source] }); } /** * 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); })();