| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- /**
- * 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);
- })();
|