| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 |
- /**
- * 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-{source} page-wide variable sourced from a
- * live property (event-mapped) or,
- * with the explicit spelling
- * -from-attribute.{name}, an
- * attribute; changes 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-{source} subtree-scoped sourced variable;
- * same two spellings as the global
- * form
- * stm-declare-{name} declares {name} for a subtree so a
- * descendant can bind it and SIBLINGS
- * of that binder can read it
- *
- * stm-update-attribute.{name}="expr" set the attribute ONLY when the
- * expression's evaluated value
- * differs from what this binding
- * last wrote (local/scripted changes
- * survive between renders)
- *
- * 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, mode};
- * mode is 'property' for `-from-{property}` (a registered observable) or
- * 'attribute' for `-from-attribute.{name}` (explicit, aligned with
- * `stm-attribute.{name}`). Expression forms have source 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-attribute\.([\w$:-]+)$/)))
- return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
- if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
- return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'property' };
- 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-attribute\.([\w$:-]+)$/)))
- return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
- if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
- return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'property' };
- 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}` (and the attribute-sourced spelling, for any
- * name the mapping covers) 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<string, string[]>}
- */
- var observables = {
- value: ['input', 'change'],
- checked: ['input', 'change']
- };
- /**
- * The events that make `name` observable on `el`, or null when the
- * property is not registered or `el` lacks it. Consulted for BOTH
- * spellings: `-from-{property}` and `-from-attribute.{name}` — the
- * event mapping is the primary observation mechanism for any name it
- * covers; the MutationObserver supplements/fallbacks for it.
- */
- 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. The EVENT MAPPING
- * is primary: whenever the source name is a registered observable on the
- * element (either spelling — `-from-{property}` or
- * `-from-attribute.{name}`), listeners on the mapped events update the
- * bound variables and redraw. The MutationObserver supplements (and
- * covers every non-observable name) so scripted attribute writes are
- * never missed. Listeners attach once per (element, property); the
- * VARIABLE registration happens for every binding (several variables may
- * bind one source — review finding V1).
- */
- function bindSource(el, binding) {
- var propKey = binding.source;
- var events = observableFor(el, propKey);
- if (events) {
- 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);
- }
- var bound = propBound.get(el);
- if (!bound) bound = {};
- if (!bound[propKey]) {
- 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);
- }
- }
- // An observable source may still be written as an attribute; keep
- // the observer as a supplementary signal (unless already attached).
- }
- 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 = attrSnapshot(el, propKey);
- new MutationObserver(function () {
- var now = attrSnapshot(el, propKey);
- if (now === last) return; // same-value write: never redraw
- last = now;
- renderAll();
- }).observe(el, { attributes: true, attributeFilter: [propKey] });
- }
- /**
- * Read a source's current value the way the binding should present it:
- * the live PROPERTY when one is observable on the element, else the
- * attribute. (For an observable property the attribute is a stale
- * default; for a plain attribute there is no property to read.)
- */
- function readSource(el, source) {
- return observableFor(el, source) ? el[source] : el.getAttribute(source);
- }
- /** Snapshot a source attribute: the string value, or null when absent. */
- function attrSnapshot(el, name) {
- var v = el.getAttribute(name);
- return v == null ? null : String(v);
- }
- /**
- * 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.
- return readSource(el, 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);
- /**
- * `stm-update-attribute.{name}="expr"` — set the attribute ONLY when the
- * expression's evaluated value differs from the last value THIS binding
- * wrote (stored separately from the DOM). Unlike `stm-attribute.{name}`
- * (which writes every cycle and would clobber user/script changes made
- * between renders), an interaction or script may change the attribute and
- * the binding leaves it alone until the expression's own output actually
- * changes. Example: an expander whose `open` state is server-driven but
- * user-toggled locally — the server's value lands once, then local
- * toggles survive until the state genuinely changes.
- *
- * Coercion: `undefined`/`null` remove the attribute; booleans set/remove
- * it; anything else is stringified. The first evaluation establishes the
- * baseline (it writes only if the attribute does not already match).
- */
- var lastWritten = new WeakMap(); // element -> { attrName: lastValue|null }
- function handleUpdateAttribute(el, scope) {
- var attrs = el.attributes;
- for (var i = 0; i < attrs.length; i++) {
- var attr = attrs[i];
- if (attr.name.indexOf('stm-update-attribute.') !== 0) continue;
- var target = attr.name.slice('stm-update-attribute.'.length);
- if (!target) continue;
- var value = evalExpr(attr.value, scope);
- var memo = lastWritten.get(el) || {};
- var key = target;
- var next; // undefined => leave untouched, null => remove
- if (value === undefined) continue; // unresolved: never touch
- if (value === null || value === false) next = null;
- else if (value === true) next = '';
- else next = String(value);
- if (Object.prototype.hasOwnProperty.call(memo, key)) {
- if (memo[key] === next) continue; // our own value: do not rewrite
- } else {
- // First evaluation: if the attribute already holds this value,
- // adopt it as the baseline without writing.
- var current = el.getAttribute(target);
- var currentNorm = current == null ? null : String(current);
- if (currentNorm === (next === null ? null : next)) {
- memo[key] = next;
- lastWritten.set(el, memo);
- continue;
- }
- }
- memo[key] = next;
- lastWritten.set(el, memo);
- if (next === null) el.removeAttribute(target);
- else el.setAttribute(target, next);
- }
- }
- window.statum.registerAttribute('stm-update-attribute.', handleUpdateAttribute,
- { before: 'stm-class.' });
- /**
- * 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();
- }
- };
- })();
|