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 ADDED
@@ -0,0 +1,1456 @@
1
+ # lime-csr.js — Technical Reference
2
+
3
+ This is the full technical reference for lime-csr.js: every template feature,
4
+ the Store API, the Mount API, lifecycle hooks, every dev-mode error code, and
5
+ the known limitations of the engine. It assumes you've read the philosophy
6
+ and quick-feature-tour in [README.md](README.md) — content that already
7
+ lives there (why lime-csr exists, the Alpine.js comparison) is **not
8
+ repeated here**.
9
+
10
+ Every code example on this page is written against the real, current API in
11
+ `src/`. Documentation and implementation are expected to agree. A mismatch is
12
+ a bug; please report it with a minimal reproduction.
13
+
14
+ ## Table of contents
15
+
16
+ 1. [Quick start](#1-quick-start)
17
+ 2. [Core concept](#2-core-concept)
18
+ 3. [Template syntax](#3-template-syntax)
19
+ - [3.1 `${path}` — static interpolation](#31-path--static-interpolation)
20
+ - [3.2 `data-text` — reactive text](#32-data-text--reactive-text)
21
+ - [3.3 `{x}` / `data-x` — reactive attribute binding](#33-x--data-x--reactive-attribute-binding)
22
+ - [3.4 `data-model` — two-way form binding](#34-data-model--two-way-form-binding)
23
+ - [3.5 `data-show` — visibility toggle](#35-data-show--visibility-toggle)
24
+ - [3.6 `<if>` / `<else>` — conditional rendering](#36-if--else--conditional-rendering)
25
+ - [3.7 `<if data-live>` — reactive conditional](#37-if-data-live--reactive-conditional)
26
+ - [3.8 `<for each as>` — static list rendering](#38-for-each-as--static-list-rendering)
27
+ - [3.9 `<for data-live>` — reactive list rendering](#39-for-data-live--reactive-list-rendering)
28
+ - [3.10 `<partial>` — template composition](#310-partial--template-composition)
29
+ - [3.11 `data-on-*` — event handling](#311-data-on---event-handling)
30
+ 4. [Store API](#4-store-api)
31
+ 5. [Mount API](#5-mount-api)
32
+ 6. [Lifecycle hooks](#6-lifecycle-hooks)
33
+ 7. [Error codes](#7-error-codes)
34
+ 8. [Known limitations](#8-known-limitations)
35
+ 9. [Architecture (reference)](#9-architecture-reference)
36
+
37
+ ---
38
+
39
+ ## 1. Quick start
40
+
41
+ One HTML file. No build, no npm install, no config file. Save this, open it
42
+ in a browser (or serve it — `file://` works fine for `<script type="module">`
43
+ as long as your browser allows local module imports), and it runs.
44
+
45
+ ```html
46
+ <!DOCTYPE html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="UTF-8">
50
+ <title>lime-csr quick start</title>
51
+ </head>
52
+ <body>
53
+ <div id="app"></div>
54
+
55
+ <template id="tpl-hello">
56
+ <h1>Hello, ${name}!</h1>
57
+ </template>
58
+
59
+ <script type="module">
60
+ import { createStore, mount } from './src/index.js';
61
+
62
+ const store = createStore({});
63
+ mount('hello', { name: 'World' }, document.getElementById('app'), store);
64
+ </script>
65
+ </body>
66
+ </html>
67
+ ```
68
+
69
+ That's it. No webpack, no Vite, no `package.json`, no `node_modules`. The
70
+ `<template id="tpl-hello">` is standard HTML; `${name}` is resolved once from
71
+ the context object (`{ name: 'World' }`) you pass to `mount()`; the result is
72
+ appended into `#app`.
73
+
74
+ For anything reactive — text/attributes/lists that update when data
75
+ changes — see [§3 Template syntax](#3-template-syntax); everything reactive
76
+ lives in the `store` (the 4th argument), not in the context object.
77
+
78
+ ---
79
+
80
+ ## 2. Core concept
81
+
82
+ The entire engine rests on one rule:
83
+
84
+ > **No `data-*` (or special tag) → the engine never touches the element. It
85
+ > stays plain, static HTML.**
86
+
87
+ | | No `data-*` | Has `data-*` |
88
+ |---------------------|---------------------------------------|------------------------------------------|
89
+ | Source | **context** (a plain JS object) | **store** (`createStore(...)`) |
90
+ | Resolved | Once, at render time | Continuously — re-applied on every change |
91
+ | Syntax | `${path}` | `data-text`, `{x}`/`data-x`, `data-model`, `data-show`, `data-live`, `data-on-*` |
92
+ | Watched afterward? | Never | Yes, via `store.subscribe` |
93
+
94
+ Special tags (`<if>`, `<for>`, `<partial>`) follow the same split: without
95
+ `data-live`, they resolve once from context and leave no trace in the final
96
+ DOM; with `data-live`, they subscribe to the store and re-run on change.
97
+
98
+ ### Engine flow
99
+
100
+ ```
101
+ mount(name, context, target, store, options)
102
+
103
+
104
+ ┌────────────────────────────┐
105
+ │ 1. STRUCTURAL PIPELINE │ <partial> → <for> → <if>/<else>
106
+ │ (reads context only) │ looped until no special tag remains
107
+ └──────────────┬───────────────┘
108
+
109
+
110
+ ┌────────────────────────────┐
111
+ │ 2. STATIC INTERPOLATION │ remaining top-level ${path} resolved
112
+ │ (reads context only) │ once, from context
113
+ └──────────────┬───────────────┘
114
+
115
+
116
+ ┌────────────────────────────┐
117
+ │ 3. REACTIVE BINDINGS │ data-model → data-text/{x} → data-show
118
+ │ (reads + subscribes to │ <for data-live> → <if data-live>
119
+ │ the store) │ (nested live-blocks recurse through
120
+ │ │ this same pipeline per branch/item)
121
+ └──────────────┬───────────────┘
122
+
123
+
124
+ target.appendChild(fragment) ← nothing is visible before this point
125
+
126
+
127
+ ┌────────────────────────────┐
128
+ │ 4. EVENT DELEGATION │ data-on-* — only if options.handlers
129
+ │ (outside the pipeline) │ was given to mount()
130
+ └──────────────┬───────────────┘
131
+
132
+
133
+ ╔════════════════════════╗
134
+ ║ store.set(path, value) ║ ──▶ notify subscribers ──▶ only the
135
+ ╚════════════════════════╝ bound DOM nodes update — nothing
136
+ else re-renders
137
+ ```
138
+
139
+ The exact rationale for each step's position in this order is in
140
+ [§9 Architecture](#9-architecture-reference).
141
+
142
+ ---
143
+
144
+ ## 3. Template syntax
145
+
146
+ The template syntax is split into two categories to help you learn and prioritize:
147
+ - **Essentials (start here)**: `${path}` (§3.1), `data-text` (§3.2), `data-model` (§3.4), `<if>`/`<else>` (§3.6), `<for>` (§3.8), and `<partial>` (§3.10). These six cover most typical use cases.
148
+ - **Advanced (reach for when needed)**: `{x}`/`data-x` (§3.3), `data-show` (§3.5), `<if data-live>` (§3.7), `<for data-live>` (§3.9), `data-on-*` (§3.11), lifecycle hooks (§6), container mode (`el=`), and `store.computed` (§4.6).
149
+
150
+ ### 3.1 `${path}` — static interpolation
151
+
152
+ Prints a value from **context** once; never watched again.
153
+
154
+ **Syntax**
155
+ ```html
156
+ <h1>${path}</h1>
157
+ <a href="/posts/${post.slug}">${post.title}</a>
158
+ ```
159
+
160
+ **Parameters**
161
+ - `path` (string): a dotted path, e.g. `post.title`. Resolved via
162
+ `getByPath` against the **context** object passed to `render`/`mount` —
163
+ never the store. Missing/`null`/`undefined` resolves to an empty string
164
+ (no crash, no warning).
165
+
166
+ **Behavior**
167
+ `${...}` is only ever a path — never an expression. It's resolved by
168
+ `resolveStatic` after all structural tags (`<partial>`/`<for>`/`<if>`) are
169
+ expanded, so `${item.x}` inside a `<for>` correctly sees the loop's item
170
+ context. Resolved values are written raw into `textContent`/attribute
171
+ values — since neither of those parses HTML, this is XSS-safe without any
172
+ extra escaping.
173
+
174
+ **Example**
175
+ ```html
176
+ <template id="tpl-card">
177
+ <div class="card">
178
+ <strong>${user.name}</strong>
179
+ <span>${user.role}</span>
180
+ </div>
181
+ </template>
182
+ ```
183
+ ```js
184
+ mount('card', { user: { name: 'Ada', role: 'Engineer' } }, target, store);
185
+ ```
186
+
187
+ **⚠ Common mistakes**
188
+ ```
189
+ WRONG: <p>${count + 1}</p>
190
+ -- Not an expression. "count + 1" is treated as a literal path
191
+ segment, which won't exist in context → renders as empty.
192
+
193
+ RIGHT: <p>${incrementedCount}</p>
194
+ -- Compute it in JS first and put the result in context:
195
+ mount('page', { incrementedCount: count + 1 }, target, store)
196
+ ```
197
+ ```
198
+ WRONG: <span>${user.name}</span>
199
+ store.set('user.name', 'New Name'); // <-- nothing happens on screen
200
+
201
+ RIGHT: <span data-text="user.name"></span>
202
+ store.set('user.name', 'New Name'); // <-- updates automatically
203
+ -- ${...} is resolved ONCE from context and never re-evaluated.
204
+ Use data-text (§3.2) for anything that needs to react to state.
205
+ ```
206
+
207
+ ---
208
+
209
+ ### 3.2 `data-text` — reactive text
210
+
211
+ Binds an element's text content to a **store** path.
212
+
213
+ **Syntax**
214
+ ```html
215
+ <span data-text="path"></span>
216
+ ```
217
+
218
+ **Parameters**
219
+ - `data-text` (string, required): a **store** path (not context). Empty →
220
+ `BINDING_MISSING_PATH` warning, no binding is set up.
221
+
222
+ **Behavior**
223
+ Sets `el.textContent = store.get(path)` immediately, then subscribes to
224
+ `path` — every subsequent `store.set(path, ...)` updates the text. Uses
225
+ `el.textContent`, never `innerHTML`: the value is never parsed as HTML, so
226
+ it can't inject markup or scripts — no manual escaping is needed or wanted.
227
+ The same store path can be bound on multiple elements; all of them update
228
+ together. `null`/`undefined` render as an empty string.
229
+
230
+ **Example**
231
+ ```html
232
+ <span data-text="likeCount"></span> likes
233
+ ```
234
+ ```js
235
+ const store = createStore({ likeCount: 12 });
236
+ mount('page', {}, target, store);
237
+ store.set('likeCount', 13); // the span updates automatically
238
+ ```
239
+
240
+ **⚠ Common mistakes**
241
+ ```
242
+ WRONG: <span data-text="post.title"></span>
243
+ mount('page', { post: { title: 'Hello' } }, target, store);
244
+ -- Renders empty. data-text ALWAYS reads from the STORE, never
245
+ context — "post" only exists in context here, not in the store.
246
+
247
+ RIGHT: const store = createStore({ post: { title: 'Hello' } });
248
+ mount('page', {}, target, store);
249
+ <span data-text="post.title"></span>
250
+ -- Put reactive data in the store. If it's genuinely static and
251
+ never needs to change, use ${post.title} (§3.1) instead.
252
+ ```
253
+
254
+ ---
255
+
256
+ ### 3.3 `{x}` / `data-x` — reactive attribute binding
257
+
258
+ Binds a placeholder inside an attribute value to a **store** path via a
259
+ matching `data-{name}` attribute.
260
+
261
+ **Syntax**
262
+ ```html
263
+ <a href="/user/{handle}" data-handle="user.handle">Profile</a>
264
+ ```
265
+
266
+ **Parameters**
267
+ - `{name}` — a placeholder inside any attribute value (except `data-*`
268
+ attributes themselves, which are the binding's *source*, not target).
269
+ - `data-{name}` (string, required) — the matching store path. Missing →
270
+ `BINDING_MISSING_DATA_ATTR` warning, that binding is skipped.
271
+ - `name` must NOT be a reserved word: `text`, `model`, `show`, `live`,
272
+ `ref`, `diff`, or anything starting with `on-` → `RESERVED_ATTR_NAME`
273
+ warning, binding rejected.
274
+
275
+ **Behavior**
276
+ Name-matching, not position-matching: `{handle}` is fed by `data-handle`.
277
+ An attribute can hold multiple placeholders (`href="/u/{a}/post/{b}"`) —
278
+ each needs its own `data-a`/`data-b`. The **whole attribute template is
279
+ kept in memory** and **re-filled from scratch** (not find-and-replace)
280
+ whenever ANY of its referenced store paths changes — this is what makes
281
+ combining static text and multiple reactive placeholders in one attribute
282
+ value correct after a partial update. Once binding is set up, the consumed
283
+ `data-{name}` attributes are removed from the DOM (clean output); a
284
+ `data-ref="lcsr-N"` handle is added in their place for internal bookkeeping
285
+ — you don't write `data-ref` yourself. If the target attribute is one of
286
+ `href`, `src`, `action`, `formaction`, `data`, `cite`, `poster`, `ping`, the
287
+ resolved value is checked against a URL protocol whitelist
288
+ (`http(s)://`, root-relative `/...`, `#...`) before being written —
289
+ `javascript:`/`data:`/other dangerous schemes resolve to an empty string
290
+ instead of being set.
291
+
292
+ **Example**
293
+ ```html
294
+ <a href="/user/{a}/post/{b}" data-a="user.handle" data-b="post.id">View post</a>
295
+ ```
296
+
297
+ **⚠ Common mistakes**
298
+ ```
299
+ WRONG: <a href="{link}" data-link="dangerousUrl"></a>
300
+ store.set('dangerousUrl', 'javascript:alert(1)');
301
+ -- href silently becomes "" (protocol rejected). Not an error, not
302
+ a warning — just blocked. Don't rely on this attribute "working"
303
+ for arbitrary store-controlled URLs; treat it as protocol-filtered.
304
+
305
+ RIGHT: Only feed URL attributes with values you've validated are meant to
306
+ be links (root-relative paths, http(s) URLs, #anchors).
307
+ ```
308
+ ```
309
+ WRONG: <span title="{text}" data-text="msg"></span>
310
+ -- "text" is a RESERVED placeholder name → RESERVED_ATTR_NAME
311
+ warning, this binding never gets set up.
312
+
313
+ RIGHT: <span title="{msg}" data-msg="msg"></span>
314
+ -- Reserved names: text, model, show, live, ref, diff, and anything
315
+ starting with "on-". Pick a different placeholder name.
316
+ ```
317
+
318
+ ---
319
+
320
+ ### 3.4 `data-model` — two-way form binding
321
+
322
+ Binds a form element to a store path in **both** directions: typing/checking
323
+ writes to the store, and the store changing updates the element.
324
+
325
+ **Syntax**
326
+ ```html
327
+ <input type="text" data-model="path">
328
+ ```
329
+
330
+ **Parameters**
331
+ - `data-model` (string, required): a store path. Empty → `MODEL_MISSING_PATH`
332
+ warning.
333
+
334
+ **Behavior** is per input kind — each binds a different DOM event and reads/writes a different shape:
335
+
336
+ #### text / textarea (default)
337
+ ```html
338
+ <input type="text" data-model="user.name">
339
+ <textarea data-model="post.body"></textarea>
340
+ ```
341
+ Event: `input`. Writes `el.value` (string) to the store on every keystroke;
342
+ writes back to `el.value` on external store changes (subject to the cursor
343
+ protection below).
344
+
345
+ #### number / range
346
+ ```html
347
+ <input type="number" data-model="age">
348
+ <input type="range" min="0" max="100" data-model="volume">
349
+ ```
350
+ Event: `input`. Writes `Number(el.value)` to the store — so a numeric
351
+ comparison like `is-gt="age" than="18"` works directly on it. Exception: if
352
+ the field is empty or not yet a valid number (e.g. `"-"` or `"1."` mid-typing),
353
+ the **raw string** is stored instead, so a half-typed value is never
354
+ silently lost or coerced to `0`/`NaN`.
355
+
356
+ #### checkbox
357
+ ```html
358
+ <input type="checkbox" data-model="agree">
359
+ ```
360
+ Event: `change`. Writes `el.checked` (boolean) to the store; the box is
361
+ checked/unchecked to match the store's truthiness.
362
+
363
+ #### radio
364
+ ```html
365
+ <input type="radio" name="plan" value="free" data-model="plan">
366
+ <input type="radio" name="plan" value="pro" data-model="plan">
367
+ ```
368
+ Event: `change`. All radios sharing the same `data-model` path form a
369
+ group bound with **one shared subscription** (not one per radio) — the
370
+ checked radio's `value` is written to the store; setting the store path
371
+ checks whichever radio's `value` matches and unchecks the rest.
372
+
373
+ #### select (single)
374
+ ```html
375
+ <select data-model="city">
376
+ <option value="ist">Istanbul</option>
377
+ <option value="ank">Ankara</option>
378
+ </select>
379
+ ```
380
+ Event: `change`. Writes `el.value` (string) to the store, same as text inputs.
381
+
382
+ #### select (multiple)
383
+ ```html
384
+ <select multiple data-model="tags">
385
+ <option value="a">A</option>
386
+ <option value="b">B</option>
387
+ </select>
388
+ ```
389
+ Event: `change`. Writes an ARRAY of the selected options' `value`s to the
390
+ store; setting the store path to an array selects the matching options and
391
+ deselects the rest.
392
+
393
+ **Cursor protection** (applies to all kinds above): writing from the store
394
+ back to the DOM is skipped if the element's current value already matches
395
+ (`el.value === next`) — this is what keeps the text cursor from jumping to
396
+ the end while the user is typing (the store-set that a keystroke itself
397
+ triggers would otherwise immediately write the "same" value back).
398
+
399
+ **Example**
400
+ ```html
401
+ <input type="text" data-model="user.name">
402
+ <input type="number" data-model="age">
403
+ <input type="checkbox" data-model="agree">
404
+ <input type="radio" name="plan" value="free" data-model="plan">
405
+ <input type="radio" name="plan" value="pro" data-model="plan">
406
+ <select data-model="city"><option value="ist">Istanbul</option></select>
407
+ <select multiple data-model="tags"><option value="a">A</option></select>
408
+ ```
409
+
410
+ **⚠ Common mistakes**
411
+ ```
412
+ WRONG: <input data-model="items.2.name">
413
+ -- INDEXED_MODEL_PATH warning. If "items" is ever reordered or an
414
+ earlier item removed, index 2 silently starts pointing at the
415
+ WRONG item (the path doesn't move with the data).
416
+
417
+ RIGHT: Use a reactive <for data-live key="item.id"> loop and bind
418
+ data-model to the LOOP VARIABLE's own field instead of a
419
+ store-array-index path:
420
+ <for each="items" as="item" key="item.id" data-live>
421
+ <input data-model="???"> <!-- see note below -->
422
+ </for>
423
+ -- data-model still needs a STORE path, so in practice this means
424
+ giving each item its own addressable store location (e.g. a
425
+ store keyed by id: store.set(`items.byId.${item.id}.name`, …))
426
+ rather than a positional array index.
427
+ ```
428
+ ```
429
+ WRONG: const list = store.get('todos');
430
+ list.push(newTodo);
431
+ store.set('todos', list); // <-- SAME array reference as before
432
+ -- IN_PLACE_MUTATION warning; store.set() returns false and NO
433
+ subscriber fires (Object.is(existing, value) is true — the
434
+ store can't tell anything changed). Any data-model/data-text/
435
+ <for data-live> bound to "todos" silently does not update.
436
+
437
+ RIGHT: store.set('todos', [...store.get('todos'), newTodo]);
438
+ -- Always pass a NEW array/object reference on mutation.
439
+ ```
440
+
441
+ ---
442
+
443
+ ### 3.5 `data-show` — visibility toggle
444
+
445
+ Shows/hides an element via CSS `display`, without ever removing it from the DOM.
446
+
447
+ **Syntax**
448
+ ```html
449
+ <div data-show="path">...</div>
450
+ ```
451
+
452
+ **Parameters**
453
+ - `data-show` (string, required): a store path. Empty → `SHOW_MISSING_PATH`
454
+ warning.
455
+
456
+ **Behavior**
457
+ The element is visible if `store.get(path)` is truthy, `display:none` if
458
+ falsy — always reactive (there's no static/one-time variant; if you want
459
+ that, just write plain CSS). The element's inline `display` value **at
460
+ setup time** is captured once (`originalDisplay`); the "show" state always
461
+ restores exactly that value — so it never hardcodes `display:block` and
462
+ breaks a `flex`/`grid`/`inline-block` layout. Runs before the fragment is
463
+ appended to the DOM, so if the initial value is falsy, the element is
464
+ already hidden before it's ever visible — no flash of unstyled/unhidden
465
+ content (FOUC).
466
+
467
+ Unlike `<if data-live>` (§3.7), the element is **never removed** — its DOM
468
+ identity, any input values inside it, scroll position, and CSS transition
469
+ state all survive a toggle. This is the right tool for modals, accordions,
470
+ and tabs.
471
+
472
+ **Example**
473
+ ```html
474
+ <div class="modal" data-show="isModalOpen" style="display:flex">
475
+ <input type="text" placeholder="stays intact across toggles">
476
+ </div>
477
+ ```
478
+
479
+ **⚠ Common mistakes**
480
+ ```
481
+ WRONG: <div data-show="count > 0"></div>
482
+ -- Not an expression. "count > 0" is treated as a literal store path,
483
+ which won't exist -> resolves to falsy (hidden).
484
+
485
+ RIGHT: store.computed('hasCount', ['count'], () => store.get('count') > 0);
486
+ <div data-show="hasCount"></div>
487
+ -- Precompute the condition with a computed path in the store.
488
+ ```
489
+ ```
490
+ WRONG: <div data-show="user.name"></div>
491
+ mount('page', { user: { name: 'Ada' } }, target, store);
492
+ -- Renders hidden. data-show reads from the STORE, never context.
493
+
494
+ RIGHT: const store = createStore({ user: { name: 'Ada' } });
495
+ <div data-show="user.name"></div>
496
+ -- Put reactive data in the store.
497
+ ```
498
+
499
+ ---
500
+
501
+ ### 3.6 `<if>` / `<else>` — conditional rendering
502
+
503
+ Statically selects one of two content branches based on a condition
504
+ evaluated against **context**.
505
+
506
+ **Syntax**
507
+ ```html
508
+ <if is-gt="path" than="value">
509
+ ...then content...
510
+ <else>
511
+ ...else content...
512
+ </else>
513
+ </if>
514
+ ```
515
+
516
+ **Parameters** — exactly one operator attribute, plus `than`/`to` for the
517
+ right-hand side (`is-truthy` ignores it):
518
+
519
+ | Operator | Meaning |
520
+ |---|---|
521
+ | `is-gt` | left `>` right (numeric) |
522
+ | `is-lt` | left `<` right (numeric) |
523
+ | `is-gte` | left `>=` right (numeric) |
524
+ | `is-lte` | left `<=` right (numeric) |
525
+ | `is-eq` | left `===` right (string comparison) |
526
+ | `is-neq` | left `!==` right (string comparison) |
527
+ | `is-truthy` | `Boolean(left)` — no right-hand side |
528
+
529
+ The operator's attribute VALUE is a **context** path (`is-gt="score"` reads
530
+ `context.score`); `than`/`to` is a raw **literal** string, never a path.
531
+
532
+ **Behavior**
533
+ `<else>` is a **wrapper**, not a self-closing marker — `<else></else>`.
534
+ Among `<if>`'s DIRECT children, everything EXCEPT the `<else>` element is
535
+ the "then" group; `<else>`'s own children are the "else" group. Neither
536
+ `<if>` nor `<else>` remain in the final DOM — only the winning content is
537
+ left in place. Processing is outer-to-inner (an inner `<if>` waits until
538
+ its ancestor `<if>` resolves). If content follows `<else>` at the same
539
+ level, a tolerant warning (`ELSE_AFTER_CONTENT`) is issued but it still
540
+ works (that content is simply counted as "then").
541
+
542
+ **Example**
543
+ ```html
544
+ <if is-gt="commentCount" than="0">
545
+ <p>${commentCount} comments</p>
546
+ <else>
547
+ <p>No comments yet.</p>
548
+ </else>
549
+ </if>
550
+ ```
551
+
552
+ **⚠ Common mistakes**
553
+ ```
554
+ WRONG: <if is-truthy="loggedIn">
555
+ <p>Welcome</p>
556
+ <else/>
557
+ <p>Please log in</p>
558
+ </if>
559
+ -- HTML5 does not treat <else/> as a void element. The parser
560
+ swallows everything after it (including the real content and
561
+ the closing </if>) INTO the self-closed <else>, corrupting the
562
+ structure in ways that are hard to predict.
563
+
564
+ RIGHT: <if is-truthy="loggedIn">
565
+ <p>Welcome</p>
566
+ <else>
567
+ <p>Please log in</p>
568
+ </else>
569
+ </if>
570
+ -- Always write <else>...</else> as a full wrapper.
571
+ ```
572
+ ```
573
+ WRONG: <div>
574
+ <if is-truthy="x">
575
+ </div>
576
+ <p>content</p>
577
+ </if>
578
+ -- <else>/<if> must nest cleanly within normal HTML structure; an
579
+ <if> spanning across an unrelated element's boundary produces
580
+ unpredictable DOM (the parser will auto-close/reparent things).
581
+
582
+ RIGHT: Keep <if>...</if> (and any <else> inside it) fully nested within a
583
+ single parent element, like any other HTML tag pair.
584
+ ```
585
+
586
+ Also see [§9](#9-architecture-reference) for the `<table>` **foster-parenting**
587
+ trap: an `<if>`/`<for>`/`<else>` written directly inside `<table>` (not
588
+ inside a `<tr>`/`<td>`) gets silently relocated by the HTML parser itself,
589
+ before lime-csr ever sees it. Detected in dev-mode (`TABLE_FOSTER_PARENTING`),
590
+ not fixable at the engine level — move the tag outside `<table>` instead.
591
+
592
+ ---
593
+
594
+ ### 3.7 `<if data-live>` — reactive conditional
595
+
596
+ The reactive counterpart of `<if>`: re-evaluates and swaps branches
597
+ whenever the condition's **store** path changes.
598
+
599
+ **Syntax**
600
+ ```html
601
+ <if is-gt="path" than="value" data-live>
602
+ ...
603
+ <else>...</else>
604
+ </if>
605
+ ```
606
+
607
+ **Parameters** — same operator/`than`/`to` rules as `<if>` (§3.6), except
608
+ the operator's LEFT side is now a **store** path (not context), plus:
609
+
610
+ - `data-live` (empty or a path): empty → tracks the operator's own path
611
+ (`is-gt="count"` → watches `"count"`). Set to an explicit path
612
+ (`data-live="x"`) to watch something other than/in addition to the
613
+ operator's own path — needed if the condition depends on more than one
614
+ store value.
615
+ - `el` (optional, tag name): wraps branch content in a persistent
616
+ container element instead of bare comment anchors — see "Container mode" below.
617
+ - `data-after` / `data-before` (optional, handler names): see [§6](#6-lifecycle-hooks).
618
+
619
+ **Behavior**
620
+ `than`/`to` is **always** static/literal, even if it looks like a path —
621
+ it is never tracked from the store. When the condition changes, the ACTIVE
622
+ BRANCH IS COMPLETELY TORN DOWN and rebuilt: input focus, scroll position,
623
+ and any DOM identity inside it are lost. If the condition re-evaluates to
624
+ the SAME truthiness as before, nothing happens (no teardown, no hooks fire).
625
+
626
+ **Container mode (`el="tag"`)**: without `el`, the branch is placed between
627
+ two fixed HTML comment anchors (`<!-- live-if:ref --> ... <!-- /live-if:ref -->`).
628
+ With `el="div"` (or any tag), branch content instead goes inside a real
629
+ `<div>` element that is **created once and never recreated** across
630
+ switches — useful when you need one stable DOM node to attach a
631
+ CSS transition class to, or to pass to a third-party widget. Any
632
+ non-reserved attribute on `<if>` (e.g. `class`, `id`) is copied onto that
633
+ container.
634
+
635
+ **Example**
636
+ ```html
637
+ <if is-gt="commentCount" than="0" data-live>
638
+ <p><span data-text="commentCount"></span> comments so far.</p>
639
+ <else>
640
+ <p>Be the first to comment!</p>
641
+ </else>
642
+ </if>
643
+ ```
644
+ ```html
645
+ <!-- container mode, for a chart that needs a stable mount point -->
646
+ <if is-truthy="showChart" data-live el="div" class="chart-box"
647
+ data-after="initChart" data-before="destroyChart">
648
+ <canvas></canvas>
649
+ </if>
650
+ ```
651
+
652
+ **⚠ Common mistakes**
653
+ ```
654
+ WRONG: <if is-gt="user.score" than="user.limit" data-live>
655
+ -- "user.limit" is NOT tracked — than/to is always a literal. If
656
+ user.limit changes in the store, this <if> does NOT re-evaluate,
657
+ even though it looks like a reactive comparison of two paths.
658
+
659
+ RIGHT: store.computed('scoreOverLimit', ['user.score', 'user.limit'],
660
+ () => store.get('user.score') > store.get('user.limit'));
661
+ <if is-truthy="scoreOverLimit" data-live>
662
+ -- Precompute the comparison into a single derived path (§4.6) and
663
+ track THAT with data-live.
664
+ ```
665
+
666
+ ---
667
+
668
+ ### 3.8 `<for each as>` — static list rendering
669
+
670
+ Renders a list once, from a **context** array; never updates afterward.
671
+
672
+ **Syntax**
673
+ ```html
674
+ <for each="path" as="item">
675
+ ...
676
+ </for>
677
+ ```
678
+
679
+ **Parameters**
680
+ - `each` (string, required): a context path resolving to an array.
681
+ - `as` (string, required): the variable name each item is bound to inside the loop.
682
+ - `index` (string, optional): if given, also binds the item's 0-based
683
+ position under this name.
684
+
685
+ **Behavior**
686
+ **Inherited context** — the defining difference from `<partial>` (§3.10):
687
+ the parent context is preserved, `as` (and `index`) is merely added on top
688
+ (`{ ...context, [as]: item }`). Accessing outer variables
689
+ (`${post.title}` inside a `<for>` over `post.comments`) works naturally. In
690
+ a nested `<for>`, the inner loop's `as` shadows the outer one for its own
691
+ scope. An empty array removes `<for>` silently (not an error); if `each`
692
+ doesn't resolve to an array at all, a warning (`FOR_NOT_ARRAY`) is issued
693
+ and `<for>` is removed.
694
+
695
+ **Example**
696
+ ```html
697
+ <for each="comments" as="comment" index="i">
698
+ <p>${i}. ${comment.body} — by ${author.name}</p>
699
+ <!-- "author" here comes from the OUTER context, not from "comment" -->
700
+ </for>
701
+ ```
702
+
703
+ **⚠ Common mistakes**
704
+ ```
705
+ WRONG: <for each="items" as="item">
706
+ <li><span data-text="item.name"></span></li>
707
+ </for>
708
+ mount('page', {}, target, store);
709
+ -- data-text attempts to read from the store, but static <for> only
710
+ binds "item" in the static context. Inside the store, there is
711
+ no path named "item.name" -> renders empty.
712
+
713
+ RIGHT: <for each="items" as="item">
714
+ <li><span>${item.name}</span></li>
715
+ </for>
716
+ -- Use static interpolation ${item.name} for static loops. If the list
717
+ needs to be reactive, use `<for data-live>` instead.
718
+ ```
719
+ ```
720
+ WRONG: <for each="todos" as="todo">
721
+ <li>${todo.text}</li>
722
+ </for>
723
+ store.set('todos', [...]); // expecting DOM to update
724
+ -- Since the loop lacks the `data-live` attribute, it is parsed once
725
+ from context and never updates on store changes.
726
+
727
+ RIGHT: <for each="todos" as="todo" key="todo.id" data-live>
728
+ <li>${todo.text}</li>
729
+ </for>
730
+ -- Use data-live and key to make a loop reactive.
731
+ ```
732
+
733
+ ---
734
+
735
+ ### 3.9 `<for data-live>` — reactive list rendering
736
+
737
+ The reactive counterpart of `<for>`: updates the DOM via a key-based diff
738
+ whenever the **store** array changes.
739
+
740
+ **Syntax**
741
+ ```html
742
+ <for each="path" as="item" key="item.idPath" data-live>
743
+ ...
744
+ </for>
745
+ ```
746
+
747
+ **Parameters** — same `each`/`as`/`index` as §3.8 (now `each` is a **store**
748
+ path), plus:
749
+
750
+ - `key` (string, **required**): a path, evaluated per item, that uniquely
751
+ identifies it (e.g. `item.id`). Missing → `FOR_MISSING_KEY` warning,
752
+ `<for>` is removed WITHOUT becoming reactive (fails safe, doesn't guess).
753
+ A key value that collides with another item's → `FOR_DUPLICATE_KEY`
754
+ warning, the duplicate is skipped.
755
+ - `data-diff` (optional: `simple` | `lcs` | `replace`; default `simple`):
756
+ which reconcile strategy to use (see table below). An unrecognized value
757
+ → `UNKNOWN_DIFF_STRATEGY` warning, falls back to `simple`.
758
+ - `el` (optional, tag name): wraps ALL item blocks in one persistent
759
+ container — same mechanism as `<if data-live el=...>` (§3.7), but here it
760
+ wraps the whole list, not each item.
761
+ - `data-after` / `data-before` (optional, handler names): fire **per item**
762
+ added/removed — see [§6](#6-lifecycle-hooks).
763
+
764
+ **Identity is always preserved for surviving keys**: the same `key` always
765
+ maps to the same DOM node, moved via `insertBefore` when its position
766
+ changes — never destroyed and recreated. If a field inside an item
767
+ genuinely changes, reflect that through a reactive binding
768
+ (`data-text`/`{x}`) inside the item template — the loop itself never
769
+ re-renders a surviving key just because the underlying object reference
770
+ changed (most immutable-update patterns build fresh objects on every
771
+ change even when content is identical, so reference-equality is not a
772
+ reliable signal of "this item's content changed").
773
+
774
+ **Diff strategies:**
775
+
776
+ | `data-diff` | What it does | When to use |
777
+ |---|---|---|
778
+ | *(omitted)* / `simple` | Forward pass; each item is left alone if already immediately after the previous one, otherwise moved to the end. Cheap, correct, but only catches LOCALLY-adjacent no-ops — one item moved far can cascade into moving everything after it. | Default. Fine for most lists, especially ones that mostly append/remove rather than reorder. |
779
+ | `lcs` | Computes the Longest Increasing Subsequence of survivors' old positions; only items OUTSIDE that subsequence are moved — a globally minimal set of DOM operations for the given reorder. | Lists with frequent, non-trivial reordering (drag-to-reorder, sortable columns) where minimizing DOM churn/focus loss matters. |
780
+ | `replace` | No diffing at all — every existing item is torn down (`cleanup()` + DOM removal) and the whole list is rendered from scratch, every single reconcile. Identity is NEVER preserved, even for a key that "didn't change." | Very large lists where per-item DOM identity doesn't matter (e.g. a read-only log viewer) and the bookkeeping cost of diffing isn't worth it. |
781
+
782
+ Regardless of strategy, an **append fast-path** applies automatically
783
+ whenever the old key list is an exact prefix of the new one (the common
784
+ "new item(s) added to the end" case): only the new tail items are rendered
785
+ and inserted — zero cost for the untouched prefix, and neither `simple` nor
786
+ `lcs`'s full algorithm even runs. `replace` does not use this fast-path (by
787
+ design — it always rebuilds everything). None of this needs to be turned on
788
+ explicitly; it's automatic.
789
+
790
+ **Example**
791
+ ```html
792
+ <for each="comments" as="comment" key="comment.id" data-live data-diff="lcs">
793
+ <partial name="comment" data="comment"></partial>
794
+ </for>
795
+ ```
796
+
797
+ **⚠ Common mistakes**
798
+ ```
799
+ WRONG: const items = store.get('items');
800
+ items.push(newItem);
801
+ store.set('items', items); // <-- SAME array reference
802
+ -- IN_PLACE_MUTATION warning; store.set() returns false, no
803
+ subscriber fires — the list silently does not update.
804
+
805
+ RIGHT: store.set('items', [...store.get('items'), newItem]);
806
+ ```
807
+ ```
808
+ WRONG: <for each="items" as="item" data-live>
809
+ <li>${item.name}</li>
810
+ </for>
811
+ -- FOR_MISSING_KEY warning; <for> is removed WITHOUT becoming
812
+ reactive (renders once, like a static <for>, then never updates).
813
+
814
+ RIGHT: <for each="items" as="item" key="item.id" data-live>
815
+ <li>${item.name}</li>
816
+ </for>
817
+ ```
818
+ ```
819
+ WRONG: <for each="items" as="item" key="item.category" data-live>
820
+ -- if two items share the same category, FOR_DUPLICATE_KEY warns
821
+ and only the FIRST one with that key is kept.
822
+
823
+ RIGHT: Use a genuinely unique field (usually an id) as the key.
824
+ ```
825
+
826
+ ---
827
+
828
+ ### 3.10 `<partial>` — template composition
829
+
830
+ Expands a named template into the current one, with an **isolated**
831
+ context of its own.
832
+
833
+ **Syntax**
834
+ ```html
835
+ <partial name="templateName" data="path"></partial>
836
+ ```
837
+
838
+ **Parameters**
839
+ - `name` (string, required): the target template's name (looked up as
840
+ `<template id="tpl-{name}">`). Missing → `PARTIAL_MISSING_NAME` warning.
841
+ Not found → `PARTIAL_NOT_FOUND` warning, `<partial>` is removed.
842
+ - `data` (string, optional): a **parent-context** path resolving to an
843
+ object — becomes the ENTIRE base context inside the partial. Parent
844
+ context is otherwise completely invisible inside the partial. Omitted →
845
+ base context is `{}`.
846
+ - any OTHER attribute (except ones starting with `data-`, reserved for the
847
+ engine) is a **prop**: its value is a parent-context path, resolved and
848
+ added to the partial's context under the attribute's own name, on top of
849
+ `data` (props win on a name collision).
850
+
851
+ **Behavior**
852
+ `<partial>` is fully isolated — unlike `<for>` (§3.8), it does NOT inherit
853
+ the parent context. Recursive partials are supported (a comment reply is
854
+ itself a comment) up to `MAX_DEPTH = 50`; beyond that, remaining
855
+ `<partial>`s are removed and a `PARTIAL_DEPTH_LIMIT` warning is issued
856
+ (protects against an accidental self-referencing partial). `<partial>`
857
+ leaves no trace in the final DOM — only its expanded content remains.
858
+
859
+ **Example**
860
+ ```html
861
+ <template id="tpl-avatar">
862
+ <span class="avatar" title="${name}">${name}</span>
863
+ </template>
864
+
865
+ <template id="tpl-comment">
866
+ <partial name="avatar" data="author"></partial>
867
+ <p>${body}</p>
868
+ </template>
869
+ ```
870
+ ```html
871
+ <!-- multi-prop: data (base) + two extra props -->
872
+ <partial name="like-button" data="post" action="likeAction" count="post.likeCount"></partial>
873
+ ```
874
+
875
+ **Override example** — a prop with the SAME name as a field already present
876
+ in `data` wins (note: the prop's OWN attribute name can be anything except
877
+ `name`/`data` themselves, which are always reserved for the partial
878
+ selector and the base object):
879
+ ```js
880
+ // context: { post: { label: 'from-data' }, altLabel: 'from-prop' }
881
+ ```
882
+ ```html
883
+ <template id="tpl-badge"><span>${label}</span></template>
884
+ <!-- data="post" resolves to {label:'from-data'}; the "label" prop below is
885
+ added ON TOP of that base object, so it overrides the "label" data
886
+ already carried — the badge renders "from-prop", not "from-data" -->
887
+ <partial name="badge" data="post" label="altLabel"></partial>
888
+ ```
889
+
890
+ **⚠ Common mistakes**
891
+ ```
892
+ WRONG: <template id="tpl-loop">
893
+ <partial name="loop"></partial>
894
+ </template>
895
+ -- No base case: recurses until MAX_DEPTH (50), then
896
+ PARTIAL_DEPTH_LIMIT warns and the remaining <partial>s are
897
+ removed. Page doesn't crash, but partially-expanded output
898
+ is probably not what you want.
899
+
900
+ RIGHT: Make sure every recursive partial (e.g. a comment → its replies)
901
+ has a real base case: an array that eventually becomes empty
902
+ (an empty replies list simply renders nothing further).
903
+ ```
904
+ ```
905
+ WRONG: <partial name="card" data-source="post"></partial>
906
+ -- "data-source" starts with "data-", so it's reserved for the
907
+ ENGINE (data-model, data-text, etc.) and is silently NOT
908
+ treated as a prop — ${source} inside the partial is undefined.
909
+
910
+ RIGHT: <partial name="card" source="post"></partial>
911
+ -- Prop attribute names must not start with "data-".
912
+ ```
913
+
914
+ ---
915
+
916
+ ### 3.11 `data-on-*` — event handling
917
+
918
+ Attribute-based, eval-free event binding, resolved by NAME against a
919
+ handler dictionary — never an expression.
920
+
921
+ **Syntax**
922
+ ```html
923
+ <button data-on-click="handlerName">...</button>
924
+ ```
925
+
926
+ **Parameters**
927
+ - `data-on-{event}`: `{event}` must be one of `click`, `input`, `change`,
928
+ `submit`, `keydown`. Anything else → `UNKNOWN_EVENT` warning, ignored.
929
+ - The attribute VALUE is a **key** looked up in the `handlers` dictionary
930
+ passed to `mount()`'s 5th argument (`{ handlers: { handlerName(event, el) {...} } }`).
931
+ Not found at click-time → `HANDLER_NOT_FOUND` warning, no crash.
932
+
933
+ **Behavior**
934
+ **Delegation, not per-element listeners**: ONE listener is set up per event
935
+ TYPE actually used across the whole page's templates (not one per
936
+ `data-on-*` element), attached to `mount()`'s `target`. This means elements
937
+ added later by a reactive `<for data-live>`/`<if data-live>` need **no
938
+ additional setup** — the single listener catches them via event bubbling
939
+ the moment they're clicked. A handler always receives `(event, element)` —
940
+ no context is injected; use `element.dataset` (paired with a
941
+ `data-id="${id}"`-style attribute on the same element) to identify which
942
+ item was interacted with, and reach the `store` via closure from wherever
943
+ `handlers` was defined. `data-on-submit` ALWAYS calls `preventDefault()`
944
+ (even if the handler isn't found) — this is a deliberate, non-configurable
945
+ default (no page reload on submit is the overwhelmingly common need). If
946
+ `options.handlers` isn't passed to `mount()` at all, the event system is
947
+ never set up (zero cost).
948
+
949
+ **Example**
950
+ ```html
951
+ <button data-on-click="deleteItem" data-id="42">Delete</button>
952
+ ```
953
+ ```js
954
+ mount('page', ctx, target, store, {
955
+ handlers: {
956
+ deleteItem(event, el) {
957
+ const id = el.dataset.id; // "42"
958
+ store.update('items', (items) => items.filter((i) => i.id !== id));
959
+ },
960
+ },
961
+ });
962
+ ```
963
+
964
+ **⚠ Common mistakes**
965
+ ```
966
+ WRONG: <button data-on-click="count++">Increment</button>
967
+ -- Not an expression. "count++" is treated as a literal HANDLER
968
+ NAME, looked up verbatim in the handlers dictionary — it will
969
+ never be found (HANDLER_NOT_FOUND).
970
+
971
+ RIGHT: <button data-on-click="increment">Increment</button>
972
+ mount('page', ctx, target, store, {
973
+ handlers: { increment(e, el) { store.update('count', (v) => v + 1); } },
974
+ });
975
+ ```
976
+
977
+ ---
978
+
979
+ ## 4. Store API
980
+
981
+ `import { createStore } from './src/index.js';` (or directly from `./src/store.js`).
982
+
983
+ ### 4.1 `createStore(initialState)` → `Store`
984
+
985
+ ```js
986
+ const store = createStore({ count: 0, user: { name: 'Ada' } });
987
+ ```
988
+ `initialState` (object, optional, default `{}`) is held **by reference**,
989
+ not copied. Returns a `Store` object with `get`/`set`/`update`/`subscribe`/`computed`.
990
+
991
+ ### 4.2 `store.get(path)` → value
992
+
993
+ ```js
994
+ store.get('user.name'); // → 'Ada'
995
+ store.get(); // no path → the ENTIRE state object
996
+ ```
997
+ Reads via a dotted path (`getByPath` under the hood); any missing segment
998
+ resolves to `undefined`, never throws.
999
+
1000
+ ### 4.3 `store.set(path, value)` → boolean
1001
+
1002
+ ```js
1003
+ store.set('count', 5); // → true (changed)
1004
+ store.set('count', 5); // → false (same value — Object.is comparison, no notify)
1005
+ ```
1006
+ Writes `value` at `path`, creating intermediate objects as needed. Compares
1007
+ via `Object.is`: setting the exact same value again is a no-op — no
1008
+ subscriber fires, and `false` is returned. Notification is both **upward**
1009
+ (changing `"a.b.c"` also notifies subscribers of `"a"` and `"a.b"`) and
1010
+ **downward** (changing `"user"` also notifies subscribers of
1011
+ `"user.name"`) — so `store.set('user', {...})` correctly updates a
1012
+ `data-text="user.name"` binding elsewhere on the page.
1013
+
1014
+ **Prototype pollution guard**: if any path segment is `__proto__`,
1015
+ `constructor`, or `prototype`, the write is silently rejected (`{ changed: false }`)
1016
+ — relevant if a path is ever built from user input (e.g. a dynamic
1017
+ `data-model` target).
1018
+
1019
+ **⚠ Common mistakes**
1020
+ ```
1021
+ WRONG: const arr = store.get('items');
1022
+ arr.push(newItem);
1023
+ store.set('items', arr); // SAME reference as before
1024
+ -- IN_PLACE_MUTATION warning. Object.is(existing, value) is true
1025
+ (it's literally the same array object) → store.set() returns
1026
+ false immediately, WITHOUT writing or notifying anything. Every
1027
+ data-text/data-model/<for data-live> bound to "items" is now
1028
+ silently out of sync with what you think you just did.
1029
+
1030
+ RIGHT: store.set('items', [...store.get('items'), newItem]);
1031
+ -- Always construct a NEW object/array reference when "mutating."
1032
+ This is the single most common lime-csr footgun — Object.is is
1033
+ reference equality, not deep equality, by design (deep-diffing
1034
+ would be slow and un-KISS).
1035
+ ```
1036
+
1037
+ ### 4.4 `store.update(path, fn)` → boolean
1038
+
1039
+ ```js
1040
+ store.update('count', (v) => v + 1);
1041
+ ```
1042
+ Shorthand for `store.set(path, fn(store.get(path)))`. Same `Object.is`
1043
+ semantics as `set` — `fn` must return a NEW reference if `path` holds an
1044
+ object/array you're "modifying."
1045
+
1046
+ ### 4.5 `store.subscribe(path, callback)` → unsubscribe function
1047
+
1048
+ ```js
1049
+ const unsubscribe = store.subscribe('count', (newVal, oldVal, changedPath) => {
1050
+ console.log(newVal, oldVal, changedPath);
1051
+ });
1052
+ unsubscribe(); // cancels; once the last subscriber on a path is gone, that
1053
+ // path's internal bookkeeping is deleted too — no leak.
1054
+ ```
1055
+ `callback(currentValue, previousValue, changedPath)`. Fires on both upward
1056
+ and downward notification (see §4.3) — `changedPath` tells you exactly
1057
+ which `store.set()` call triggered this particular invocation, which may
1058
+ differ from the `path` you subscribed to.
1059
+
1060
+ ### 4.6 `store.computed(path, deps, fn)` → dispose function
1061
+
1062
+ ```js
1063
+ const dispose = store.computed('fullName', ['firstName', 'lastName'],
1064
+ () => store.get('firstName') + ' ' + store.get('lastName'));
1065
+ // later:
1066
+ dispose();
1067
+ ```
1068
+ Registers a derived value that lives at an ordinary store `path` — read it
1069
+ with `store.get('fullName')` or bind `data-text="fullName"` exactly like
1070
+ any other value. Computed immediately on registration, then automatically
1071
+ recomputed whenever any path in `deps` changes. **Chainable**: a computed
1072
+ path can itself be a dep of another computed. **Loop-guarded**: if `fn`'s
1073
+ own execution ends up (directly or indirectly) triggering a recompute of
1074
+ the SAME path while it's already running, the reentrant call is swallowed
1075
+ — no stack overflow. Calling `store.set()` directly on a computed path
1076
+ still works (isn't blocked) but issues a `COMPUTED_MANUAL_SET` warning —
1077
+ the manually-set value is silently overwritten the next time any dep changes.
1078
+
1079
+ **⚠ Common mistakes**
1080
+ ```
1081
+ WRONG: // Recomputing "remaining" by hand, in every place "todos" changes
1082
+ function addTodo(t) {
1083
+ store.set('todos', [...store.get('todos'), t]);
1084
+ store.set('remaining', store.get('todos').filter(x => !x.done).length);
1085
+ }
1086
+ function toggleTodo(id) {
1087
+ store.set('todos', /* ... */);
1088
+ store.set('remaining', store.get('todos').filter(x => !x.done).length);
1089
+ }
1090
+ -- Duplicated, easy to forget in a THIRD place that touches todos
1091
+ later, and now "remaining" is quietly stale.
1092
+
1093
+ RIGHT: store.computed('remaining', ['todos'],
1094
+ () => store.get('todos').filter(x => !x.done).length);
1095
+ // every future store.set('todos', ...) anywhere keeps "remaining"
1096
+ // correct automatically — one definition, no duplication.
1097
+ ```
1098
+
1099
+ ---
1100
+
1101
+ ## 5. Mount API
1102
+
1103
+ `import { mount, unmount, render } from './src/index.js';`
1104
+
1105
+ ### 5.1 `mount(templateName, context, target, store, options?)` → cleanup function
1106
+
1107
+ ```js
1108
+ const cleanup = mount('page', { pageTitle: 'Hi' }, document.getElementById('app'), store, {
1109
+ handlers: { deleteItem(e, el) { /* ... */ } },
1110
+ beforeRender(context, store) { /* ... */ },
1111
+ afterRender(rootEl, store) { /* ... */ },
1112
+ });
1113
+ ```
1114
+
1115
+ | Argument | Type | Notes |
1116
+ |---|---|---|
1117
+ | `templateName` | string | Looked up as `<template id="tpl-{templateName}">`. Not found → `MOUNT_TEMPLATE_NOT_FOUND` warning, mount is a no-op (returns a no-op cleanup). |
1118
+ | `context` | object | Static data for `${path}`. |
1119
+ | `target` | Element | Where the rendered content is appended. |
1120
+ | `store` | `Store` \| `null` | `createStore(...)`. If `null`, all reactive features (`data-text`, `data-model`, `<if data-live>`, ...) are simply skipped — only static content renders. |
1121
+ | `options.handlers` | object, optional | See [§3.11](#311-data-on---event-handling) and [§6](#6-lifecycle-hooks) — also used for block-level `data-after`/`data-before`. |
1122
+ | `options.beforeRender` | `(context, store) => void`, optional | See [§6](#6-lifecycle-hooks). |
1123
+ | `options.afterRender` | `(rootEl, store) => void`, optional | See [§6](#6-lifecycle-hooks). |
1124
+
1125
+ Calling `mount()` again on a `target` that's already mounted automatically
1126
+ runs the previous mount's cleanup and clears `target` first — this is how
1127
+ you switch pages/components on the same root element.
1128
+
1129
+ ### 5.2 `unmount(target)`
1130
+
1131
+ ```js
1132
+ unmount(document.getElementById('app'));
1133
+ ```
1134
+ Equivalent to calling the `cleanup()` function `mount()` returned, but
1135
+ useful when you don't have that reference handy — looked up internally by
1136
+ `target`. Cancels every reactive subscription tied to that mount and clears
1137
+ `target`'s content.
1138
+
1139
+ ### 5.3 `render(fragment, context, store, handlers?)` → cleanup function
1140
+
1141
+ The lower-level function `mount()` calls internally — exported for cases
1142
+ where you already have a `DocumentFragment` (e.g. from `getTemplate()`) and
1143
+ want to process it without the template-lookup/DOM-append/event-delegation
1144
+ parts of `mount()`. `handlers` here is only used for block-level
1145
+ `data-after`/`data-before` lookups (§6) — `data-on-*` delegation itself is
1146
+ set up only by `mount()`, since it needs a stable `target` to delegate
1147
+ from.
1148
+
1149
+ ### 5.4 Pipeline order
1150
+
1151
+ ```
1152
+ partial → for → if (looped until none remain)
1153
+ → resolveStatic (top-level ${path})
1154
+ → data-model → data-text/{x} → data-show
1155
+ → <for data-live> → <if data-live>
1156
+ ```
1157
+ Full rationale for this exact order (and the invariants it depends on) is
1158
+ in [§9](#9-architecture-reference).
1159
+
1160
+ ---
1161
+
1162
+ ## 6. Lifecycle hooks
1163
+
1164
+ Two independent hook systems exist, at two different scopes:
1165
+
1166
+ | | Mount-level | Block-level |
1167
+ |---|---|---|
1168
+ | Hooks | `beforeRender`, `afterRender` | `data-after`, `data-before` |
1169
+ | Declared | JS, in `mount()`'s `options` | HTML, as attributes on `<if data-live>`/`<for data-live>` |
1170
+ | Scope | The WHOLE `mount()` call | One reactive branch (`<if>`) or one list item (`<for>`) |
1171
+ | Fires | Once, when `mount()` runs | Every time a branch switches / an item is added or removed — potentially many times over a component's life |
1172
+ | Typical use | Measuring/logging around a full page render, seeding derived state before the pipeline runs | Initializing/destroying a per-branch or per-item widget (chart, editor, map) as it enters/leaves the DOM |
1173
+
1174
+ ### 6.1 Mount-level: `beforeRender` / `afterRender`
1175
+
1176
+ ```js
1177
+ mount('page', ctx, target, store, {
1178
+ beforeRender(context, store) {
1179
+ context.injected = 'value'; // mutating context here is visible
1180
+ // to the pipeline that runs next
1181
+ },
1182
+ afterRender(rootEl, store) {
1183
+ rootEl.querySelector('.chart'); // content is already in the DOM here
1184
+ },
1185
+ });
1186
+ ```
1187
+ `beforeRender(context, store)` runs BEFORE the render pipeline — mutations
1188
+ to `context` are picked up by the same render (useful for injecting
1189
+ computed/derived fields). `afterRender(rootEl, store)` runs AFTER content
1190
+ is appended to `target` — safe to do real DOM measurements/third-party
1191
+ widget setup for the WHOLE mounted tree here. Both optional; omitting
1192
+ either is a complete no-op (no warning, no cost).
1193
+
1194
+ ### 6.2 Block-level: `data-after` / `data-before`
1195
+
1196
+ ```html
1197
+ <if is-truthy="showChart" data-live el="div" data-after="initChart" data-before="destroyChart">
1198
+ <canvas></canvas>
1199
+ </if>
1200
+
1201
+ <for each="rows" as="row" key="row.id" data-live data-after="initRow" data-before="destroyRow">
1202
+ <li>${row.label}</li>
1203
+ </for>
1204
+ ```
1205
+ ```js
1206
+ mount('page', ctx, target, store, {
1207
+ handlers: {
1208
+ initChart(rootEl, store) { /* rootEl is the el="div" container */ },
1209
+ destroyChart(rootEl, store) { /* rootEl still in the DOM here */ },
1210
+ initRow(rootEl, store) { /* rootEl is this <li> */ },
1211
+ destroyRow(rootEl, store) { /* rootEl is the <li> being removed */ },
1212
+ },
1213
+ });
1214
+ ```
1215
+ Handler signature for both: `(rootElement, store)` — no `event` object (these
1216
+ aren't DOM events). Looked up in the SAME `handlers` dictionary as
1217
+ `data-on-*`, by name.
1218
+
1219
+ **`<if data-live>`**: `data-after` fires once the winning branch's nodes
1220
+ are in the DOM — both on the initial render AND every later switch.
1221
+ `data-before` fires on the OLD branch, synchronously, right before its
1222
+ `cleanup()` and DOM removal (the element is still attached at call time).
1223
+ Re-evaluating to the SAME truthiness fires neither.
1224
+
1225
+ **`<for data-live>`**: `data-after` fires once per NEW item's nodes being
1226
+ placed (initial render, every later addition, and every item under
1227
+ `data-diff="replace"`, since that strategy treats every item as new on every
1228
+ reconcile). `data-before` fires once per REMOVED item, before its
1229
+ `cleanup()`/DOM removal. **Moved/reordered survivors never trigger either
1230
+ hook** — their DOM node was never destroyed, so there's nothing to
1231
+ (re)initialize or tear down.
1232
+
1233
+ `rootElement` is the `el="tag"` container if configured (`<if>`'s whole
1234
+ branch, or `<for>`'s whole list — NOT per-item for `<for>`); otherwise it's
1235
+ the branch's/item's own first top-level **element** node (template
1236
+ whitespace around it is skipped automatically). A handler name that isn't
1237
+ found in `handlers` → `BLOCK_AFTER_NOT_FOUND`/`BLOCK_BEFORE_NOT_FOUND`
1238
+ warning, no crash — rendering itself is unaffected.
1239
+
1240
+ **Known limitation**: `data-before` is always awaited **synchronously** —
1241
+ there's no way to `await` an exit animation or other async cleanup before
1242
+ the DOM node is actually removed. See [§8](#8-known-limitations).
1243
+
1244
+ ---
1245
+
1246
+ ## 7. Error codes
1247
+
1248
+ All dev-mode warnings go through `errors.js`'s single `warn()` function —
1249
+ format: `console.warn('[lime-csr] CODE: message', context?)`. **Default:
1250
+ ON.** `setDevMode(false)` silences everything completely (no warnings are
1251
+ ever printed, the page never crashes either way — dev-mode only controls
1252
+ whether you're TOLD about a problem, not whether the engine degrades
1253
+ safely from it).
1254
+
1255
+ ```js
1256
+ import { setDevMode, isDevMode } from './src/index.js';
1257
+ setDevMode(false); // production: silent
1258
+ isDevMode(); // → false
1259
+ ```
1260
+
1261
+ | Code | When it fires | Suggested fix |
1262
+ |---|---|---|
1263
+ | `UNKNOWN_OPERATOR` | `<if>` has an `is-*` attribute that isn't in the operator table | Use one of `is-gt`/`is-lt`/`is-gte`/`is-lte`/`is-eq`/`is-neq`/`is-truthy` |
1264
+ | `MISSING_OPERATOR` | `<if>` has no operator attribute at all | Add one of the operators above |
1265
+ | `ELSE_AFTER_CONTENT` | An element follows `<else>` as a direct child of `<if>` | Move that content before `<else>`, or into it — it's still treated as "then" either way |
1266
+ | `PARTIAL_NOT_FOUND` | `<partial name="x">` — no `tpl-x` exists | Define `<template id="tpl-x">`, or check for a typo in `name` |
1267
+ | `PARTIAL_MISSING_NAME` | `<partial>` has no `name` attribute | Add `name="..."` |
1268
+ | `PARTIAL_DEPTH_LIMIT` | Recursive partial expansion hit `MAX_DEPTH` (50) | Check for a partial that (in)directly calls itself with no base case |
1269
+ | `TEMPLATE_NOT_FOUND` | `getTemplate`/`renderTemplate` couldn't find `tpl-x` | Define the `<template id="tpl-x">` |
1270
+ | `FOR_MISSING_ATTR` | `<for>` is missing `each` and/or `as` | Add both: `<for each="..." as="...">` |
1271
+ | `FOR_NOT_ARRAY` | `<for each="x">` resolved to a non-array | Check that `x` is really an array in context (static `<for>`) or store (`<for data-live>`) |
1272
+ | `BINDING_MISSING_PATH` | `data-text=""` (empty) | Give it a store path |
1273
+ | `BINDING_MISSING_DATA_ATTR` | An `{x}` placeholder has no matching `data-x` | Add `data-x="store.path"`, or remove the `{x}` placeholder |
1274
+ | `UNSAFE_EVENT_ATTR` | A reactive `{x}`/`data-x` targets an `on*` attribute | Never bind reactive data to event-handler attributes; use `data-on-*` (§3.11) for events |
1275
+ | `LIVE_IF_MISSING_OP` | `<if data-live>` has no valid operator | Add one (same table as `UNKNOWN_OPERATOR`) |
1276
+ | `PIPELINE_DEPTH_LIMIT` | `render()`'s structural pipeline hit `MAX_PIPELINE_ITERATIONS` (100) | Look for runaway nested `<partial>`/`<for>`/`<if>` structures, often a self-referencing partial |
1277
+ | `MOUNT_TEMPLATE_NOT_FOUND` | `mount()`'s `templateName` has no matching `tpl-*` | Check the name passed to `mount()` against your `<template id>`s |
1278
+ | `FOR_MISSING_KEY` | `<for data-live>` has no `key` | Add `key="item.idPath"` |
1279
+ | `FOR_DUPLICATE_KEY` | Two items resolved to the same `key` | Use a genuinely unique field, usually an id |
1280
+ | `MODEL_MISSING_PATH` | `data-model=""` (empty) | Give it a store path |
1281
+ | `TABLE_FOSTER_PARENTING` | A special tag inside `<table>` looks like it got relocated by the HTML parser | Move the tag outside `<table>`, or wrap the row-producing content in a `<partial>` called from outside the table |
1282
+ | `SHOW_MISSING_PATH` | `data-show=""` (empty) | Give it a store path |
1283
+ | `UNKNOWN_EVENT` | `data-on-{event}` uses an unsupported event type | Use one of `click`/`input`/`change`/`submit`/`keydown` |
1284
+ | `HANDLER_NOT_FOUND` | `data-on-*`'s handler name isn't in `handlers` | Define it in the `handlers` object passed to `mount()` |
1285
+ | `RESERVED_ATTR_NAME` | A `{x}`/`data-x` placeholder used a reserved name | Rename it — reserved: `text`, `model`, `show`, `live`, `ref`, `diff`, anything starting with `on-` |
1286
+ | `INDEXED_MODEL_PATH` | `data-model` contains a numeric path segment (e.g. `items.0.name`) | Use `<for data-live key>` + bind to a per-item-addressable store location instead of an array index |
1287
+ | `COMPUTED_MANUAL_SET` | `store.set()` called directly on a `store.computed()` path | Don't; update one of its `deps` instead, or use a different path |
1288
+ | `IN_PLACE_MUTATION` | `store.set()` got the SAME object/array reference already stored | Pass a new reference: `store.set(path, [...arr])` / `{...obj}` |
1289
+ | `UNKNOWN_DIFF_STRATEGY` | `data-diff` has a value other than `simple`/`lcs`/`replace` | Use one of those three, or omit the attribute for the default |
1290
+ | `BLOCK_AFTER_NOT_FOUND` | `data-after`'s handler name isn't in `handlers` | Define it in the `handlers` object passed to `mount()` |
1291
+ | `BLOCK_BEFORE_NOT_FOUND` | `data-before`'s handler name isn't in `handlers` | Define it in the `handlers` object passed to `mount()` |
1292
+
1293
+ ---
1294
+
1295
+ ## 8. Known limitations
1296
+
1297
+ - **`<table>` foster-parenting is detected, not fixed.** The HTML parser
1298
+ itself moves an `<if>`/`<for>`/`<else>` written directly inside `<table>`
1299
+ (outside a `<tr>`/`<td>`) to BEFORE the table, before lime-csr ever runs —
1300
+ this is standard browser HTML-parsing behavior, outside any framework's
1301
+ control. Detected in dev-mode on first template read
1302
+ (`TABLE_FOSTER_PARENTING`, §7), not correctable at runtime. **Workaround**:
1303
+ move the condition/loop outside `<table>`, or produce the row markup via
1304
+ a `<partial>` called from outside the table.
1305
+
1306
+ - **`than`/`to` is never reactive.** `<if data-live>` only tracks the
1307
+ operator's LEFT side; the right-hand comparison value is always
1308
+ static/literal, even if it happens to look like a path. If the
1309
+ comparison itself needs to be reactive on both sides, precompute it with
1310
+ `store.computed()` (§4.6) into a single trackable boolean path.
1311
+
1312
+ - **`data-diff="lcs"` is opt-in, not the default.** `simple` (§3.9) is the
1313
+ default reconcile strategy for `<for data-live>` — it's correct and cheap
1314
+ to compute, just not globally-minimal on non-trivial reorders. Turn on
1315
+ `lcs` explicitly for lists with frequent, large reorders.
1316
+
1317
+ - **Block-level hooks (`data-after`/`data-before`) are always synchronous.**
1318
+ `data-before` cannot `await` a Promise before the DOM node is actually
1319
+ torn down — there's no built-in way to wait for an exit animation to
1320
+ finish first. A future extension could support an async before-hook
1321
+ (await if a Promise is returned); not implemented today.
1322
+
1323
+ - **No rich-HTML rendering path.** `data-text` (§3.2) always uses
1324
+ `textContent` — this is exactly what makes it XSS-safe with zero
1325
+ escaping, but it also means it can never render markup from the store
1326
+ (bold text, links, etc. coming from data). There is no reactive
1327
+ equivalent of `innerHTML` anywhere in the engine; any HTML-shaped content
1328
+ has to come from the template itself (`${...}`/static markup), never from
1329
+ reactive store data.
1330
+
1331
+ - **Indexed `data-model` paths are warned about, not blocked.**
1332
+ `data-model="items.2.name"` (§3.4) still WORKS today and is not
1333
+ prevented — it just emits `INDEXED_MODEL_PATH` in dev-mode, because the
1334
+ underlying path-drift risk (index 2 silently pointing at the wrong item
1335
+ after a reorder/removal) is real but considered the caller's
1336
+ responsibility to avoid, per the project's KISS stance against building
1337
+ an automatic path-remapping mechanism.
1338
+
1339
+ ---
1340
+
1341
+ ## 9. Architecture (reference)
1342
+
1343
+ This section is for people reading or extending the source — everyday
1344
+ usage doesn't require it.
1345
+
1346
+ ### 9.1 Module map
1347
+
1348
+ Orchestration lives entirely in `src/index.js`. **Modules do not import
1349
+ each other** (`errors.js` is the sole exception, and it imports nothing
1350
+ itself) — only `index.js` decides call order.
1351
+
1352
+ | Module | Responsibility | Exports |
1353
+ |---|---|---|
1354
+ | `store.js` | Path-based reactive state: get/set/subscribe/computed, prototype-pollution rejection | `getByPath`, `setByPath`, `createStore` |
1355
+ | `utils.js` | Security helpers — XSS/URL sanitization | `escapeHtml`, `safeAttr`, `isSafeUrlProtocol`, `safeUrl`, `safeStyleUrl` |
1356
+ | `template.js` | `<template>` reading + cache, static `${path}` interpolation, `<table>` foster-parenting detection | `getTemplate`, `resolveStatic`, `renderTemplate` |
1357
+ | `conditionals.js` | Static `<if>`/`<else>` processing, operator table | `OPERATORS`, `evalCondition`, `processIf`, `processAllIfs` |
1358
+ | `partials.js` | `<partial>` expansion (isolated context, multi-prop, recursive, depth limit) | `expandPartials` |
1359
+ | `loops.js` | Static `<for each as index>` list rendering (inherited context) | `expandLoops` |
1360
+ | `bindings.js` | Reactive `data-text` + `{x}`/`data-x` attribute binding | `setupBindings` |
1361
+ | `bindings-model.js` | Two-way form binding (`data-model`) | `setupModelBindings` |
1362
+ | `bindings-show.js` | Reactive visibility (`data-show`) | `setupShowBindings` |
1363
+ | `bindings-events.js` | Event delegation (`data-on-*`) | `setupEventBindings` |
1364
+ | `bindings-blocks.js` | Reactive `<if data-live>` (tear-down/rebuild, `el=`, hooks) | `setupLiveIfs` |
1365
+ | `bindings-loops.js` | Reactive `<for data-live key>` (key-based diff, `data-diff`, `el=`, hooks) | `setupLiveFors` |
1366
+ | `errors.js` | dev_mode warning layer — the bottom-most layer | `setDevMode`, `isDevMode`, `warn`, `errors` (namespace) |
1367
+ | `shared.js` | Pure utility helpers: inLiveBlock, inUnexpandedFor, LIS indices computation | `inLiveBlock`, `inUnexpandedFor`, `longestIncreasingSubsequenceIndices` |
1368
+ | `index.js` | Orchestration: `render`/`mount`/`unmount` + re-exports of everything above | `render`, `mount`, `unmount`, ... |
1369
+
1370
+ ### 9.2 Pipeline order and why
1371
+
1372
+ ```
1373
+ mount(templateName, context, target, store, options?)
1374
+ 1. options.beforeRender(context, store)
1375
+ 2. getTemplate(templateName) → fragment (cloneNode(true) from cache)
1376
+ 3. render(fragment, context, store, options.handlers):
1377
+ a. loop until stable: expandPartials → expandLoops → processAllIfs
1378
+ b. resolveStatic (remaining top-level ${path})
1379
+ c. setupModelBindings (data-model)
1380
+ d. setupBindings (data-text + {x}/data-x)
1381
+ e. setupShowBindings (data-show)
1382
+ f. setupLiveFors (<for data-live>)
1383
+ g. setupLiveIfs (<if data-live>)
1384
+ 4. target.appendChild(fragment)
1385
+ 5. options.afterRender(target, store)
1386
+ 6. if options.handlers: setupEventBindings(target, store, handlers)
1387
+ ```
1388
+
1389
+ - **3a is a LOOP**, not one pass: a `<partial>`'s own template can contain
1390
+ a new `<for>`/`<if>`, a `<for>`'s content can contain a new `<partial>`,
1391
+ and so on. Bounded by `MAX_PIPELINE_ITERATIONS = 100`
1392
+ (`PIPELINE_DEPTH_LIMIT` if exceeded). `expandPartials` runs BEFORE
1393
+ `expandLoops` every pass; a `<partial data="item">` inside a not-yet-expanded
1394
+ `<for as="item">` would see `item` unbound if resolved too early — an
1395
+ `inUnexpandedFor()` check defers such partials to the SAME pass's
1396
+ `expandLoops` call, which resolves them immediately afterward with the
1397
+ correct item context (the pipeline's call ORDER never changes — only
1398
+ which `<partial>`s get touched narrows).
1399
+ - **3b runs AFTER structural expansion**: resolving `${item.x}` before a
1400
+ `<for>` expands would see the wrong (top-level) context and produce an
1401
+ empty string. `expandLoops` already calls `resolveStatic` itself per
1402
+ item; 3b only handles what's left at the top level.
1403
+ - **3c/3d/3e run AFTER structural expansion**, so bindings never attach to
1404
+ a node that's about to be deleted (memory leak). Their own relative order
1405
+ doesn't matter for correctness EXCEPT that `data-model` (3c) is
1406
+ guaranteed to run before `data-on-*` event delegation (step 6) is even
1407
+ set up — so if the same element has both `data-model` and `data-on-input`,
1408
+ the store is already updated by the time the app-level handler runs.
1409
+ - **3f runs before 3g**: an `<if data-live>` nested inside a
1410
+ `<for data-live>` is handled by the RECURSIVE `render()` call each item
1411
+ gets (via `renderFn`); by the time 3g runs at the outer level, those
1412
+ inner `<if data-live>`s have already become their own anchors and are no
1413
+ longer matched by 3g's top-level query.
1414
+ - **Step 6 (event delegation) is OUTSIDE `render()` entirely**: it's a
1415
+ single delegation listener on `target`, not a per-element subscription —
1416
+ there's no "binding too early" risk to guard against, so it doesn't need
1417
+ to participate in the render pipeline's ordering at all, and needs no
1418
+ `inLiveBlock` filter.
1419
+
1420
+ `render()` itself is passed as the `renderFn` callback into `setupLiveIfs`/
1421
+ `setupLiveFors`, so a branch switch or a new list item runs this ENTIRE
1422
+ pipeline again, recursively, for just that subtree.
1423
+
1424
+ ### 9.3 Centralized Utility Helpers (shared.js)
1425
+
1426
+ To keep the codebase DRY (Don't Repeat Yourself) and highly maintainable, shared utility functions such as `inLiveBlock(node)`, `inUnexpandedFor(node)`, and `longestIncreasingSubsequenceIndices(seq)` are centralized in `src/shared.js`.
1427
+
1428
+ - **Leaf Dependency**: `shared.js` does not import any other modules. This allows it to be imported by any other module in the codebase without introducing circular dependency risks.
1429
+ - **`inLiveBlock(node)` and `inUnexpandedFor(node)`**: Content inside a not-yet-expanded `<if data-live>`/`<for data-live>` (or static `<for>`) block must be skipped during the main pipeline passes, as their correct context is only known inside their own recursive `render()` call. If bound too early, subscriptions would leak or resolve against the wrong context.
1430
+ - **`longestIncreasingSubsequenceIndices(seq)`**: The math utility used by the `"lcs"` loop diffing strategy is placed here to keep `bindings-loops.js` focused entirely on DOM reconciliation.
1431
+
1432
+ ### 9.4 Security model
1433
+
1434
+ - **`data-text`/`${...}`**: written via `textContent`/`nodeValue`/`attr.value`
1435
+ — none of these parse HTML, so there is nothing to escape and no XSS
1436
+ surface, by construction (not by sanitization).
1437
+ - **`{x}`/`data-x` on `on*` attributes**: rejected outright
1438
+ (`UNSAFE_EVENT_ATTR`) — browsers actually EXECUTE `onclick`/`onerror`/etc.
1439
+ when set via `setAttribute`, which would let reactive data run as code.
1440
+ - **URL attributes** (`href`, `src`, `action`, `formaction`, `data`, `cite`,
1441
+ `poster`, `ping`): checked against a protocol whitelist
1442
+ (`isSafeUrlProtocol` in `utils.js`) before `setAttribute` —
1443
+ `javascript:`/`data:`/other dangerous schemes resolve to `""`.
1444
+ - **`store.js` prototype-pollution guard**: `__proto__`/`constructor`/`prototype`
1445
+ are rejected as path segments in `setByPath`, silently.
1446
+ - **No `eval`/`new Function` anywhere in the codebase.** `data-on-*` and
1447
+ `data-after`/`data-before` handlers are always resolved by NAME LOOKUP in
1448
+ a plain object, never by evaluating a string as code — the engine works
1449
+ under a strict Content-Security-Policy with no `unsafe-eval` in
1450
+ `script-src`.
1451
+
1452
+ ---
1453
+
1454
+ *See also: [README.md](README.md) for the philosophy and a quick feature
1455
+ tour; [llms.txt](llms.txt) for a short machine-readable index of this repo's
1456
+ documentation.*