lime-csr-js 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * @module bindings-model
3
+ * Two-way form binding — `data-model="path"`.
4
+ *
5
+ * WHAT IT DOES
6
+ * <input data-model="user.name">
7
+ * - DOM to state: on element event (see "EVENT SELECTION"), store.set(path, ...).
8
+ * - State to DOM: store.subscribe on path change, updates element value.
9
+ * Both directions — the read-write counterpart of data-text's "read-only".
10
+ * NO EVAL: path is a fixed string, no expression is executed; only
11
+ * getByPath/store API is used (see store.js).
12
+ *
13
+ * SUPPORTED INPUT TYPES (see classify()):
14
+ * - text/textarea/email/password/... (default) → el.value, 'input' event.
15
+ * - number/range → el.value read as Number(), 'input' event.
16
+ * - checkbox → el.checked (boolean), 'change' event.
17
+ * - radio → group sharing the same data-model path; only the checked
18
+ * radio's value is written to state; state change makes the matching radio
19
+ * checked (see "RADIO GROUPS"), 'change' event.
20
+ * - select (single) → el.value, 'change' event.
21
+ * - select[multiple] → array of selected option values, 'change' event.
22
+ *
23
+ * NUMERIC INPUT DECISION (number/range):
24
+ * Read value is converted via Number() (stored as number in state — so
25
+ * comparisons with is-gt etc. work directly). Exception: if the field is
26
+ * empty or Number() returns NaN (user typing "-" or "1." etc.), the RAW
27
+ * STRING is stored — otherwise data would silently disappear (jump to NaN
28
+ * or 0).
29
+ *
30
+ * LOOP GUARD (write ↔ read):
31
+ * User types → store.set → subscribe fires → element updated.
32
+ * If the new value equals the element's current value, the write is skipped
33
+ * (see write() functions) — otherwise cursor position (input/textarea)
34
+ * could reset. Since store.set's own subscribe trigger always has
35
+ * "new value == element's current value", this in practice COMPLETELY prevents
36
+ * unnecessary writes (and therefore cursor jumps).
37
+ *
38
+ * RADIO GROUPS:
39
+ * input[type=radio][data-model="x"] may share the same path across multiple
40
+ * elements (a group). The group gets ONE subscribe (not one per element) —
41
+ * when state changes, every radio's checked is updated to match its value.
42
+ * Each radio still has its own 'change' listener (writes only when checked).
43
+ *
44
+ * OUT OF SCOPE (same pattern as bindings.js):
45
+ * data-model inside an unexpanded <if data-live>/<for data-live> block is
46
+ * NOT bound here — that content is bound only by renderFn (render()) with
47
+ * the correct dal/element context. Otherwise, on branch change, the old
48
+ * subscription/listener cannot be cancelled → leak.
49
+ *
50
+ * data-model + data-on-input ON SAME ELEMENT (2h):
51
+ * When both attributes are on the same element, data-model runs FIRST.
52
+ * This order is enforced in index.js (setupModelBindings before event bindings).
53
+ * Result: the model handler fires before any data-on-input handler, so the
54
+ * store is updated by the time the application handler runs.
55
+ *
56
+ * INDEXED PATH WARNING (2a — T1 fix):
57
+ * If data-model path contains a numeric index segment (e.g. "items.0.name"),
58
+ * a dev-mode warning is issued: after array mutation the path drifts and
59
+ * points to the wrong item. Use a reactive <for data-live key=...> loop instead.
60
+ */
61
+
62
+ import { errors } from './errors.js';
63
+ import { inLiveBlock } from './shared.js';
64
+
65
+ const MODEL_ATTR = 'data-model';
66
+
67
+ // Detect paths like "items.0.name" or "list.2" (numeric segment anywhere)
68
+ const INDEXED_PATH_RE = /(?:^|\.)\d+(?:\.|$)/;
69
+
70
+
71
+
72
+ /**
73
+ * Determines the element's data-model kind.
74
+ *
75
+ * @param {Element} el
76
+ * @returns {'checkbox'|'radio'|'select-multiple'|'select-single'|'number'|'text'}
77
+ */
78
+ function classify(el) {
79
+ if (el.tagName === 'SELECT') return el.multiple ? 'select-multiple' : 'select-single';
80
+ if (el.tagName === 'TEXTAREA') return 'text';
81
+ const type = (el.getAttribute('type') || 'text').toLowerCase();
82
+ if (type === 'checkbox') return 'checkbox';
83
+ if (type === 'radio') return 'radio';
84
+ if (type === 'number' || type === 'range') return 'number';
85
+ return 'text';
86
+ }
87
+
88
+ /**
89
+ * For each input kind: which event to listen for, how to read from the DOM,
90
+ * how to write to the DOM (skip if same value — loop/cursor protection).
91
+ *
92
+ * @type {Object<string, { event: string, read: function(Element): *, write: function(Element, *): void }>}
93
+ */
94
+ const KIND_HANDLERS = {
95
+ text: {
96
+ event: 'input',
97
+ read: (el) => el.value,
98
+ write: (el, val) => {
99
+ const next = val == null ? '' : String(val);
100
+ if (el.value === next) return;
101
+ el.value = next;
102
+ },
103
+ },
104
+ number: {
105
+ event: 'input',
106
+ read: (el) => {
107
+ const raw = el.value;
108
+ if (raw === '') return '';
109
+ const n = Number(raw);
110
+ return Number.isNaN(n) ? raw : n; // invalid/partial input → raw string (no data loss)
111
+ },
112
+ write: (el, val) => {
113
+ const next = val == null ? '' : String(val);
114
+ if (el.value === next) return;
115
+ el.value = next;
116
+ },
117
+ },
118
+ checkbox: {
119
+ event: 'change',
120
+ read: (el) => el.checked,
121
+ write: (el, val) => {
122
+ const next = Boolean(val);
123
+ if (el.checked === next) return;
124
+ el.checked = next;
125
+ },
126
+ },
127
+ 'select-single': {
128
+ event: 'change',
129
+ read: (el) => el.value,
130
+ write: (el, val) => {
131
+ const next = val == null ? '' : String(val);
132
+ if (el.value === next) return;
133
+ el.value = next;
134
+ },
135
+ },
136
+ 'select-multiple': {
137
+ event: 'change',
138
+ read: (el) => Array.from(el.selectedOptions).map((opt) => opt.value),
139
+ write: (el, val) => {
140
+ const arr = Array.isArray(val) ? val.map(String) : [];
141
+ for (const opt of el.options) {
142
+ const shouldSelect = arr.includes(opt.value);
143
+ if (opt.selected !== shouldSelect) opt.selected = shouldSelect;
144
+ }
145
+ },
146
+ },
147
+ };
148
+
149
+ /**
150
+ * Binds a single (non-radio) data-model element.
151
+ *
152
+ * @param {Element} el
153
+ * @param {import('./store.js').Store} store
154
+ * @returns {function(): void} cleanup
155
+ */
156
+ function bindElement(el, store) {
157
+ const path = el.getAttribute(MODEL_ATTR);
158
+ const handler = KIND_HANDLERS[classify(el)];
159
+
160
+ // 2a T1: warn if path contains numeric index (path drift risk)
161
+ if (INDEXED_PATH_RE.test(path)) {
162
+ errors.indexedModelPath(path, el);
163
+ }
164
+
165
+ handler.write(el, store.get(path)); // initial value: state → DOM
166
+
167
+ // 2h: data-model handler runs FIRST (before data-on-input handlers)
168
+ const onEvent = () => { store.set(path, handler.read(el)); }; // DOM → state
169
+ el.addEventListener(handler.event, onEvent);
170
+
171
+ const unsubscribe = store.subscribe(path, (val) => handler.write(el, val)); // state → DOM
172
+
173
+ return function cleanup() {
174
+ el.removeEventListener(handler.event, onEvent);
175
+ unsubscribe();
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Binds a radio group sharing the same path with a SINGLE subscription.
181
+ *
182
+ * @param {string} path
183
+ * @param {Element[]} radios
184
+ * @param {import('./store.js').Store} store
185
+ * @returns {function(): void} cleanup
186
+ */
187
+ function bindRadioGroup(path, radios, store) {
188
+ const applyStoreValue = (val) => {
189
+ const str = val == null ? '' : String(val);
190
+ for (const radio of radios) {
191
+ const next = radio.value === str;
192
+ if (radio.checked !== next) radio.checked = next;
193
+ }
194
+ };
195
+
196
+ applyStoreValue(store.get(path)); // initial value: state → DOM
197
+
198
+ const listeners = radios.map((radio) => {
199
+ const onChange = () => { if (radio.checked) store.set(path, radio.value); };
200
+ radio.addEventListener('change', onChange);
201
+ return { radio, onChange };
202
+ });
203
+
204
+ const unsubscribe = store.subscribe(path, applyStoreValue); // state → DOM
205
+
206
+ return function cleanup() {
207
+ for (const { radio, onChange } of listeners) radio.removeEventListener('change', onChange);
208
+ unsubscribe();
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Two-way binds every [data-model] element under root to the store.
214
+ * Opens subscriptions + event listeners; the returned cleanup() cancels all of them.
215
+ *
216
+ * @param {Element|DocumentFragment} root
217
+ * @param {import('./store.js').Store} store
218
+ * @returns {function(): void} cleanup
219
+ */
220
+ export function setupModelBindings(root, store) {
221
+ const cleanups = [];
222
+
223
+ // Elements inside <if data-live>/<for data-live> are bound separately by
224
+ // renderFn; if we processed them here, the old subscription/listener could
225
+ // not be cancelled when the branch/block changes → leak.
226
+ const elements = [
227
+ ...(root.nodeType === Node.ELEMENT_NODE && root.hasAttribute?.(MODEL_ATTR) && !inLiveBlock(root)
228
+ ? [root]
229
+ : []),
230
+ ...Array.from(root.querySelectorAll(`[${MODEL_ATTR}]`)).filter((el) => !inLiveBlock(el)),
231
+ ];
232
+
233
+ const radioGroups = new Map(); // path → Element[]
234
+ const others = [];
235
+
236
+ for (const el of elements) {
237
+ const path = el.getAttribute(MODEL_ATTR);
238
+ if (!path) {
239
+ errors.modelMissingPath(el);
240
+ continue;
241
+ }
242
+ if (classify(el) === 'radio') {
243
+ if (!radioGroups.has(path)) radioGroups.set(path, []);
244
+ radioGroups.get(path).push(el);
245
+ } else {
246
+ others.push(el);
247
+ }
248
+ }
249
+
250
+ for (const el of others) {
251
+ cleanups.push(bindElement(el, store));
252
+ }
253
+
254
+ for (const [path, radios] of radioGroups) {
255
+ cleanups.push(bindRadioGroup(path, radios, store));
256
+ }
257
+
258
+ return function cleanup() {
259
+ for (const unsub of cleanups) unsub();
260
+ cleanups.length = 0; // guard against double cleanup calls
261
+ };
262
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @module bindings-show
3
+ * Reactive visibility toggle — `data-show="path"` (the `x-show` counterpart).
4
+ *
5
+ * WHAT IT DOES
6
+ * <div data-show="isModalOpen">...</div>
7
+ * The element is visible if the store path is truthy; `display:none` if falsy.
8
+ * NO EVAL — path is a fixed string, the value is interpreted only via
9
+ * `Boolean()` (same logic as the is-truthy operator).
10
+ *
11
+ * DIFFERENCE FROM `<if data-live>` (CRITICAL):
12
+ * `<if data-live>` COMPLETELY REMOVES the branch from the DOM and rebuilds
13
+ * it when the condition changes — input value/scroll/animation state/DOM
14
+ * identity are LOST. `data-show`, on the other hand, NEVER REMOVES the
15
+ * element from the DOM; it only hides/shows it via CSS `display` — the
16
+ * element keeps living (see the "Two-way binding" and "Condition" sections
17
+ * in README). This is the right tool for modals/accordions/tabs — anything
18
+ * needing CSS transitions or preserved form state.
19
+ *
20
+ * PRESERVING THE ORIGINAL display VALUE (design decision #1):
21
+ * A fixed `"block"` is NOT assigned when hiding — this would break elements
22
+ * with a `display:flex`/`grid`/`inline-block` CSS rule (dropping a flex
23
+ * container down to `block`). Instead: the element's inline `style.display`
24
+ * value AT SETUP TIME (`originalDisplay`) is captured ONCE; the value
25
+ * written to the DOM in the "show" state is ALWAYS this original value —
26
+ * so CSS's own (stylesheet-derived) display rule is restored as-is. In the
27
+ * "hide" state, `display:none` is assigned as an inline style (overriding
28
+ * everything in CSS — required for hiding to be guaranteed).
29
+ *
30
+ * NO FOUC (design decision #2):
31
+ * setupShowBindings runs in index.js's render() flow BEFORE the fragment is
32
+ * added to the DOM (before mount()'s `target.appendChild` call). So the
33
+ * initial state is applied correctly from the start, with no
34
+ * "flash of visible content, then disappear" (the fragment is already
35
+ * invisible/detached while being hidden).
36
+ *
37
+ * ALWAYS REACTIVE (design decision #3):
38
+ * `data-*` present = reactive (existing project convention). Anyone wanting
39
+ * static/one-time hiding already writes plain CSS ("display:none" or a
40
+ * class) — no separate "static data-show" variant is needed.
41
+ *
42
+ * OUT OF SCOPE (same pattern as bindings.js/bindings-model.js):
43
+ * data-show elements INSIDE a not-yet-expanded <if data-live>/<for
44
+ * data-live> block are NOT bound here — that content is bound only by
45
+ * renderFn's (render()) call, in its own (correct) branch/item context.
46
+ */
47
+
48
+ import { errors } from './errors.js';
49
+ import { inLiveBlock } from './shared.js';
50
+
51
+ const SHOW_ATTR = 'data-show';
52
+
53
+
54
+
55
+ /**
56
+ * Reactively binds every [data-show] element under root to the store.
57
+ * Opens subscriptions; the returned cleanup() cancels all of them.
58
+ *
59
+ * @param {Element|DocumentFragment} root
60
+ * @param {import('./store.js').Store} store
61
+ * @returns {function(): void} cleanup
62
+ */
63
+ export function setupShowBindings(root, store) {
64
+ const cleanups = [];
65
+
66
+ const elements = [
67
+ ...(root.nodeType === Node.ELEMENT_NODE && root.hasAttribute?.(SHOW_ATTR) && !inLiveBlock(root)
68
+ ? [root]
69
+ : []),
70
+ ...Array.from(root.querySelectorAll(`[${SHOW_ATTR}]`)).filter((el) => !inLiveBlock(el)),
71
+ ];
72
+
73
+ for (const el of elements) {
74
+ const path = el.getAttribute(SHOW_ATTR);
75
+ if (!path) {
76
+ errors.showMissingPath(el);
77
+ continue;
78
+ }
79
+
80
+ // Capture ONCE: the "original" inline display, unaffected by any toggle.
81
+ // The "show" state always returns to THIS — CSS's own display rule isn't broken.
82
+ const originalDisplay = el.style.display;
83
+
84
+ const apply = (val) => {
85
+ el.style.display = val ? originalDisplay : 'none';
86
+ };
87
+
88
+ apply(store.get(path)); // initial state — before the fragment is added to the DOM (no FOUC)
89
+
90
+ cleanups.push(store.subscribe(path, apply));
91
+ }
92
+
93
+ return function cleanup() {
94
+ for (const unsub of cleanups) unsub();
95
+ cleanups.length = 0; // guard against double cleanup calls
96
+ };
97
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @module bindings
3
+ * Reactive data-text (text) + {x}/data-x (attribute) bindings.
4
+ *
5
+ * SCOPE — THIS MODULE:
6
+ * - data-text="path" → watch store, update el.textContent.
7
+ * - href="/u/{x}" data-x="p" → template + watch store, update attribute.
8
+ *
9
+ * OUT OF SCOPE — next step (bindings-blocks.js / mount.js):
10
+ * Reactive block reactivity (<if data-live>, reactive <for>). These
11
+ * require tear-down/rebuild + inner binding cleanup, so handled separately.
12
+ *
13
+ * CORE RULE:
14
+ * - No data-*: static — template.js already resolved it, this module skips it.
15
+ * - Has data-*: reactive — updates when state changes.
16
+ * - Reactive data comes ONLY from the store; context is not carried here.
17
+ *
18
+ * SECURITY NOTE:
19
+ * - el.textContent: does not parse HTML → escapeHtml NOT needed (written as text).
20
+ * - el.setAttribute: DOM API HTML-encodes itself → safeAttr is WRONG
21
+ * (safeAttr() + setAttribute() = double-encoding: "&amp;" → "&amp;amp;").
22
+ * Therefore setAttribute receives the RAW value.
23
+ * - URL attributes: checked with utils.isSafeUrlProtocol() before setAttribute
24
+ * (shared whitelist with utils.js).
25
+ * - on* (onclick, onerror, ...) attributes never receive reactive data:
26
+ * browsers actually execute event-handler attributes set via setAttribute;
27
+ * this would violate the "no JS execution from templates" principle.
28
+ *
29
+ * RESERVED PLACEHOLDER NAMES (2b):
30
+ * The following names CANNOT be used as {x} placeholders in attribute templates
31
+ * because lime-csr uses data-{name} for its own engine attributes:
32
+ * text, model, show, live, ref, diff — and any name starting with "on-".
33
+ * Detected in setupAttrBindings; errors.reservedAttrName is issued.
34
+ */
35
+
36
+
37
+ import { errors } from './errors.js';
38
+ import { isSafeUrlProtocol } from './utils.js';
39
+ import { inLiveBlock } from './shared.js';
40
+
41
+ // URL attributes: protocol check required before assignment.
42
+ const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'data', 'cite', 'poster', 'ping']);
43
+
44
+ // Event-handler attributes: reactive binding completely refused.
45
+ const EVENT_ATTR_PATTERN = /^on/i;
46
+
47
+ // Reactive attribute placeholder: {key} (different syntax from template.js's ${path})
48
+ const ATTR_PLACEHOLDER = /\{([^}]+)\}/g;
49
+
50
+ // Reserved placeholder names: engine uses data-{name} for its own attributes.
51
+ // Also blocks any name starting with "on-" (would conflict with data-on-{event}).
52
+ const RESERVED_NAMES = new Set(['text', 'model', 'show', 'live', 'ref', 'diff']);
53
+ function isReservedName(name) {
54
+ return RESERVED_NAMES.has(name) || name.startsWith('on-');
55
+ }
56
+
57
+ let refCounter = 0;
58
+ function nextRef() { return `lcsr-${++refCounter}`; }
59
+
60
+ /**
61
+ * Binds every [data-text] element under root to the store.
62
+ * textContent tracks state, not a static value.
63
+ *
64
+ * @param {Element|DocumentFragment} root
65
+ * @param {import('./store.js').Store} store
66
+ * @returns {Array<function(): void>} list of unsubscribe functions
67
+ */
68
+ function setupTextBindings(root, store) {
69
+ const cleanups = [];
70
+
71
+ for (const el of root.querySelectorAll('[data-text]')) {
72
+ // data-text inside <if data-live> and <for data-live> is bound by renderFn.
73
+ // If we bound it here, the old subscription couldn't be cancelled when the
74
+ // branch/block changes → leak.
75
+ if (inLiveBlock(el)) continue;
76
+
77
+ const path = el.getAttribute('data-text');
78
+
79
+ if (!path) {
80
+ errors.bindingMissingPath(el);
81
+ continue;
82
+ }
83
+
84
+ // Write the initial value immediately.
85
+ // textContent does not parse HTML → escapeHtml is not needed.
86
+ el.textContent = store.get(path) ?? '';
87
+
88
+ cleanups.push(
89
+ store.subscribe(path, (val) => {
90
+ el.textContent = val ?? '';
91
+ }),
92
+ );
93
+ }
94
+
95
+ return cleanups;
96
+ }
97
+
98
+ /**
99
+ * Finds attributes under root that contain an {x} placeholder; reads the
100
+ * store path from the matching data-x attribute and binds it reactively.
101
+ *
102
+ * Template storage: the original attribute value ("/u/{handle}") is kept in
103
+ * JS. On every store change, the template is re-filled from scratch — not
104
+ * find-and-replace.
105
+ *
106
+ * The consumed data-x attributes are removed from the DOM once binding is set
107
+ * up (clean output). The data-ref handle stays in the DOM for debugging + cleanup.
108
+ *
109
+ * @param {Element|DocumentFragment} root
110
+ * @param {import('./store.js').Store} store
111
+ * @returns {Array<function(): void>} list of unsubscribe functions
112
+ */
113
+ function setupAttrBindings(root, store) {
114
+ const cleanups = [];
115
+
116
+ // Include root itself in the scan if it's an Element; a DocumentFragment has no attributes.
117
+ // Elements inside <if data-live> and <for data-live> are bound separately by
118
+ // renderFn; if we processed them here, the old subscriptions couldn't be
119
+ // cancelled when the branch/block changes → leak.
120
+
121
+ const elements = [
122
+ ...(root.nodeType === Node.ELEMENT_NODE && !inLiveBlock(root) ? [root] : []),
123
+ ...Array.from(root.querySelectorAll('*')).filter((el) => !inLiveBlock(el)),
124
+ ];
125
+
126
+ for (const el of elements) {
127
+ const attrs = Array.from(el.attributes);
128
+
129
+ // Collect the reactive attribute bindings for this element
130
+ const boundAttrs = []; // { attrName, template, bindings: {key → storePath} }
131
+
132
+ for (const attr of attrs) {
133
+ // data-* attributes are a source, not a target; skip
134
+ if (attr.name.startsWith('data-')) continue;
135
+
136
+ ATTR_PLACEHOLDER.lastIndex = 0;
137
+ if (!ATTR_PLACEHOLDER.test(attr.value)) continue;
138
+
139
+ // onclick/onerror/... : reactive data is never bound to an event handler.
140
+ if (EVENT_ATTR_PATTERN.test(attr.name)) {
141
+ errors.unsafeEventAttr(attr.name, el);
142
+ continue;
143
+ }
144
+
145
+ const template = attr.value;
146
+ const bindings = {}; // {key} → store path
147
+ let allResolved = true;
148
+
149
+ for (const [, key] of template.matchAll(/\{([^}]+)\}/g)) {
150
+ if (key in bindings) continue; // same key may appear more than once
151
+
152
+ // 2b: reserved name check
153
+ if (isReservedName(key)) {
154
+ errors.reservedAttrName(key, el);
155
+ allResolved = false;
156
+ break;
157
+ }
158
+
159
+ const storePath = el.getAttribute(`data-${key}`);
160
+ if (!storePath) {
161
+ errors.bindingMissingDataAttr(attr.name, key, el);
162
+ allResolved = false;
163
+ break;
164
+ }
165
+ bindings[key] = storePath;
166
+ }
167
+
168
+ if (allResolved && Object.keys(bindings).length > 0) {
169
+ boundAttrs.push({ attrName: attr.name, template, bindings });
170
+ }
171
+ }
172
+
173
+ if (boundAttrs.length === 0) continue;
174
+
175
+ // Identification / debug handle — stays in the DOM so updates from the
176
+ // store can find the correct element.
177
+ if (!el.dataset.ref) el.dataset.ref = nextRef();
178
+
179
+ // Remove the consumed data-x attributes from the DOM (data-ref stays).
180
+ const usedKeys = new Set(
181
+ boundAttrs.flatMap(({ bindings: b }) => Object.keys(b)),
182
+ );
183
+ for (const key of usedKeys) el.removeAttribute(`data-${key}`);
184
+
185
+ // Update function + subscription for each attribute
186
+ for (const { attrName, template, bindings } of boundAttrs) {
187
+ // Re-fills the template from scratch with current store values — not find-and-replace.
188
+ const resolve = () => {
189
+ let resolved = template.replace(/\{([^}]+)\}/g, (_, key) =>
190
+ String(store.get(bindings[key]) ?? ''),
191
+ );
192
+
193
+ // URL attribute: block dangerous protocols like javascript:, data:.
194
+ // NOTE: setAttribute does its own HTML-encoding → the RAW value is given.
195
+ if (URL_ATTRS.has(attrName)) {
196
+ resolved = isSafeUrlProtocol(resolved) ? resolved : '';
197
+ }
198
+
199
+ el.setAttribute(attrName, resolved);
200
+ };
201
+
202
+ resolve(); // initial value
203
+
204
+ // Subscribe to every unique store path in the template.
205
+ // The template is re-resolved FROM SCRATCH when any of them changes.
206
+ const uniquePaths = [...new Set(Object.values(bindings))];
207
+ for (const path of uniquePaths) {
208
+ cleanups.push(store.subscribe(path, resolve));
209
+ }
210
+ }
211
+ }
212
+
213
+ return cleanups;
214
+ }
215
+
216
+ /**
217
+ * Sets up every reactive binding (data-text + {x}/data-x) under root.
218
+ * Subscriptions are opened; calling the returned cleanup() cancels all of them.
219
+ *
220
+ * Usage:
221
+ * const cleanup = setupBindings(document.body, store);
222
+ * // ... when the component unmounts:
223
+ * cleanup(); // prevents a memory leak
224
+ *
225
+ * @param {Element|DocumentFragment} root
226
+ * @param {import('./store.js').Store} store
227
+ * @returns {function(): void} cleanup — cancels all subscriptions
228
+ */
229
+ export function setupBindings(root, store) {
230
+ const cleanups = [
231
+ ...setupTextBindings(root, store),
232
+ ...setupAttrBindings(root, store),
233
+ ];
234
+
235
+ return function cleanup() {
236
+ for (const unsub of cleanups) unsub();
237
+ // Empty the list: guard against double cleanup calls
238
+ cleanups.length = 0;
239
+ };
240
+ }