statum-binding.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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-{attribute} page-wide variable sourced from an
  12. * element attribute; changes to that
  13. * attribute trigger a redraw
  14. * stm-local-{name}="expr" subtree-scoped variable (element +
  15. * descendants); supersedes
  16. * stm-def-{name}-as (kept as an
  17. * obsolescent alias, see below)
  18. * stm-local-{name}-from-{attribute} subtree-scoped attribute-sourced
  19. * variable; changes trigger a redraw
  20. * stm-declare-{name} declares {name} for a subtree so a
  21. * descendant can bind it and SIBLINGS
  22. * of that binder can read it
  23. *
  24. * The legacy `stm-def-{name}-as="expr"` spelling (the pre-binding-module
  25. * form, still emitted by older templates) is accepted as an exact alias of
  26. * `stm-local-{name}` and logs a one-time obsolescence warning per element.
  27. *
  28. * Semantics (operator-confirmed):
  29. * - LAZY-CACHED: a binding's expression evaluates at most once per render
  30. * pass, only when something reads the variable; the result is cached for
  31. * the pass and the cache resets each pass.
  32. * - CYCLES are an error, not a crash: re-entrant evaluation of the SAME
  33. * binding logs '[statum] variable cycle: a → b → a' (variable names) and
  34. * the variable reads as undefined for that pass. Distinct bindings that
  35. * shadow a name are not cycles (an inner `x` may reference an outer `x`).
  36. * - OBSERVABLE PROPERTIES: `-from-{name}` where {name} is a registered
  37. * observable live property (defaults: `value`, `checked` — the events a
  38. * form control fires when they change) reads the PROPERTY and listens to
  39. * those events: each event updates the bound variables and calls
  40. * renderAll() directly. Further properties are registered with
  41. * `statum.binding.observe(name, events)` — no module changes needed.
  42. * Every other name (and any name the element lacks as a property) falls
  43. * back to ATTRIBUTE semantics via MutationObserver.
  44. * - Redraw guard: an observed attribute change only redraws when the
  45. * variable's derived value actually changed — the walk itself writes
  46. * attributes (stm-attribute.*, hidden, data-stm-active) and a naive
  47. * observer would loop forever.
  48. * - A stm-global is visible page-wide on the FIRST pass it is declared in,
  49. * even at elements that precede its declaring element in document order
  50. * (the pass-start sweep exposes lazy getters; each reader resolves the
  51. * binding on demand, at most once per pass).
  52. * - Shadowing: a variable shadows slot types and outer variables with the
  53. * same name inside its scope. Duplicate stm-global-{name}: last in
  54. * document order wins, one console.warn per pass.
  55. */
  56. (function () {
  57. if (!window.statum || !window.statum._bridge) {
  58. console.error('[statum] statum-binding.js requires statum-core.js');
  59. return;
  60. }
  61. if (!window.statum.registerAttribute) {
  62. console.error('[statum] statum-binding.js requires statum-document.js');
  63. return;
  64. }
  65. var bridge = window.statum._bridge;
  66. var evalExpr = bridge.evalExpr;
  67. var renderAll = bridge.renderAll;
  68. // Scope-chain marker for declared slots. Expressions evaluate through the
  69. // evalScope proxy, whose `with` semantics only surface string keys, so
  70. // this key is invisible to page code.
  71. var DECLARED = '__stmDeclared';
  72. /** Per-pass cache: binding key -> value. Reset at every pass start. */
  73. var passCache = {};
  74. /** Evaluation stack for cycle detection: {key, name}, innermost last. */
  75. var evalStack = [];
  76. /**
  77. * Per-(element, source) plumbing registries: which property listeners /
  78. * attribute observers are already attached, so several bindings may share
  79. * one element without one starving the other (review finding V1).
  80. * @type {WeakMap} element -> { source: true }
  81. */
  82. var propBound = new WeakMap();
  83. var observed = new WeakMap();
  84. /** Variables bound to each (element, property): element -> { name: prop }. */
  85. var propNames = new WeakMap();
  86. /** Live property values: element -> { varName: value }. @type {WeakMap} */
  87. var liveProps = new WeakMap();
  88. /** Stable per-element identity for cache keys. @type {WeakMap} */
  89. var elIds = new WeakMap();
  90. var elSeq = 0;
  91. /** Elements already warned for legacy stm-def usage. @type {WeakSet} */
  92. var legacyWarned = new WeakSet();
  93. function elId(el) {
  94. var id = elIds.get(el);
  95. if (id === undefined) { id = ++elSeq; elIds.set(el, id); }
  96. return id;
  97. }
  98. /**
  99. * Resolve one binding for this pass: evaluate at most once, cache, and
  100. * detect re-entrant evaluation of the SAME binding (a cycle).
  101. * @param {string} key unique per binding site (stable across passes)
  102. * @param {string} name variable name, for diagnostics
  103. * @param {Function} compute () => value
  104. */
  105. function resolve(key, name, compute) {
  106. if (Object.prototype.hasOwnProperty.call(passCache, key)) {
  107. return passCache[key];
  108. }
  109. for (var i = 0; i < evalStack.length; i++) {
  110. if (evalStack[i].key === key) {
  111. var chain = [];
  112. for (var j = i; j < evalStack.length; j++) chain.push(evalStack[j].name);
  113. console.error('[statum] variable cycle: ' + chain.concat(name).join(' → '));
  114. return undefined;
  115. }
  116. }
  117. evalStack.push({ key: key, name: name });
  118. try {
  119. var value = compute();
  120. passCache[key] = value;
  121. return value;
  122. } finally {
  123. evalStack.pop();
  124. }
  125. }
  126. /**
  127. * Parse a binding attribute name into {kind, name, source, attr};
  128. * source is the attribute/property name for -from- forms, else null.
  129. * `stm-def-{name}-as` (the pre-binding spelling) parses as a LOCAL
  130. * expression binding with legacy=true: identical semantics, plus an
  131. * obsolescence warning at bind time.
  132. * @param {string} attr
  133. */
  134. function parseBinding(attr) {
  135. var m;
  136. if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-(.+)$/)))
  137. return { kind: 'global', name: m[1], source: m[2], attr: attr };
  138. if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)$/)))
  139. return { kind: 'global', name: m[1], source: null, attr: attr };
  140. if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-(.+)$/)))
  141. return { kind: 'local', name: m[1], source: m[2], attr: attr };
  142. if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)$/)))
  143. return { kind: 'local', name: m[1], source: null, attr: attr };
  144. if ((m = attr.match(/^stm-def-([A-Za-z_$][\w$]*)-as$/)))
  145. return { kind: 'local', name: m[1], source: null, attr: attr, legacy: true };
  146. return null;
  147. }
  148. /**
  149. * OBSERVABLE live properties: property name → the standard events that
  150. * signal its change. This table — not hard-coded `if` checks — is what
  151. * makes `-from-{property}` generic: `value` and `checked` ship by default
  152. * (input/select/textarea fire `input`/`change`), and page code registers
  153. * further event-driven properties via `statum.binding.observe()` instead
  154. * of the module growing special cases.
  155. * @type {Object<string, string[]>}
  156. */
  157. var observables = {
  158. value: ['input', 'change'],
  159. checked: ['input', 'change']
  160. };
  161. /**
  162. * The events that make `name` observable on `el`, or null when `el` has
  163. * no such property (fall back to attribute semantics — a `value` binding
  164. * on a `<div>` observes the attribute, not a property the element lacks).
  165. */
  166. function observableFor(el, name) {
  167. if (!Object.prototype.hasOwnProperty.call(observables, name)) return null;
  168. if (!(name in el)) return null;
  169. return observables[name];
  170. }
  171. /**
  172. * Attach the redraw plumbing for one -from- binding. For an observable
  173. * property (see `observables`) the listeners are attached ONCE per
  174. * (element, property); the VARIABLE registration (propNames + seed)
  175. * happens for every binding — several variables may bind the same
  176. * property, and each must see updates (review finding V1). Everything
  177. * else observes the attribute via MutationObserver.
  178. */
  179. function bindSource(el, binding) {
  180. var propKey = binding.source;
  181. var events = observableFor(el, propKey);
  182. if (events) {
  183. // Register this variable against the property (always).
  184. var names = propNames.get(el) || {};
  185. names[binding.name] = propKey;
  186. propNames.set(el, names);
  187. var seed = liveProps.get(el) || {};
  188. if (!Object.prototype.hasOwnProperty.call(seed, binding.name)) {
  189. seed[binding.name] = el[propKey];
  190. liveProps.set(el, seed);
  191. }
  192. // Attach the shared listeners once per (element, property).
  193. var bound = propBound.get(el);
  194. if (bound && bound[propKey]) return;
  195. if (!bound) bound = {};
  196. bound[propKey] = true;
  197. propBound.set(el, bound);
  198. var onChange = function () {
  199. var n = propNames.get(el);
  200. if (!n) return;
  201. var changed = false;
  202. var store = liveProps.get(el) || {};
  203. for (var name in n) {
  204. var key = n[name];
  205. if (store[name] !== el[key]) {
  206. store[name] = el[key];
  207. changed = true;
  208. }
  209. }
  210. if (changed) liveProps.set(el, store);
  211. if (changed) renderAll();
  212. };
  213. for (var i = 0; i < events.length; i++) {
  214. el.addEventListener(events[i], onChange);
  215. }
  216. return;
  217. }
  218. var seen = observed.get(el);
  219. if (seen && seen[propKey]) return;
  220. if (typeof MutationObserver !== 'function') return;
  221. if (!seen) seen = {};
  222. seen[propKey] = true;
  223. observed.set(el, seen);
  224. var last = el.getAttribute(propKey);
  225. new MutationObserver(function () {
  226. var now = el.getAttribute(propKey);
  227. if (now === last) return; // same-value write: never redraw
  228. last = now;
  229. renderAll();
  230. }).observe(el, { attributes: true, attributeFilter: [propKey] });
  231. }
  232. /**
  233. * Build one binding's value computation. Expression forms evaluate
  234. * against the PARENT scope (a binding cannot reference itself or a
  235. * same-element sibling, matching the stm-def semantics this replaces).
  236. * -from- forms read their live source (attaching plumbing on first use).
  237. */
  238. function makeCompute(el, binding, parentScope) {
  239. if (binding.source === null) {
  240. return function () {
  241. return evalExpr(el.getAttribute(binding.attr), parentScope);
  242. };
  243. }
  244. return function () {
  245. bindSource(el, binding);
  246. // Observable properties read the live property; everything else reads
  247. // the attribute. Reading fresh keeps the value correct even between
  248. // the last event and the next render.
  249. if (observableFor(el, binding.source)) return el[binding.source];
  250. return el.getAttribute(binding.source);
  251. };
  252. }
  253. /**
  254. * Expose a lazily-resolved variable on a scope. `slot`, when present, is
  255. * a declared slot (see buildScope): resolution goes through the slot so
  256. * the NEWEST binder in the subtree supplies the value, and the cache key
  257. * is the SLOT's (one evaluation per pass no matter which getter is read).
  258. */
  259. function defineVar(scope, name, key, compute, slot) {
  260. if (slot) slot.compute = compute;
  261. Object.defineProperty(scope, name, {
  262. configurable: true,
  263. enumerable: true,
  264. get: function () {
  265. if (slot) return resolve(key, name, slot.compute);
  266. return resolve(key, name, compute);
  267. },
  268. set: function (v) { // expressions never assign; escape hatch only
  269. Object.defineProperty(scope, name, {
  270. configurable: true, writable: true, value: v
  271. });
  272. }
  273. });
  274. }
  275. /**
  276. * Walk UP a scope chain for a declared slot named `name`.
  277. * @returns {{key: string, compute: Function}|null}
  278. */
  279. function findDeclared(scope, name) {
  280. var s = scope;
  281. while (s) {
  282. if (Object.prototype.hasOwnProperty.call(s, DECLARED) && s[DECLARED][name]) {
  283. return s[DECLARED][name];
  284. }
  285. s = Object.getPrototypeOf(s);
  286. }
  287. return null;
  288. }
  289. /**
  290. * Scope construction seam (document's walk calls this for every element;
  291. * core defaults to identity). Elements carrying stm-local-* or
  292. * stm-declare-* get a child scope with lazy getters; others pass through
  293. * unchanged.
  294. */
  295. function buildScope(el, scope) {
  296. var attrs = el.attributes;
  297. var child = null;
  298. var declaredHere = null;
  299. for (var i = 0; i < attrs.length; i++) {
  300. var attrName = attrs[i].name;
  301. var binding = parseBinding(attrName);
  302. if (binding && binding.kind === 'local') {
  303. if (!child) child = Object.create(scope || null);
  304. if (binding.legacy && !legacyWarned.has(el)) {
  305. legacyWarned.add(el);
  306. console.warn('[statum] stm-def-' + binding.name + '-as is obsolescent; ' +
  307. 'use stm-local-' + binding.name + ' (same semantics)');
  308. }
  309. // If an ancestor declared this name, bind into its slot so the
  310. // declarer's whole subtree (including siblings of this element)
  311. // reads this binding.
  312. var slot = findDeclared(child, binding.name);
  313. var key = slot ? slot.key
  314. : 'local:' + binding.name + ':' + elId(el) + ':' + attrName;
  315. defineVar(child, binding.name, key,
  316. makeCompute(el, binding, scope), slot);
  317. } else if (attrName.indexOf('stm-declare-') === 0) {
  318. var dName = attrName.slice('stm-declare-'.length);
  319. if (!dName) continue;
  320. if (!child) child = Object.create(scope || null);
  321. if (!declaredHere) {
  322. declaredHere = {};
  323. Object.defineProperty(child, DECLARED, { value: declaredHere });
  324. }
  325. if (!declaredHere[dName]) {
  326. var dSlot = { key: 'declare:' + dName + ':' + elId(el),
  327. compute: function () { return undefined; } };
  328. declaredHere[dName] = dSlot;
  329. defineVar(child, dName, dSlot.key, dSlot.compute, dSlot);
  330. }
  331. }
  332. }
  333. return child || scope;
  334. }
  335. /**
  336. * Pass start (document's walk driver calls this with the root scope):
  337. * reset the per-pass cache and expose every stm-global-* on the root
  338. * scope so they are visible page-wide. Later declarations overwrite
  339. * earlier ones (getters redefine); duplicates warn once per pass.
  340. */
  341. function passStart(rootScope) {
  342. passCache = {};
  343. var seenByName = {};
  344. var els = document.querySelectorAll('*');
  345. for (var i = 0; i < els.length; i++) {
  346. var el = els[i];
  347. var attrs = el.attributes;
  348. for (var j = 0; j < attrs.length; j++) {
  349. var name = attrs[j].name;
  350. if (name.indexOf('stm-global-') !== 0) continue;
  351. var binding = parseBinding(name);
  352. if (!binding) continue;
  353. if (seenByName[binding.name] && seenByName[binding.name] !== el) {
  354. console.warn('[statum] stm-global-' + binding.name +
  355. ' declared more than once; the last declaration in document order wins');
  356. }
  357. seenByName[binding.name] = el;
  358. defineVar(rootScope, binding.name, 'global:' + binding.name,
  359. makeCompute(el, binding, rootScope));
  360. }
  361. }
  362. }
  363. // --- installation -------------------------------------------------------
  364. bridge.buildScope(buildScope);
  365. bridge.onPassStart(passStart);
  366. /**
  367. * Public surface: register (or replace) an OBSERVABLE PROPERTY — a live
  368. * element property that fires standard DOM events when it changes, so
  369. * `-from-{name}` bindings read it as a property and listen to those
  370. * events instead of observing the attribute. `value` and `checked` are
  371. * registered by default; page code can extend the set:
  372. *
  373. * statum.binding.observe('selectedIndex', ['change']);
  374. * statum.binding.observe('open', ['toggle']);
  375. *
  376. * @param {string} name property name
  377. * @param {string[]} events events that signal the property changed
  378. */
  379. window.statum.binding = {
  380. observe: function (name, events) {
  381. if (typeof name !== 'string' || !name ||
  382. !Array.isArray(events) || !events.length) {
  383. console.warn('[statum] statum.binding.observe: expected a property name and a non-empty event list');
  384. return;
  385. }
  386. observables[name] = events.slice();
  387. }
  388. };
  389. })();