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
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.*
|