@uistate/renderer 1.0.0
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/LICENSE.md +29 -0
- package/README.md +187 -0
- package/index.js +13 -0
- package/package.json +44 -0
- package/renderer.js +267 -0
- package/self-test.js +102 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ajdin Imsirovic
|
|
4
|
+
|
|
5
|
+
This software is licensed under a PROPRIETARY LICENSE.
|
|
6
|
+
|
|
7
|
+
## Permitted Uses
|
|
8
|
+
|
|
9
|
+
- Personal projects
|
|
10
|
+
- Open-source projects
|
|
11
|
+
- Educational purposes
|
|
12
|
+
|
|
13
|
+
## Restrictions
|
|
14
|
+
|
|
15
|
+
- Commercial use requires a separate license
|
|
16
|
+
- Modification and redistribution of this file are NOT permitted
|
|
17
|
+
- This file may not be included in derivative works without permission
|
|
18
|
+
|
|
19
|
+
## Disclaimer
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
For commercial licensing inquiries, contact the author.
|
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# @uistate/renderer
|
|
2
|
+
|
|
3
|
+
Direct-binding reactive renderer for [@uistate/core](https://www.npmjs.com/package/@uistate/core). Bind DOM nodes to store paths with `bind-*` attributes and `set` actions. Zero build step. ~270 lines.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @uistate/renderer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependency: `@uistate/core >= 5.0.0`
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<body>
|
|
17
|
+
<h1>Count: <span bind-text="count"></span></h1>
|
|
18
|
+
<button set="count:decrement">−</button>
|
|
19
|
+
<button set="count:0">Reset</button>
|
|
20
|
+
<button set="count:increment">+</button>
|
|
21
|
+
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { createEventState } from '@uistate/core';
|
|
24
|
+
import { mount } from '@uistate/renderer';
|
|
25
|
+
|
|
26
|
+
const store = createEventState({ count: 0 });
|
|
27
|
+
mount(store);
|
|
28
|
+
</script>
|
|
29
|
+
</body>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A reactive counter. No React. No Babel. No bundler. No innerHTML. Just HTML attributes and two imports.
|
|
33
|
+
|
|
34
|
+
## Three Primitives
|
|
35
|
+
|
|
36
|
+
### 1. Delegated Actions (DOM → Store)
|
|
37
|
+
|
|
38
|
+
Attach store writes to user events. Three delegated listeners on the root handle everything — they survive DOM mutations and never need re-wiring.
|
|
39
|
+
|
|
40
|
+
| Attribute | Event | Example |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `set` | click | `<button set="count:increment">+</button>` |
|
|
43
|
+
| `set-blur` | focusout | `<input set-blur="item.editing:false">` |
|
|
44
|
+
| `set-enter` | keydown Enter | `<input set-enter="todos:push(draft)">` |
|
|
45
|
+
|
|
46
|
+
#### Set Expressions
|
|
47
|
+
|
|
48
|
+
| Syntax | Effect |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `set="count:increment"` | `store.set('count', current + 1)` |
|
|
51
|
+
| `set="count:decrement"` | `store.set('count', current - 1)` |
|
|
52
|
+
| `set="count:0"` | `store.set('count', 0)` |
|
|
53
|
+
| `set="ui.dark:toggle"` | `store.set('ui.dark', !current)` |
|
|
54
|
+
| `set="user.name:Bob"` | `store.set('user.name', 'Bob')` |
|
|
55
|
+
| `set="todos:push(draft)"` | Clone `draft` into `todos`, reset `draft` |
|
|
56
|
+
| `set="todos.t1:delete"` | Remove `t1` from `todos` |
|
|
57
|
+
| `set="flag:true"` | `store.set('flag', true)` |
|
|
58
|
+
| `set="flag:false"` | `store.set('flag', false)` |
|
|
59
|
+
| `set="val:null"` | `store.set('val', null)` |
|
|
60
|
+
|
|
61
|
+
### 2. Direct Node Binding (Store → DOM)
|
|
62
|
+
|
|
63
|
+
Bind store paths directly to DOM node properties. Each binding creates one EventState subscription and performs surgical updates — no re-rendering, no diffing.
|
|
64
|
+
|
|
65
|
+
| Attribute | What it does | Example |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `bind-text` | Sets `textContent` | `<span bind-text="user.name"></span>` |
|
|
68
|
+
| `bind-value` | Two-way input binding | `<input bind-value="draft.text">` |
|
|
69
|
+
| `bind-focus` | Focus when truthy | `<input bind-focus="item.editing">` |
|
|
70
|
+
| `bind-data-*` | Sets `dataset.*` | `<div bind-data-done="item.done">` |
|
|
71
|
+
| `bind-attr-*` | Sets any attribute | `<img bind-attr-src="user.avatar">` |
|
|
72
|
+
|
|
73
|
+
**`bind-data-*` + CSS attribute selectors** replace conditional class logic:
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<div bind-data-done="todos.t1.done">...</div>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```css
|
|
80
|
+
[data-done="true"] { text-decoration: line-through; opacity: 0.6; }
|
|
81
|
+
[data-done="false"] { text-decoration: none; }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
No ternary operators. No `classnames()`. CSS does what CSS was designed to do.
|
|
85
|
+
|
|
86
|
+
### 3. Keyed Collections
|
|
87
|
+
|
|
88
|
+
Render dynamic lists with `each` + `<template>`. Reconciliation is key-based: only added/removed items touch the DOM.
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<div each="todos">
|
|
92
|
+
<template>
|
|
93
|
+
<div class="todo-item" bind-data-done="{_path}.done">
|
|
94
|
+
<button set="{_path}.done:toggle">✓</button>
|
|
95
|
+
<span bind-text="{_path}.text"></span>
|
|
96
|
+
<small bind-text="{_key}"></small>
|
|
97
|
+
<button set="{_path}:delete">×</button>
|
|
98
|
+
</div>
|
|
99
|
+
</template>
|
|
100
|
+
</div>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- `{_key}` — the object key (e.g., `t1`)
|
|
104
|
+
- `{_path}` — the full path (e.g., `todos.t1`)
|
|
105
|
+
|
|
106
|
+
Placeholders are resolved once when the item is created. All `bind-*` and `set` attributes work inside templates.
|
|
107
|
+
|
|
108
|
+
## API
|
|
109
|
+
|
|
110
|
+
### `mount(store, root?)`
|
|
111
|
+
|
|
112
|
+
Scans the DOM tree rooted at `root` (default: `document.body`) for `bind-*`, `set`, and `each` attributes. Sets up event delegation, bindings, and collections. Returns a cleanup function.
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
import { mount } from '@uistate/renderer';
|
|
116
|
+
|
|
117
|
+
const cleanup = mount(store);
|
|
118
|
+
// Later: cleanup() to remove all subscriptions and listeners
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Pure Functions (testable in Node — no DOM required)
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
import { parseSetExpr, evalExpr, parsePush } from '@uistate/renderer';
|
|
125
|
+
|
|
126
|
+
parseSetExpr('count:increment');
|
|
127
|
+
// → { path: 'count', expr: 'increment' }
|
|
128
|
+
|
|
129
|
+
evalExpr('increment', 5);
|
|
130
|
+
// → 6
|
|
131
|
+
|
|
132
|
+
parsePush('push(draft)');
|
|
133
|
+
// → { source: 'draft' }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
These are the same functions the renderer uses internally. Because they're pure, they run in Node with zero dependencies — enabling the self-test.
|
|
137
|
+
|
|
138
|
+
## Testing
|
|
139
|
+
|
|
140
|
+
Two test layers, both DOMless:
|
|
141
|
+
|
|
142
|
+
### `self-test.js` — Pure function tests (zero dependencies)
|
|
143
|
+
|
|
144
|
+
Tests the renderer's internal pure functions (`parseSetExpr`, `evalExpr`, `parsePush`) in Node. No store, no DOM, no test framework, no devDependencies. Runs automatically on `npm install` via the `postinstall` hook. **39 assertions, instant feedback.**
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
node self-test.js
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### `tests/renderer.test.js` — Store integration tests
|
|
151
|
+
|
|
152
|
+
Tests full state workflows via `@uistate/event-test`: CRUD cycles, editing lifecycles, wildcard subscriptions, batch operations. Creates real EventState stores and exercises the same dot-path patterns the renderer drives — still without touching the DOM. **34 tests, all passing.**
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm test
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The self-test is a direct consequence of the architecture: since the renderer's logic is split into pure functions and a DOM-mounting function (`mount`), the pure functions are testable without a DOM, without JSDOM, without any test framework.
|
|
159
|
+
|
|
160
|
+
## What's Not Here
|
|
161
|
+
|
|
162
|
+
- No virtual DOM
|
|
163
|
+
- No template interpolation engine
|
|
164
|
+
- No innerHTML in the render loop
|
|
165
|
+
- No diffing algorithm
|
|
166
|
+
- No component lifecycle
|
|
167
|
+
- No build step
|
|
168
|
+
- No JSX transform
|
|
169
|
+
|
|
170
|
+
HTML is the skeleton. The store is the brain. Bindings are the nerves.
|
|
171
|
+
|
|
172
|
+
## Philosophy
|
|
173
|
+
|
|
174
|
+
The renderer exists to prove that **the state layer is the real product**. The same `@uistate/core` store works with React, Vue, Svelte, Angular — or with this 268-line renderer that needs nothing but a browser.
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
A React Component (for comparison): f(props, ownState, lifecycle, hooks, context, memo, refs) → VDOM → DOM
|
|
178
|
+
A UIstate Renderer (for comparison): mount(store) → bind-text="path" → textContent
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Author
|
|
182
|
+
|
|
183
|
+
Ajdin Imsirovic
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
Proprietary — see [LICENSE.md](./LICENSE.md)
|
package/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uistate/renderer - Direct-binding reactive renderer for @uistate/core
|
|
3
|
+
*
|
|
4
|
+
* Bind DOM nodes to store paths with bind-* attributes and set actions.
|
|
5
|
+
* Zero build step. Licensed under a proprietary license — see LICENSE.md.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
parseSetExpr,
|
|
10
|
+
evalExpr,
|
|
11
|
+
parsePush,
|
|
12
|
+
mount
|
|
13
|
+
} from './renderer.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uistate/renderer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Direct-binding reactive renderer for @uistate/core. Zero build step.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./renderer": "./renderer.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"renderer.js",
|
|
14
|
+
"self-test.js",
|
|
15
|
+
"LICENSE.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postinstall": "node self-test.js",
|
|
19
|
+
"test": "node tests/renderer.test.js",
|
|
20
|
+
"self-test": "node self-test.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"uistate",
|
|
24
|
+
"renderer",
|
|
25
|
+
"reactive-html",
|
|
26
|
+
"subscribe-element",
|
|
27
|
+
"zero-build",
|
|
28
|
+
"dom-binding",
|
|
29
|
+
"state-management",
|
|
30
|
+
"path-based"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@uistate/core": ">=5.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@uistate/event-test": "^1.0.0"
|
|
37
|
+
},
|
|
38
|
+
"author": "Ajdin Imsirovic",
|
|
39
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/ImsirovicAjdin/uistate-renderer"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/renderer.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uistate/renderer — Direct-binding reactive renderer for EventState
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 Ajdin Imsirovic
|
|
5
|
+
*
|
|
6
|
+
* Three primitives:
|
|
7
|
+
* 1. Delegated Actions (DOM → Store): set, set-blur, set-enter
|
|
8
|
+
* 2. Direct Node Binding (Store → DOM): bind-text, bind-value, bind-data-*, bind-focus
|
|
9
|
+
* 3. Keyed Collections: each="path" + <template>
|
|
10
|
+
*
|
|
11
|
+
* No templates. No innerHTML. No interpolation. No diffing.
|
|
12
|
+
* Event delegation survives DOM mutations. Bindings are surgical.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── Pure helpers ──────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const BOUND = Symbol('r2');
|
|
18
|
+
|
|
19
|
+
export function parseSetExpr(raw) {
|
|
20
|
+
const i = raw.indexOf(':');
|
|
21
|
+
if (i === -1) return { path: raw.trim(), expr: null };
|
|
22
|
+
return { path: raw.slice(0, i).trim(), expr: raw.slice(i + 1).trim() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function evalExpr(expr, current) {
|
|
26
|
+
if (expr == null) return current;
|
|
27
|
+
if (expr === 'increment') return (Number(current) || 0) + 1;
|
|
28
|
+
if (expr === 'decrement') return (Number(current) || 0) - 1;
|
|
29
|
+
if (expr === 'toggle') return !current;
|
|
30
|
+
if (expr === 'true') return true;
|
|
31
|
+
if (expr === 'false') return false;
|
|
32
|
+
if (expr === 'null') return null;
|
|
33
|
+
const n = Number(expr);
|
|
34
|
+
if (!isNaN(n) && expr.trim() !== '') return n;
|
|
35
|
+
try { return JSON.parse(expr); } catch (_) {}
|
|
36
|
+
return expr;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parsePush(expr) {
|
|
40
|
+
if (!expr) return null;
|
|
41
|
+
if (expr === 'push') return { source: null };
|
|
42
|
+
const m = expr.match(/^push\(([^)]+)\)$/);
|
|
43
|
+
return m ? { source: m[1].trim() } : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Mount ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function mount(store, root = document.body) {
|
|
49
|
+
const subs = []; // { node, unsub }
|
|
50
|
+
|
|
51
|
+
// ── Binding helpers ──
|
|
52
|
+
|
|
53
|
+
function addBinding(path, node, updateFn) {
|
|
54
|
+
updateFn(store.get(path));
|
|
55
|
+
const unsub = store.subscribe(path, (value) => updateFn(value));
|
|
56
|
+
subs.push({ node, unsub });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cleanupWithin(container) {
|
|
60
|
+
for (let i = subs.length - 1; i >= 0; i--) {
|
|
61
|
+
if (container.contains(subs[i].node)) {
|
|
62
|
+
subs[i].unsub();
|
|
63
|
+
subs.splice(i, 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Scan for bind-* attributes ──
|
|
69
|
+
|
|
70
|
+
function scanBindings(el) {
|
|
71
|
+
const nodes = el instanceof Element ? [el, ...el.querySelectorAll('*')] : [];
|
|
72
|
+
|
|
73
|
+
for (const node of nodes) {
|
|
74
|
+
if (node[BOUND]) continue;
|
|
75
|
+
node[BOUND] = true;
|
|
76
|
+
|
|
77
|
+
// bind-text: one-way, textContent
|
|
78
|
+
if (node.hasAttribute('bind-text')) {
|
|
79
|
+
const p = node.getAttribute('bind-text');
|
|
80
|
+
addBinding(p, node, v => {
|
|
81
|
+
node.textContent = v != null ? String(v) : '';
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// bind-value: two-way for inputs
|
|
86
|
+
if (node.hasAttribute('bind-value')) {
|
|
87
|
+
const p = node.getAttribute('bind-value');
|
|
88
|
+
addBinding(p, node, v => {
|
|
89
|
+
const s = v != null ? String(v) : '';
|
|
90
|
+
if (node.type === 'checkbox') {
|
|
91
|
+
if (node.checked !== !!v) node.checked = !!v;
|
|
92
|
+
} else {
|
|
93
|
+
if (node.value !== s) node.value = s;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
node.addEventListener('input', () => {
|
|
97
|
+
store.set(p, node.type === 'checkbox' ? node.checked : node.value);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// bind-focus: focus element when value is truthy
|
|
102
|
+
if (node.hasAttribute('bind-focus')) {
|
|
103
|
+
const p = node.getAttribute('bind-focus');
|
|
104
|
+
addBinding(p, node, v => {
|
|
105
|
+
if (v) requestAnimationFrame(() => {
|
|
106
|
+
if (node.offsetParent !== null) node.focus();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// bind-data-* → dataset, bind-attr-* → setAttribute
|
|
112
|
+
for (const attr of Array.from(node.attributes)) {
|
|
113
|
+
if (attr.name.startsWith('bind-data-')) {
|
|
114
|
+
const dName = attr.name.slice(10);
|
|
115
|
+
const p = attr.value;
|
|
116
|
+
addBinding(p, node, v => {
|
|
117
|
+
node.dataset[dName] = v != null ? String(v) : '';
|
|
118
|
+
});
|
|
119
|
+
} else if (attr.name.startsWith('bind-attr-')) {
|
|
120
|
+
const aName = attr.name.slice(10);
|
|
121
|
+
const p = attr.value;
|
|
122
|
+
addBinding(p, node, v => {
|
|
123
|
+
if (v != null) node.setAttribute(aName, String(v));
|
|
124
|
+
else node.removeAttribute(aName);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Delegated action execution ──
|
|
132
|
+
|
|
133
|
+
function executeSet(raw) {
|
|
134
|
+
const { path, expr } = parseSetExpr(raw);
|
|
135
|
+
|
|
136
|
+
// delete: remove key from parent collection
|
|
137
|
+
if (expr === 'delete') {
|
|
138
|
+
const dot = path.lastIndexOf('.');
|
|
139
|
+
if (dot === -1) return;
|
|
140
|
+
const parentPath = path.slice(0, dot);
|
|
141
|
+
const key = path.slice(dot + 1);
|
|
142
|
+
const parent = store.get(parentPath) || {};
|
|
143
|
+
const updated = {};
|
|
144
|
+
for (const k of Object.keys(parent)) {
|
|
145
|
+
if (k !== key) updated[k] = parent[k];
|
|
146
|
+
}
|
|
147
|
+
store.set(parentPath, updated);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// push(sourcePath): clone source into collection, reset source
|
|
152
|
+
const push = parsePush(expr);
|
|
153
|
+
if (push) {
|
|
154
|
+
const sourcePath = push.source;
|
|
155
|
+
if (!sourcePath) return;
|
|
156
|
+
const src = store.get(sourcePath);
|
|
157
|
+
if (!src || typeof src !== 'object') return;
|
|
158
|
+
const key = `t_${Date.now()}`;
|
|
159
|
+
store.batch(() => {
|
|
160
|
+
store.set(`${path}.${key}`, JSON.parse(JSON.stringify(src)));
|
|
161
|
+
for (const k of Object.keys(src)) {
|
|
162
|
+
const v = src[k];
|
|
163
|
+
store.set(`${sourcePath}.${k}`,
|
|
164
|
+
typeof v === 'string' ? '' :
|
|
165
|
+
typeof v === 'boolean' ? false :
|
|
166
|
+
typeof v === 'number' ? 0 : null
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// normal: evaluate expression and set
|
|
174
|
+
store.set(path, evalExpr(expr, store.get(path)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Step 1: Event delegation (once, never re-wired) ──
|
|
178
|
+
|
|
179
|
+
root.addEventListener('click', e => {
|
|
180
|
+
const t = e.target.closest('[set]');
|
|
181
|
+
if (t && root.contains(t)) executeSet(t.getAttribute('set'));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
root.addEventListener('focusout', e => {
|
|
185
|
+
const t = e.target.closest('[set-blur]');
|
|
186
|
+
if (t && root.contains(t)) executeSet(t.getAttribute('set-blur'));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
root.addEventListener('keydown', e => {
|
|
190
|
+
if (e.key !== 'Enter') return;
|
|
191
|
+
const t = e.target.closest('[set-enter]');
|
|
192
|
+
if (t && root.contains(t)) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
executeSet(t.getAttribute('set-enter'));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── Step 3: Keyed collections ──
|
|
199
|
+
|
|
200
|
+
function setupCollection(container) {
|
|
201
|
+
const collPath = container.getAttribute('each');
|
|
202
|
+
const tpl = container.querySelector('template');
|
|
203
|
+
if (!collPath || !tpl) return;
|
|
204
|
+
|
|
205
|
+
const templateHTML = tpl.innerHTML.trim();
|
|
206
|
+
const rendered = new Map(); // key → element
|
|
207
|
+
let lastKeyStr = '';
|
|
208
|
+
|
|
209
|
+
function resolve(html, key) {
|
|
210
|
+
return html
|
|
211
|
+
.replace(/\{_key\}/g, key)
|
|
212
|
+
.replace(/\{_path\}/g, `${collPath}.${key}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function reconcile() {
|
|
216
|
+
const coll = store.get(collPath) || {};
|
|
217
|
+
const keys = Object.keys(coll).filter(k => coll[k] != null);
|
|
218
|
+
const keyStr = keys.join(',');
|
|
219
|
+
|
|
220
|
+
// Fast bail: same keys, no structural change
|
|
221
|
+
if (keyStr === lastKeyStr) return;
|
|
222
|
+
lastKeyStr = keyStr;
|
|
223
|
+
|
|
224
|
+
const keySet = new Set(keys);
|
|
225
|
+
|
|
226
|
+
// Remove deleted items
|
|
227
|
+
for (const [k, el] of rendered) {
|
|
228
|
+
if (!keySet.has(k)) {
|
|
229
|
+
cleanupWithin(el);
|
|
230
|
+
el.remove();
|
|
231
|
+
rendered.delete(k);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Add new items
|
|
236
|
+
for (const k of keys) {
|
|
237
|
+
if (!rendered.has(k)) {
|
|
238
|
+
const html = resolve(templateHTML, k);
|
|
239
|
+
const tmp = document.createElement('div');
|
|
240
|
+
tmp.innerHTML = html;
|
|
241
|
+
const el = tmp.firstElementChild;
|
|
242
|
+
if (!el) continue;
|
|
243
|
+
el.dataset.key = k;
|
|
244
|
+
container.appendChild(el);
|
|
245
|
+
rendered.set(k, el);
|
|
246
|
+
scanBindings(el);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Subscribe: exact (for delete/replace) + wildcard (for child add)
|
|
252
|
+
const u1 = store.subscribe(collPath, reconcile);
|
|
253
|
+
const u2 = store.subscribe(`${collPath}.*`, reconcile);
|
|
254
|
+
subs.push({ node: container, unsub: u1 });
|
|
255
|
+
subs.push({ node: container, unsub: u2 });
|
|
256
|
+
|
|
257
|
+
reconcile();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Init ──
|
|
261
|
+
|
|
262
|
+
root.querySelectorAll('[each]').forEach(setupCollection);
|
|
263
|
+
scanBindings(root);
|
|
264
|
+
|
|
265
|
+
// Return cleanup function
|
|
266
|
+
return () => subs.forEach(s => s.unsub());
|
|
267
|
+
}
|
package/self-test.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uistate/renderer — self-test
|
|
3
|
+
*
|
|
4
|
+
* Standalone test of pure functions. No dependencies beyond renderer.js.
|
|
5
|
+
* Runs on `node self-test.js` or as a postinstall hook.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
parseSetExpr,
|
|
10
|
+
evalExpr,
|
|
11
|
+
parsePush
|
|
12
|
+
} from './renderer.js';
|
|
13
|
+
|
|
14
|
+
let passed = 0;
|
|
15
|
+
let failed = 0;
|
|
16
|
+
|
|
17
|
+
function assert(name, condition) {
|
|
18
|
+
if (condition) {
|
|
19
|
+
passed++;
|
|
20
|
+
} else {
|
|
21
|
+
failed++;
|
|
22
|
+
console.error(` ✗ ${name}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── parseSetExpr ────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const p1 = parseSetExpr('count:increment');
|
|
29
|
+
assert('parseSetExpr: path', p1.path === 'count');
|
|
30
|
+
assert('parseSetExpr: expr', p1.expr === 'increment');
|
|
31
|
+
|
|
32
|
+
const p2 = parseSetExpr('count');
|
|
33
|
+
assert('parseSetExpr: path only', p2.path === 'count');
|
|
34
|
+
assert('parseSetExpr: no expr → null', p2.expr === null);
|
|
35
|
+
|
|
36
|
+
const p3 = parseSetExpr('user.name:Bob');
|
|
37
|
+
assert('parseSetExpr: dotted path', p3.path === 'user.name');
|
|
38
|
+
assert('parseSetExpr: string expr', p3.expr === 'Bob');
|
|
39
|
+
|
|
40
|
+
const p4 = parseSetExpr('count:0');
|
|
41
|
+
assert('parseSetExpr: zero expr', p4.expr === '0');
|
|
42
|
+
|
|
43
|
+
const p5 = parseSetExpr('todos:push');
|
|
44
|
+
assert('parseSetExpr: push path', p5.path === 'todos');
|
|
45
|
+
assert('parseSetExpr: push expr', p5.expr === 'push');
|
|
46
|
+
|
|
47
|
+
const p6 = parseSetExpr('todos:push(draft)');
|
|
48
|
+
assert('parseSetExpr: push(source) path', p6.path === 'todos');
|
|
49
|
+
assert('parseSetExpr: push(source) expr', p6.expr === 'push(draft)');
|
|
50
|
+
|
|
51
|
+
const p7 = parseSetExpr('todos.t1:delete');
|
|
52
|
+
assert('parseSetExpr: delete path', p7.path === 'todos.t1');
|
|
53
|
+
assert('parseSetExpr: delete expr', p7.expr === 'delete');
|
|
54
|
+
|
|
55
|
+
const p8 = parseSetExpr('todos.t1.editing:false');
|
|
56
|
+
assert('parseSetExpr: editing path', p8.path === 'todos.t1.editing');
|
|
57
|
+
assert('parseSetExpr: editing expr', p8.expr === 'false');
|
|
58
|
+
|
|
59
|
+
const p9 = parseSetExpr(' count : increment ');
|
|
60
|
+
assert('parseSetExpr: trims whitespace path', p9.path === 'count');
|
|
61
|
+
assert('parseSetExpr: trims whitespace expr', p9.expr === 'increment');
|
|
62
|
+
|
|
63
|
+
// ── evalExpr ────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
assert('evalExpr: increment', evalExpr('increment', 5) === 6);
|
|
66
|
+
assert('evalExpr: increment from 0', evalExpr('increment', 0) === 1);
|
|
67
|
+
assert('evalExpr: increment from null', evalExpr('increment', null) === 1);
|
|
68
|
+
assert('evalExpr: decrement', evalExpr('decrement', 5) === 4);
|
|
69
|
+
assert('evalExpr: decrement from 0', evalExpr('decrement', 0) === -1);
|
|
70
|
+
assert('evalExpr: toggle true→false', evalExpr('toggle', true) === false);
|
|
71
|
+
assert('evalExpr: toggle false→true', evalExpr('toggle', false) === true);
|
|
72
|
+
assert('evalExpr: number 42', evalExpr('42', 0) === 42);
|
|
73
|
+
assert('evalExpr: number 0', evalExpr('0', 99) === 0);
|
|
74
|
+
assert('evalExpr: negative number', evalExpr('-5', 0) === -5);
|
|
75
|
+
assert('evalExpr: boolean true', evalExpr('true', 0) === true);
|
|
76
|
+
assert('evalExpr: boolean false', evalExpr('false', 1) === false);
|
|
77
|
+
assert('evalExpr: null keyword', evalExpr('null', 'x') === null);
|
|
78
|
+
assert('evalExpr: plain string', evalExpr('hello', '') === 'hello');
|
|
79
|
+
assert('evalExpr: null expr → returns current', evalExpr(null, 7) === 7);
|
|
80
|
+
assert('evalExpr: undefined expr → returns current', evalExpr(undefined, 7) === 7);
|
|
81
|
+
|
|
82
|
+
// ── parsePush ───────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const pp1 = parsePush('push');
|
|
85
|
+
assert('parsePush: bare push → source null', pp1 !== null && pp1.source === null);
|
|
86
|
+
|
|
87
|
+
const pp2 = parsePush('push(draft)');
|
|
88
|
+
assert('parsePush: push(draft) → source "draft"', pp2 !== null && pp2.source === 'draft');
|
|
89
|
+
|
|
90
|
+
const pp3 = parsePush('push(form.data)');
|
|
91
|
+
assert('parsePush: push(form.data) → source "form.data"', pp3 !== null && pp3.source === 'form.data');
|
|
92
|
+
|
|
93
|
+
assert('parsePush: non-push returns null', parsePush('increment') === null);
|
|
94
|
+
assert('parsePush: null returns null', parsePush(null) === null);
|
|
95
|
+
assert('parsePush: empty string returns null', parsePush('') === null);
|
|
96
|
+
|
|
97
|
+
// ── Results ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
console.log(`\n@uistate/renderer v1.0.0 — self-test`);
|
|
100
|
+
console.log(`✓ ${passed} assertions passed${failed ? `, ✗ ${failed} failed` : ''}\n`);
|
|
101
|
+
|
|
102
|
+
if (failed > 0) process.exit(1);
|