|
|
@@ -0,0 +1,306 @@
|
|
|
+/**
|
|
|
+ * 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);
|
|
|
+})();
|