statum-binding.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. /**
  2. * statum-binding.js — client-side variables for Statum pages.
  3. *
  4. * Load position: after statum-core.js and statum-document.js. This module
  5. * and statum-markdown.js are order-independent and need not co-exist.
  6. *
  7. * ES5-style, zero dependencies, guarded IIFE. Attributes:
  8. *
  9. * stm-global-{name}="expr" page-wide variable, usable in any
  10. * expression anywhere on the page
  11. * stm-global-{name}-from-{source} page-wide variable sourced from a
  12. * live property (event-mapped) or,
  13. * with the explicit spelling
  14. * -from-attribute.{name}, an
  15. * attribute; changes trigger a redraw
  16. * stm-local-{name}="expr" subtree-scoped variable (element +
  17. * descendants); supersedes
  18. * stm-def-{name}-as (kept as an
  19. * obsolescent alias, see below)
  20. * stm-local-{name}-from-{source} subtree-scoped sourced variable;
  21. * same two spellings as the global
  22. * form
  23. * stm-declare-{name} declares {name} for a subtree so a
  24. * descendant can bind it and SIBLINGS
  25. * of that binder can read it
  26. *
  27. * stm-update-attribute.{name}="expr" set the attribute ONLY when the
  28. * expression's evaluated value
  29. * differs from what this binding
  30. * last wrote (local/scripted changes
  31. * survive between renders)
  32. *
  33. * The legacy `stm-def-{name}-as="expr"` spelling (the pre-binding-module
  34. * form, still emitted by older templates) is accepted as an exact alias of
  35. * `stm-local-{name}` and logs a one-time obsolescence warning per element.
  36. *
  37. * Semantics (operator-confirmed):
  38. * - LAZY-CACHED: a binding's expression evaluates at most once per render
  39. * pass, only when something reads the variable; the result is cached for
  40. * the pass and the cache resets each pass.
  41. * - CYCLES are an error, not a crash: re-entrant evaluation of the SAME
  42. * binding logs '[statum] variable cycle: a → b → a' (variable names) and
  43. * the variable reads as undefined for that pass. Distinct bindings that
  44. * shadow a name are not cycles (an inner `x` may reference an outer `x`).
  45. * - OBSERVABLE PROPERTIES: `-from-{name}` where {name} is a registered
  46. * observable live property (defaults: `value`, `checked` — the events a
  47. * form control fires when they change) reads the PROPERTY and listens to
  48. * those events: each event updates the bound variables and calls
  49. * renderAll() directly. Further properties are registered with
  50. * `statum.binding.observe(name, events)` — no module changes needed.
  51. * Every other name (and any name the element lacks as a property) falls
  52. * back to ATTRIBUTE semantics via MutationObserver.
  53. * - Redraw guard: an observed attribute change only redraws when the
  54. * variable's derived value actually changed — the walk itself writes
  55. * attributes (stm-attribute.*, hidden, data-stm-active) and a naive
  56. * observer would loop forever.
  57. * - A stm-global is visible page-wide on the FIRST pass it is declared in,
  58. * even at elements that precede its declaring element in document order
  59. * (the pass-start sweep exposes lazy getters; each reader resolves the
  60. * binding on demand, at most once per pass).
  61. * - Shadowing: a variable shadows slot types and outer variables with the
  62. * same name inside its scope. Duplicate stm-global-{name}: last in
  63. * document order wins, one console.warn per pass.
  64. */
  65. (function () {
  66. if (!window.statum || !window.statum._bridge) {
  67. console.error('[statum] statum-binding.js requires statum-core.js');
  68. return;
  69. }
  70. if (!window.statum.registerAttribute) {
  71. console.error('[statum] statum-binding.js requires statum-document.js');
  72. return;
  73. }
  74. var bridge = window.statum._bridge;
  75. var evalExpr = bridge.evalExpr;
  76. var renderAll = bridge.renderAll;
  77. // Scope-chain marker for declared slots. Expressions evaluate through the
  78. // evalScope proxy, whose `with` semantics only surface string keys, so
  79. // this key is invisible to page code.
  80. var DECLARED = '__stmDeclared';
  81. /** Per-pass cache: binding key -> value. Reset at every pass start. */
  82. var passCache = {};
  83. /** Evaluation stack for cycle detection: {key, name}, innermost last. */
  84. var evalStack = [];
  85. /**
  86. * Per-(element, source) plumbing registries: which property listeners /
  87. * attribute observers are already attached, so several bindings may share
  88. * one element without one starving the other (review finding V1).
  89. * @type {WeakMap} element -> { source: true }
  90. */
  91. var propBound = new WeakMap();
  92. var observed = new WeakMap();
  93. /** Variables bound to each (element, property): element -> { name: prop }. */
  94. var propNames = new WeakMap();
  95. /** Live property values: element -> { varName: value }. @type {WeakMap} */
  96. var liveProps = new WeakMap();
  97. /** Stable per-element identity for cache keys. @type {WeakMap} */
  98. var elIds = new WeakMap();
  99. var elSeq = 0;
  100. /** Elements already warned for legacy stm-def usage. @type {WeakSet} */
  101. var legacyWarned = new WeakSet();
  102. function elId(el) {
  103. var id = elIds.get(el);
  104. if (id === undefined) { id = ++elSeq; elIds.set(el, id); }
  105. return id;
  106. }
  107. /**
  108. * Resolve one binding for this pass: evaluate at most once, cache, and
  109. * detect re-entrant evaluation of the SAME binding (a cycle).
  110. * @param {string} key unique per binding site (stable across passes)
  111. * @param {string} name variable name, for diagnostics
  112. * @param {Function} compute () => value
  113. */
  114. function resolve(key, name, compute) {
  115. if (Object.prototype.hasOwnProperty.call(passCache, key)) {
  116. return passCache[key];
  117. }
  118. for (var i = 0; i < evalStack.length; i++) {
  119. if (evalStack[i].key === key) {
  120. var chain = [];
  121. for (var j = i; j < evalStack.length; j++) chain.push(evalStack[j].name);
  122. console.error('[statum] variable cycle: ' + chain.concat(name).join(' → '));
  123. return undefined;
  124. }
  125. }
  126. evalStack.push({ key: key, name: name });
  127. try {
  128. var value = compute();
  129. passCache[key] = value;
  130. return value;
  131. } finally {
  132. evalStack.pop();
  133. }
  134. }
  135. /**
  136. * Parse a binding attribute name into {kind, name, source, attr, mode};
  137. * mode is 'property' for `-from-{property}` (a registered observable) or
  138. * 'attribute' for `-from-attribute.{name}` (explicit, aligned with
  139. * `stm-attribute.{name}`). Expression forms have source null.
  140. * `stm-def-{name}-as` (the pre-binding spelling) parses as a LOCAL
  141. * expression binding with legacy=true: identical semantics, plus an
  142. * obsolescence warning at bind time.
  143. * @param {string} attr
  144. */
  145. function parseBinding(attr) {
  146. var m;
  147. if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-attribute\.([\w$:-]+)$/)))
  148. return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
  149. if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
  150. return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'property' };
  151. if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)$/)))
  152. return { kind: 'global', name: m[1], source: null, attr: attr };
  153. if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-attribute\.([\w$:-]+)$/)))
  154. return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
  155. if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
  156. return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'property' };
  157. if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)$/)))
  158. return { kind: 'local', name: m[1], source: null, attr: attr };
  159. if ((m = attr.match(/^stm-def-([A-Za-z_$][\w$]*)-as$/)))
  160. return { kind: 'local', name: m[1], source: null, attr: attr, legacy: true };
  161. return null;
  162. }
  163. /**
  164. * OBSERVABLE live properties: property name → the standard events that
  165. * signal its change. This table — not hard-coded `if` checks — is what
  166. * makes `-from-{property}` (and the attribute-sourced spelling, for any
  167. * name the mapping covers) generic: `value` and `checked` ship by
  168. * default (input/select/textarea fire `input`/`change`), and page code
  169. * registers further event-driven properties via
  170. * `statum.binding.observe()` instead of the module growing special cases.
  171. * @type {Object<string, string[]>}
  172. */
  173. var observables = {
  174. value: ['input', 'change'],
  175. checked: ['input', 'change']
  176. };
  177. /**
  178. * The events that make `name` observable on `el`, or null when the
  179. * property is not registered or `el` lacks it. Consulted for BOTH
  180. * spellings: `-from-{property}` and `-from-attribute.{name}` — the
  181. * event mapping is the primary observation mechanism for any name it
  182. * covers; the MutationObserver supplements/fallbacks for it.
  183. */
  184. function observableFor(el, name) {
  185. if (!Object.prototype.hasOwnProperty.call(observables, name)) return null;
  186. if (!(name in el)) return null;
  187. return observables[name];
  188. }
  189. /**
  190. * Attach the redraw plumbing for one -from- binding. The EVENT MAPPING
  191. * is primary: whenever the source name is a registered observable on the
  192. * element (either spelling — `-from-{property}` or
  193. * `-from-attribute.{name}`), listeners on the mapped events update the
  194. * bound variables and redraw. The MutationObserver supplements (and
  195. * covers every non-observable name) so scripted attribute writes are
  196. * never missed. Listeners attach once per (element, property); the
  197. * VARIABLE registration happens for every binding (several variables may
  198. * bind one source — review finding V1).
  199. */
  200. function bindSource(el, binding) {
  201. var propKey = binding.source;
  202. var events = observableFor(el, propKey);
  203. if (events) {
  204. var names = propNames.get(el) || {};
  205. names[binding.name] = propKey;
  206. propNames.set(el, names);
  207. var seed = liveProps.get(el) || {};
  208. if (!Object.prototype.hasOwnProperty.call(seed, binding.name)) {
  209. seed[binding.name] = el[propKey];
  210. liveProps.set(el, seed);
  211. }
  212. var bound = propBound.get(el);
  213. if (!bound) bound = {};
  214. if (!bound[propKey]) {
  215. bound[propKey] = true;
  216. propBound.set(el, bound);
  217. var onChange = function () {
  218. var n = propNames.get(el);
  219. if (!n) return;
  220. var changed = false;
  221. var store = liveProps.get(el) || {};
  222. for (var name in n) {
  223. var key = n[name];
  224. if (store[name] !== el[key]) {
  225. store[name] = el[key];
  226. changed = true;
  227. }
  228. }
  229. if (changed) liveProps.set(el, store);
  230. if (changed) renderAll();
  231. };
  232. for (var i = 0; i < events.length; i++) {
  233. el.addEventListener(events[i], onChange);
  234. }
  235. }
  236. // An observable source may still be written as an attribute; keep
  237. // the observer as a supplementary signal (unless already attached).
  238. }
  239. var seen = observed.get(el);
  240. if (seen && seen[propKey]) return;
  241. if (typeof MutationObserver !== 'function') return;
  242. if (!seen) seen = {};
  243. seen[propKey] = true;
  244. observed.set(el, seen);
  245. var last = attrSnapshot(el, propKey);
  246. new MutationObserver(function () {
  247. var now = attrSnapshot(el, propKey);
  248. if (now === last) return; // same-value write: never redraw
  249. last = now;
  250. renderAll();
  251. }).observe(el, { attributes: true, attributeFilter: [propKey] });
  252. }
  253. /**
  254. * Read a source's current value the way the binding should present it:
  255. * the live PROPERTY when one is observable on the element, else the
  256. * attribute. (For an observable property the attribute is a stale
  257. * default; for a plain attribute there is no property to read.)
  258. */
  259. function readSource(el, source) {
  260. return observableFor(el, source) ? el[source] : el.getAttribute(source);
  261. }
  262. /** Snapshot a source attribute: the string value, or null when absent. */
  263. function attrSnapshot(el, name) {
  264. var v = el.getAttribute(name);
  265. return v == null ? null : String(v);
  266. }
  267. /**
  268. * Build one binding's value computation. Expression forms evaluate
  269. * against the PARENT scope (a binding cannot reference itself or a
  270. * same-element sibling, matching the stm-def semantics this replaces).
  271. * -from- forms read their live source (attaching plumbing on first use).
  272. */
  273. function makeCompute(el, binding, parentScope) {
  274. if (binding.source === null) {
  275. return function () {
  276. return evalExpr(el.getAttribute(binding.attr), parentScope);
  277. };
  278. }
  279. return function () {
  280. bindSource(el, binding);
  281. // Observable properties read the live property; everything else reads
  282. // the attribute. Reading fresh keeps the value correct even between
  283. // the last event and the next render.
  284. return readSource(el, binding.source);
  285. };
  286. }
  287. /**
  288. * Expose a lazily-resolved variable on a scope. `slot`, when present, is
  289. * a declared slot (see buildScope): resolution goes through the slot so
  290. * the NEWEST binder in the subtree supplies the value, and the cache key
  291. * is the SLOT's (one evaluation per pass no matter which getter is read).
  292. */
  293. function defineVar(scope, name, key, compute, slot) {
  294. if (slot) slot.compute = compute;
  295. Object.defineProperty(scope, name, {
  296. configurable: true,
  297. enumerable: true,
  298. get: function () {
  299. if (slot) return resolve(key, name, slot.compute);
  300. return resolve(key, name, compute);
  301. },
  302. set: function (v) { // expressions never assign; escape hatch only
  303. Object.defineProperty(scope, name, {
  304. configurable: true, writable: true, value: v
  305. });
  306. }
  307. });
  308. }
  309. /**
  310. * Walk UP a scope chain for a declared slot named `name`.
  311. * @returns {{key: string, compute: Function}|null}
  312. */
  313. function findDeclared(scope, name) {
  314. var s = scope;
  315. while (s) {
  316. if (Object.prototype.hasOwnProperty.call(s, DECLARED) && s[DECLARED][name]) {
  317. return s[DECLARED][name];
  318. }
  319. s = Object.getPrototypeOf(s);
  320. }
  321. return null;
  322. }
  323. /**
  324. * Scope construction seam (document's walk calls this for every element;
  325. * core defaults to identity). Elements carrying stm-local-* or
  326. * stm-declare-* get a child scope with lazy getters; others pass through
  327. * unchanged.
  328. */
  329. function buildScope(el, scope) {
  330. var attrs = el.attributes;
  331. var child = null;
  332. var declaredHere = null;
  333. for (var i = 0; i < attrs.length; i++) {
  334. var attrName = attrs[i].name;
  335. var binding = parseBinding(attrName);
  336. if (binding && binding.kind === 'local') {
  337. if (!child) child = Object.create(scope || null);
  338. if (binding.legacy && !legacyWarned.has(el)) {
  339. legacyWarned.add(el);
  340. console.warn('[statum] stm-def-' + binding.name + '-as is obsolescent; ' +
  341. 'use stm-local-' + binding.name + ' (same semantics)');
  342. }
  343. // If an ancestor declared this name, bind into its slot so the
  344. // declarer's whole subtree (including siblings of this element)
  345. // reads this binding.
  346. var slot = findDeclared(child, binding.name);
  347. var key = slot ? slot.key
  348. : 'local:' + binding.name + ':' + elId(el) + ':' + attrName;
  349. defineVar(child, binding.name, key,
  350. makeCompute(el, binding, scope), slot);
  351. } else if (attrName.indexOf('stm-declare-') === 0) {
  352. var dName = attrName.slice('stm-declare-'.length);
  353. if (!dName) continue;
  354. if (!child) child = Object.create(scope || null);
  355. if (!declaredHere) {
  356. declaredHere = {};
  357. Object.defineProperty(child, DECLARED, { value: declaredHere });
  358. }
  359. if (!declaredHere[dName]) {
  360. var dSlot = { key: 'declare:' + dName + ':' + elId(el),
  361. compute: function () { return undefined; } };
  362. declaredHere[dName] = dSlot;
  363. defineVar(child, dName, dSlot.key, dSlot.compute, dSlot);
  364. }
  365. }
  366. }
  367. return child || scope;
  368. }
  369. /**
  370. * Pass start (document's walk driver calls this with the root scope):
  371. * reset the per-pass cache and expose every stm-global-* on the root
  372. * scope so they are visible page-wide. Later declarations overwrite
  373. * earlier ones (getters redefine); duplicates warn once per pass.
  374. */
  375. function passStart(rootScope) {
  376. passCache = {};
  377. var seenByName = {};
  378. var els = document.querySelectorAll('*');
  379. for (var i = 0; i < els.length; i++) {
  380. var el = els[i];
  381. var attrs = el.attributes;
  382. for (var j = 0; j < attrs.length; j++) {
  383. var name = attrs[j].name;
  384. if (name.indexOf('stm-global-') !== 0) continue;
  385. var binding = parseBinding(name);
  386. if (!binding) continue;
  387. if (seenByName[binding.name] && seenByName[binding.name] !== el) {
  388. console.warn('[statum] stm-global-' + binding.name +
  389. ' declared more than once; the last declaration in document order wins');
  390. }
  391. seenByName[binding.name] = el;
  392. defineVar(rootScope, binding.name, 'global:' + binding.name,
  393. makeCompute(el, binding, rootScope));
  394. }
  395. }
  396. }
  397. // --- installation -------------------------------------------------------
  398. bridge.buildScope(buildScope);
  399. bridge.onPassStart(passStart);
  400. /**
  401. * `stm-update-attribute.{name}="expr"` — set the attribute ONLY when the
  402. * expression's evaluated value differs from the last value THIS binding
  403. * wrote (stored separately from the DOM). Unlike `stm-attribute.{name}`
  404. * (which writes every cycle and would clobber user/script changes made
  405. * between renders), an interaction or script may change the attribute and
  406. * the binding leaves it alone until the expression's own output actually
  407. * changes. Example: an expander whose `open` state is server-driven but
  408. * user-toggled locally — the server's value lands once, then local
  409. * toggles survive until the state genuinely changes.
  410. *
  411. * Coercion: `undefined`/`null` remove the attribute; booleans set/remove
  412. * it; anything else is stringified. The first evaluation establishes the
  413. * baseline (it writes only if the attribute does not already match).
  414. */
  415. var lastWritten = new WeakMap(); // element -> { attrName: lastValue|null }
  416. function handleUpdateAttribute(el, scope) {
  417. var attrs = el.attributes;
  418. for (var i = 0; i < attrs.length; i++) {
  419. var attr = attrs[i];
  420. if (attr.name.indexOf('stm-update-attribute.') !== 0) continue;
  421. var target = attr.name.slice('stm-update-attribute.'.length);
  422. if (!target) continue;
  423. var value = evalExpr(attr.value, scope);
  424. var memo = lastWritten.get(el) || {};
  425. var key = target;
  426. var next; // undefined => leave untouched, null => remove
  427. if (value === undefined) continue; // unresolved: never touch
  428. if (value === null || value === false) next = null;
  429. else if (value === true) next = '';
  430. else next = String(value);
  431. if (Object.prototype.hasOwnProperty.call(memo, key)) {
  432. if (memo[key] === next) continue; // our own value: do not rewrite
  433. } else {
  434. // First evaluation: if the attribute already holds this value,
  435. // adopt it as the baseline without writing.
  436. var current = el.getAttribute(target);
  437. var currentNorm = current == null ? null : String(current);
  438. if (currentNorm === (next === null ? null : next)) {
  439. memo[key] = next;
  440. lastWritten.set(el, memo);
  441. continue;
  442. }
  443. }
  444. memo[key] = next;
  445. lastWritten.set(el, memo);
  446. if (next === null) el.removeAttribute(target);
  447. else el.setAttribute(target, next);
  448. }
  449. }
  450. window.statum.registerAttribute('stm-update-attribute.', handleUpdateAttribute,
  451. { before: 'stm-class.' });
  452. /**
  453. * Public surface: register (or replace) an OBSERVABLE PROPERTY — a live
  454. * element property that fires standard DOM events when it changes, so
  455. * `-from-{name}` bindings read it as a property and listen to those
  456. * events instead of observing the attribute. `value` and `checked` are
  457. * registered by default; page code can extend the set:
  458. *
  459. * statum.binding.observe('selectedIndex', ['change']);
  460. * statum.binding.observe('open', ['toggle']);
  461. *
  462. * @param {string} name property name
  463. * @param {string[]} events events that signal the property changed
  464. */
  465. window.statum.binding = {
  466. observe: function (name, events) {
  467. if (typeof name !== 'string' || !name ||
  468. !Array.isArray(events) || !events.length) {
  469. console.warn('[statum] statum.binding.observe: expected a property name and a non-empty event list');
  470. return;
  471. }
  472. observables[name] = events.slice();
  473. }
  474. };
  475. })();