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.
- package/DOCS.md +1456 -0
- package/LICENCE.md +20 -0
- package/README.md +150 -0
- package/dist/index.min.js +1 -0
- package/package.json +53 -0
- package/src/bindings-blocks.js +363 -0
- package/src/bindings-events.js +176 -0
- package/src/bindings-loops.js +568 -0
- package/src/bindings-model.js +262 -0
- package/src/bindings-show.js +97 -0
- package/src/bindings.js +240 -0
- package/src/conditionals.js +153 -0
- package/src/errors.js +414 -0
- package/src/index.js +312 -0
- package/src/loops.js +117 -0
- package/src/partials.js +158 -0
- package/src/shared.js +103 -0
- package/src/store.js +265 -0
- package/src/template.js +263 -0
- package/src/utils.js +84 -0
|
@@ -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
|
+
}
|
package/src/bindings.js
ADDED
|
@@ -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;").
|
|
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
|
+
}
|