shablon 0.0.1-rc.1
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 +14 -0
- package/README.md +345 -0
- package/index.js +3 -0
- package/package.json +34 -0
- package/src/router.js +134 -0
- package/src/state.js +304 -0
- package/src/template.js +405 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Zero-Clause BSD (0BSD)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 - present, Gani Georgiev
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for
|
|
6
|
+
any purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
|
|
9
|
+
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
|
|
10
|
+
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
|
|
11
|
+
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
|
|
12
|
+
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
|
13
|
+
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
|
14
|
+
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
Shablon - No-build JavaScript frontend framework
|
|
2
|
+
======================================================================
|
|
3
|
+
|
|
4
|
+
> [!CAUTION]
|
|
5
|
+
> This is mostly an experiment created for the planned [PocketBase](https://github.com/pocketbase/pocketbase) UI rewrite to allow frontend plugins support.
|
|
6
|
+
>
|
|
7
|
+
> **Don't use it yet - it hasn't been actually tested in real applications and it may change without notice!**
|
|
8
|
+
|
|
9
|
+
**Shablon** _("template" in Bulgarian)_ is a ~5KB JS framework that comes with deeply reactive state management, plain JS extendable templates and hash-based router.
|
|
10
|
+
|
|
11
|
+
Shablon has very small learning curve (**4 main exported functions**) and it is suitable for building Single-page applications (SPA):
|
|
12
|
+
|
|
13
|
+
- State: `store(obj)` and `watch(callback)`
|
|
14
|
+
- Template: `t.[tag](attrs, ...children)`
|
|
15
|
+
- Router: `router(routes, options)`
|
|
16
|
+
|
|
17
|
+
There is no dedicated "component" structure. Everything is essentially plain DOM elements sprinkled with a little reactivity.
|
|
18
|
+
|
|
19
|
+
Below is an example _Todos list "component"_ to see how it looks:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
function todos() {
|
|
23
|
+
const data = store({
|
|
24
|
+
todos: [],
|
|
25
|
+
newTitle: "",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// external watcher
|
|
29
|
+
const w = watch(() => {
|
|
30
|
+
console.log("new title:", data.newTitle)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return t.div({ className: "todos-list", onunmount: () => w.unwatch() },
|
|
34
|
+
t.h1({ textContent: "Todos" }),
|
|
35
|
+
t.ul({ style: "margin: 20px 0" },
|
|
36
|
+
() => {
|
|
37
|
+
if (!data.todos.length) {
|
|
38
|
+
return t.li({ rid: "notodos", textContent: "No todos." })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return data.todos.map((todo) => {
|
|
42
|
+
return t.li({ rid: todo, textContent: () => todo.title })
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
t.hr(),
|
|
47
|
+
t.input({ type: "text", value: () => data.newTitle, oninput: (e) => data.newTitle = e.target.value }),
|
|
48
|
+
t.button({ textContent: "Add", onclick: () => data.todos.push({ title: data.newTitle }) })
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
document.getElementById("app").replaceChildren(todos());
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
|
|
57
|
+
<summary>
|
|
58
|
+
Example Svelte 5 equivalent code for comparison
|
|
59
|
+
|
|
60
|
+
_Shablon is not as pretty as Svelte but it strives for similar developer experience._
|
|
61
|
+
</summary>
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
```svelte
|
|
65
|
+
<script>
|
|
66
|
+
let todos = $state([])
|
|
67
|
+
let newTitle = $state("")
|
|
68
|
+
|
|
69
|
+
// external watcher
|
|
70
|
+
// note: no need to manually call "untrack" because Svelte does it automatically on component unmount
|
|
71
|
+
$effect(() => {
|
|
72
|
+
console.log("new title:", newTitle)
|
|
73
|
+
})
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<div class="todos-list">
|
|
77
|
+
<h1>Todos</h1>
|
|
78
|
+
<ul style="margin: 20px 0">
|
|
79
|
+
{#each todos as todo}
|
|
80
|
+
<li>{todo.title}</li>
|
|
81
|
+
{:else}
|
|
82
|
+
<li>No todos.</li>
|
|
83
|
+
{/each}
|
|
84
|
+
</ul>
|
|
85
|
+
<hr>
|
|
86
|
+
<input type="text" bind:value="{newTitle}">
|
|
87
|
+
<button onclick="{() => todos.push({ title: newTitle })}">Add</button>
|
|
88
|
+
</div>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
</details>
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
> You can also check the [`example` folder](https://github.com/ganigeorgiev/shablon/tree/master/example) for a showcase of a minimal SPA with 2 pages.
|
|
98
|
+
|
|
99
|
+
#### Global via script tag (browsers)
|
|
100
|
+
|
|
101
|
+
The default [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) bundle will load all exported Shablon functions in the global context.
|
|
102
|
+
You can find the bundle file at [`dist/shablon.iife.js`](https://github.com/ganigeorgiev/shablon/blob/master/dist/shablon.iife.js) (or use a CDN pointing to it):
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<!-- <script src="https://cdn.jsdelivr.net/gh/ganigeorgiev/shablon@master/dist/shablon.iife.js"></script> -->
|
|
106
|
+
<script src="/path/to/dist/shablon.iife.js"></script>
|
|
107
|
+
<script type="text/javascript">
|
|
108
|
+
const data = store({ count: 0 })
|
|
109
|
+
...
|
|
110
|
+
</script>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### ES module (browsers and npm)
|
|
114
|
+
|
|
115
|
+
Alternatively, you can load the package as ES module by using the [`dist/shablon.es.js`](https://github.com/ganigeorgiev/shablon/blob/master/dist/shablon.es.js) file.
|
|
116
|
+
|
|
117
|
+
<!-- Alternatively, you can load the package as ES module either by using the [`dist/shablon.es.js`](https://github.com/ganigeorgiev/shablon/blob/master/dist/shablon.es.js) file or
|
|
118
|
+
importing it from [npm](https://www.npmjs.com/package/shablon). -->
|
|
119
|
+
|
|
120
|
+
- browsers:
|
|
121
|
+
```html
|
|
122
|
+
<!-- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules -->
|
|
123
|
+
<script type="module">
|
|
124
|
+
import { t, store, watch, router } from "/path/to/dist/shablon.es.js"
|
|
125
|
+
|
|
126
|
+
const data = store({ count: 0 })
|
|
127
|
+
...
|
|
128
|
+
</script>
|
|
129
|
+
```
|
|
130
|
+
<!--
|
|
131
|
+
- npm (`npm -i shablon`):
|
|
132
|
+
```js
|
|
133
|
+
import { t, store, watch, router } from "shablon"
|
|
134
|
+
|
|
135
|
+
const data = store({ count: 0 })
|
|
136
|
+
...
|
|
137
|
+
```
|
|
138
|
+
-->
|
|
139
|
+
## API
|
|
140
|
+
<details>
|
|
141
|
+
<summary><strong id="api.store">store(obj)</strong></summary>
|
|
142
|
+
|
|
143
|
+
`store(obj)` returns a reactive `Proxy` of the specified plain object.
|
|
144
|
+
|
|
145
|
+
The keys of an `obj` must be "stringifiable" because they are used internally to construct a path to the reactive value.
|
|
146
|
+
|
|
147
|
+
The values can be any valid JS value, including nested arrays and objects (aka. it is recursively reactive).
|
|
148
|
+
|
|
149
|
+
Getters are also supported and can be used as reactive computed properties.
|
|
150
|
+
The value of a reactive getter is "cached", meaning that even if one of the getter dependency changes, as long as the resulting value is the same there will be no unnecessary watch events fired.
|
|
151
|
+
|
|
152
|
+
Multiple changes from one or many stores are also automatically batched in a microtask. For example:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
const data = store({ age: 49, activity: "work" })
|
|
156
|
+
|
|
157
|
+
watch(() => {
|
|
158
|
+
console.log("age", data.age)
|
|
159
|
+
console.log("activity", data.activity)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// changing both fields will trigger the watcher only once
|
|
163
|
+
data.age++
|
|
164
|
+
data.activity = "rest"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
</details>
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
<details>
|
|
171
|
+
<summary><strong id="api.watch">watch(callback)</strong></summary>
|
|
172
|
+
|
|
173
|
+
Watch registers a callback function that fires once on initialization and
|
|
174
|
+
every time any of its `store` reactive dependencies change.
|
|
175
|
+
|
|
176
|
+
It returns a "watcher" object that could be used to `unwatch` the registered listener.
|
|
177
|
+
|
|
178
|
+
For example:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const data = store({ count: 0 })
|
|
182
|
+
|
|
183
|
+
const w = watch(() => console.log(data.count))
|
|
184
|
+
|
|
185
|
+
data.count++ // triggers watch update
|
|
186
|
+
|
|
187
|
+
w.unwatch()
|
|
188
|
+
|
|
189
|
+
data.count++ // doesn't trigger watch update
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Note that for reactive getters, initially the watch callback will be invoked twice because we register a second internal watcher to cache the getter value.
|
|
193
|
+
|
|
194
|
+
</details>
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
<details>
|
|
198
|
+
<summary><strong id="api.t">t.[tag](attrs, ...children)</strong></summary>
|
|
199
|
+
|
|
200
|
+
`t.[tag](attrs, ...children)` constructs and returns a new DOM element (aka. `document.createElement(tag)`).
|
|
201
|
+
|
|
202
|
+
`tag` could be any valid HTML element name - `div`, `span`, `hr`, `img`, registered custom web component, etc.
|
|
203
|
+
|
|
204
|
+
`attrs` is an object where the keys are:
|
|
205
|
+
- valid element's [JS property](https://developer.mozilla.org/en-US/docs/Web/API/Element#instance_properties)
|
|
206
|
+
_(note that some HTML attribute names are different from their JS property equivalent, e.g. `class` vs `className`, `for` vs `htmlFor`, etc.)_
|
|
207
|
+
- regular or custom HTML attribute if it has `html-` prefix _(it is stripped from the final attribute)_, e.g. `html-data-name`
|
|
208
|
+
|
|
209
|
+
The attributes value could be a plain JS value or reactive function that returns such value _(e.g. `() => data.count`)_.
|
|
210
|
+
|
|
211
|
+
`children` is an optional list of child elements that could be:
|
|
212
|
+
- plain text (inserted as `TextNode`)
|
|
213
|
+
- single tag
|
|
214
|
+
- array of tags
|
|
215
|
+
- reactive function that returns any of the above
|
|
216
|
+
|
|
217
|
+
When a reactive function is set as attribute value or child, it is invoked only when the element is mounted and automatically "unwatched" on element removal _(with slight debounce to minimize render blocking)_.
|
|
218
|
+
|
|
219
|
+
**Lifecycle attributes**
|
|
220
|
+
|
|
221
|
+
Each constructed tag has 3 additional optional lifecycle attributes:
|
|
222
|
+
|
|
223
|
+
- `onmount: func` - optional callback called when the element is inserted in the DOM
|
|
224
|
+
- `onunmount: func` - optional callback called when the element is removed from the DOM
|
|
225
|
+
- `rid: any` - "replacement id" is an identifier based on which we can decide whether to reuse the element or not during rerendering (e.g. on list change); the value could be anything comparable with `==`
|
|
226
|
+
|
|
227
|
+
</details>
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
<details>
|
|
231
|
+
<summary><strong id="api.router">router(routes, options)</strong></summary>
|
|
232
|
+
|
|
233
|
+
`router(routes, options = { fallbackPath: "#/", transition: true })` initializes a hash-based client-side router by loading the provided routes configuration and listens for hash navigation changes.
|
|
234
|
+
|
|
235
|
+
`routes` is a key-value object where:
|
|
236
|
+
- the key must be a string path such as `#/a/b/{someParam}`
|
|
237
|
+
- value is a route handler function that executes every time the page hash matches with the route's path
|
|
238
|
+
_(the route handler can return a "destroy" function that is invoked when navigating away from that route)_
|
|
239
|
+
|
|
240
|
+
Note that by default the router expects to have at least one "#/" route that will be also used as fallback in case the user navigate to a missing page.
|
|
241
|
+
|
|
242
|
+
For example:
|
|
243
|
+
|
|
244
|
+
```js
|
|
245
|
+
router({
|
|
246
|
+
"#/": (route) => {
|
|
247
|
+
document.getElementById(app).replaceChildren(
|
|
248
|
+
t.div({ textContent: "Homepage!"})
|
|
249
|
+
)
|
|
250
|
+
},
|
|
251
|
+
"#/users/{id}": (route) => {
|
|
252
|
+
document.getElementById(app).replaceChildren(
|
|
253
|
+
t.div({ textContent: "User " + route.params.id })
|
|
254
|
+
)
|
|
255
|
+
return () => { console.log("cleanup...") }
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
</details>
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
## Performance and caveats
|
|
264
|
+
|
|
265
|
+
No extensive testing or benchmarks have been done yet but for the simple cases it should perform as fast as it could get because we update only the targeted DOM attribute when possible _(furthermore multiple store changes are auto batched per microtask to ensure that watchers are not invoked unnecessary)_.
|
|
266
|
+
|
|
267
|
+
For example, the expression `t.div({ textContent: () => data.title })` is roughly the same as the following pseudo-code:
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
const div = document.createElement("div")
|
|
271
|
+
div.textContent = data.title
|
|
272
|
+
|
|
273
|
+
function onTitleChange() {
|
|
274
|
+
div.textContent = data.title
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Conditional rendering tags as part of a reactive child function is a little bit more complicated though.
|
|
279
|
+
By default when such function runs due to a store dependency change, the old children will be removed and the new ones will be inserted on every call of that function which could be unnecessary if the tags hasn't really changed.
|
|
280
|
+
|
|
281
|
+
To avoid this you can specify the `rid` attribute which instructs Shablon to reuse the same element if the old and new `rid` are the same minimizing the DOM operations. For example:
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
const data = store({ count: 0, list: ["a", "b", "c"] })
|
|
285
|
+
|
|
286
|
+
// ALWAYS replace the child tags on every data.count or data.list change
|
|
287
|
+
t.div({ className: "bad"},
|
|
288
|
+
() => {
|
|
289
|
+
if (data.count < 2) {
|
|
290
|
+
return t.strong({}, "Not enough elements")
|
|
291
|
+
}
|
|
292
|
+
return data.list.map((item) => t.div({}, item))
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
// replace the child tags on data.count or data.list change
|
|
297
|
+
// ONLY if the tags "rid" attribute has changed
|
|
298
|
+
t.div({ className: "good"},
|
|
299
|
+
() => {
|
|
300
|
+
if (data.count < 2) {
|
|
301
|
+
return t.strong({ rid: "noelems" }, "Not enough elements")
|
|
302
|
+
}
|
|
303
|
+
return data.list.map((item) => t.div({ rid: item }, item))
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Other things that could be a performance bottleneck are the lifecycle attributes (`onmount`, `onunmount`) because currently they rely on a global `MutationObserver` which could be potentially slow for deeply nested elements due to the nature of the current recursive implementation _(this will be further evaluated during the actual integration in PocketBase)_.
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
## Security
|
|
312
|
+
|
|
313
|
+
Shablon **DOES NOT** perform any explicit escaping on its own and it relies on:
|
|
314
|
+
|
|
315
|
+
- modern browsers to perform TextNode _(when a child is a plain string)_ and attributes value escaping out of the box for us
|
|
316
|
+
- developers to use the appropriate safe JS properties (e.g. `textContent` instead of `innerHTML`)
|
|
317
|
+
|
|
318
|
+
**There could be some gaps and edge cases so I strongly recommend registering a [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) either as `meta` tag or HTTP header to prevent XSS attacks.**
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
## Why Shablon?
|
|
322
|
+
|
|
323
|
+
If you are not sure why would you use Shablon instead of Svelte, Lit, Vue, etc., then I'd suggest to simply pick one of the latter because they usually have a lot more features, can offer better ergonomics and have abundance of tutorials.
|
|
324
|
+
|
|
325
|
+
Shablon was created for my own projects, and more specifically for PocketBase in order to allow writing dynamically loaded dashboard UI plugins without requiring a Node.js build step.
|
|
326
|
+
Since I didn't feel comfortable maintaining UI plugins system on top of another framework with dozens other dependencies that tend to change in a non-compatible way over time, I've decided to try building my own with minimal API surface and that can be safely "frozen".
|
|
327
|
+
|
|
328
|
+
Shablon exists because:
|
|
329
|
+
|
|
330
|
+
- it can be quickly learned (4 main exported functions)
|
|
331
|
+
- it has minimal "magic" and no unsafe-eval (aka. it is Content Security Policy friendly)
|
|
332
|
+
- no IDE plugin or custom syntax highlighter is needed (it is plain JavaScript)
|
|
333
|
+
- the templates return regular [JS `Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element) allowing direct mutations
|
|
334
|
+
- it doesn't require build step and can be imported in the browser with a regular script tag
|
|
335
|
+
- it has no external dependencies and doesn't need to be updated frequently
|
|
336
|
+
- it is easy to maintain on my own _(under 2000 LOC with tests)_
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
## Contributing
|
|
340
|
+
|
|
341
|
+
Shablon is free and open source project licensed under the [Zero-Clause BSD License](https://github.com/ganigeorgiev/shablon/blob/master/LICENSE.md) _(no attribution required)_.
|
|
342
|
+
|
|
343
|
+
Feel free to report bugs, but feature requests are not welcomed.
|
|
344
|
+
|
|
345
|
+
**There are no plans to extend the project scope and once a stable PocketBase release is published it could be considered complete.**
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.0.1-rc.1",
|
|
3
|
+
"name": "shablon",
|
|
4
|
+
"description": "No-build JavaScript framework for Single-page applications",
|
|
5
|
+
"author": "Gani Georgiev",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": "./index.js",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git://github.com/ganigeorgiev/shablon.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"frontend",
|
|
15
|
+
"js-framework",
|
|
16
|
+
"no-build",
|
|
17
|
+
"spa"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "rolldown -c",
|
|
21
|
+
"format": "npx prettier ./src --write",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"jsdom": "^26.0.0",
|
|
26
|
+
"prettier": "^3.6.2",
|
|
27
|
+
"rolldown": "^1.0.0-beta.44"
|
|
28
|
+
},
|
|
29
|
+
"prettier": {
|
|
30
|
+
"tabWidth": 4,
|
|
31
|
+
"printWidth": 90,
|
|
32
|
+
"bracketSameLine": true
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @callback routeHandler
|
|
3
|
+
* @param {{params:Object.<string,string>, query:Object.<string,string[]>, path:string, regex:string, pattern:string, handler:Function}} route
|
|
4
|
+
* @return {Function|void} Optional destroy function.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Router set up a hash-based client-side router by loading the
|
|
8
|
+
* provided routes configuration and listens for hash navigation changes.
|
|
9
|
+
*
|
|
10
|
+
* `routes` is a key-value object where:
|
|
11
|
+
* - the key must be a string path such as "#/a/b/{someParam}"
|
|
12
|
+
* - value is a route handler function that executes every time the page hash matches with the route's path.
|
|
13
|
+
* The route handler can return a "destroy" function that will be invoked when navigating away from that route.
|
|
14
|
+
*
|
|
15
|
+
* Note that by default it expects to have at least one "#/" route that will be
|
|
16
|
+
* also used as fallback in case the user navigate to a page that is not defined.
|
|
17
|
+
*
|
|
18
|
+
* Example:
|
|
19
|
+
*
|
|
20
|
+
* ```js
|
|
21
|
+
* router({
|
|
22
|
+
* "#/": (route) => {
|
|
23
|
+
* document.getElementById(app).replaceChildren(
|
|
24
|
+
* t.div({ textContent: "Homepage!"})
|
|
25
|
+
* )
|
|
26
|
+
* },
|
|
27
|
+
* "#/users/{id}": (route) => {
|
|
28
|
+
* document.getElementById(app).replaceChildren(
|
|
29
|
+
* t.div({ textContent: "User " + route.params.id })
|
|
30
|
+
* )
|
|
31
|
+
* },
|
|
32
|
+
* })
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @param {Object.<string, routeHandler>} routes
|
|
36
|
+
* @param {Object} [options]
|
|
37
|
+
* @param {string} [options.fallbackPath]
|
|
38
|
+
* @param {boolean} [options.transition]
|
|
39
|
+
*/
|
|
40
|
+
export function router(routes, options = { fallbackPath: "#/", transition: true }) {
|
|
41
|
+
let defs = prepareRoutes(routes);
|
|
42
|
+
|
|
43
|
+
let prevDestroy;
|
|
44
|
+
|
|
45
|
+
let onHashChange = () => {
|
|
46
|
+
let path = window.location.hash || options.fallbackPath;
|
|
47
|
+
|
|
48
|
+
let route =
|
|
49
|
+
findActiveRoute(defs, path) || findActiveRoute(defs, options.fallbackPath);
|
|
50
|
+
if (!route) {
|
|
51
|
+
console.warn("no route found");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let navigate = async () => {
|
|
56
|
+
try {
|
|
57
|
+
await prevDestroy?.();
|
|
58
|
+
prevDestroy = await route.handler(route);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn("Route navigation failed:", err);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (options.transition && document.startViewTransition) {
|
|
65
|
+
document.startViewTransition(navigate);
|
|
66
|
+
} else {
|
|
67
|
+
navigate();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
window.addEventListener("hashchange", onHashChange);
|
|
72
|
+
|
|
73
|
+
onHashChange();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findActiveRoute(defs, path) {
|
|
77
|
+
for (let def of defs) {
|
|
78
|
+
let match = path.match(def.regex);
|
|
79
|
+
if (!match) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// extract query params (the value is always stored as array)
|
|
84
|
+
let query = {};
|
|
85
|
+
let rawQuery = path.split("?")?.[1];
|
|
86
|
+
if (rawQuery) {
|
|
87
|
+
let searchParams = new URLSearchParams(rawQuery);
|
|
88
|
+
for (let [key, value] of searchParams.entries()) {
|
|
89
|
+
if (!Array.isArray(query[key])) {
|
|
90
|
+
query[key] = query[key] ? [query[key]] : [];
|
|
91
|
+
}
|
|
92
|
+
query[key].push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Object.assign(
|
|
97
|
+
{
|
|
98
|
+
path: path,
|
|
99
|
+
query: query,
|
|
100
|
+
params: match.groups || {},
|
|
101
|
+
},
|
|
102
|
+
def,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function prepareRoutes(routes) {
|
|
108
|
+
let defs = [];
|
|
109
|
+
|
|
110
|
+
for (let path in routes) {
|
|
111
|
+
let parts = path.split("/");
|
|
112
|
+
for (let i in parts) {
|
|
113
|
+
if (
|
|
114
|
+
parts[i].length > 2 &&
|
|
115
|
+
parts[i].startsWith("{") &&
|
|
116
|
+
parts[i].endsWith("}")
|
|
117
|
+
) {
|
|
118
|
+
// param
|
|
119
|
+
parts[i] = "(?<" + parts[i].substring(1, parts[i].length - 1) + ">\\w+)";
|
|
120
|
+
} else {
|
|
121
|
+
// regular path segment
|
|
122
|
+
parts[i] = RegExp.escape(parts[i]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
defs.push({
|
|
127
|
+
regex: new RegExp("^" + parts.join("\\/") + "(?:[\?\#].*)?$"),
|
|
128
|
+
pattern: path,
|
|
129
|
+
handler: routes[path],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return defs;
|
|
134
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
let activeWatcher;
|
|
2
|
+
|
|
3
|
+
let flushQueue = new Set();
|
|
4
|
+
let allWatchers = new Map();
|
|
5
|
+
let toRemove = [];
|
|
6
|
+
let cleanTimeoutId;
|
|
7
|
+
|
|
8
|
+
let idSym = Symbol();
|
|
9
|
+
let parentSym = Symbol();
|
|
10
|
+
let childrenSym = Symbol();
|
|
11
|
+
let pathsSubsSym = Symbol();
|
|
12
|
+
let unwatchedSym = Symbol();
|
|
13
|
+
let onRemoveSym = Symbol();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Watch registers a callback function that fires on initialization and
|
|
17
|
+
* every time any of its `store` reactive dependencies changes.
|
|
18
|
+
*
|
|
19
|
+
* Returns a "watcher" object that could be used to `unwatch()` the registered listener.
|
|
20
|
+
*
|
|
21
|
+
* Example:
|
|
22
|
+
*
|
|
23
|
+
* ```js
|
|
24
|
+
* const data = store({ count: 0 })
|
|
25
|
+
*
|
|
26
|
+
* const sub = watch(() => console.log(data.count))
|
|
27
|
+
*
|
|
28
|
+
* data.count++ // triggers watch update
|
|
29
|
+
*
|
|
30
|
+
* sub.unwatch()
|
|
31
|
+
*
|
|
32
|
+
* data.count++ // doesn't trigger watch update
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @param {Function} callback
|
|
36
|
+
* @return {{unwatch:Function, last:any, run:Function}}
|
|
37
|
+
*/
|
|
38
|
+
export function watch(callback) {
|
|
39
|
+
let watcher = {
|
|
40
|
+
[idSym]: "" + Math.random(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
allWatchers.set(watcher[idSym], watcher);
|
|
44
|
+
|
|
45
|
+
watcher.run = () => {
|
|
46
|
+
// nested watcher -> register previous watcher as parent
|
|
47
|
+
if (activeWatcher) {
|
|
48
|
+
watcher[parentSym] = activeWatcher[idSym];
|
|
49
|
+
|
|
50
|
+
// store immediate children references for quicker cleanup
|
|
51
|
+
activeWatcher[childrenSym] = activeWatcher[childrenSym] || [];
|
|
52
|
+
activeWatcher[childrenSym].push(watcher[idSym]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
activeWatcher = watcher;
|
|
56
|
+
watcher.last = callback();
|
|
57
|
+
activeWatcher = allWatchers.get([watcher[parentSym]]); // restore parent ref (if any)
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
watcher.unwatch = function () {
|
|
61
|
+
watcher[unwatchedSym] = 1;
|
|
62
|
+
|
|
63
|
+
toRemove.push(watcher[idSym]);
|
|
64
|
+
|
|
65
|
+
if (cleanTimeoutId) {
|
|
66
|
+
clearTimeout(cleanTimeoutId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// note: debounced and executed as separate task to minimize blocking unmount rendering
|
|
70
|
+
cleanTimeoutId = setTimeout(() => {
|
|
71
|
+
for (let id of toRemove) {
|
|
72
|
+
removeWatcher(id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
toRemove = [];
|
|
76
|
+
cleanTimeoutId = null;
|
|
77
|
+
}, 50);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
watcher.run();
|
|
81
|
+
|
|
82
|
+
return watcher;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function removeWatcher(id) {
|
|
86
|
+
let w = allWatchers.get(id);
|
|
87
|
+
|
|
88
|
+
w?.[onRemoveSym]?.();
|
|
89
|
+
|
|
90
|
+
if (w?.[childrenSym]) {
|
|
91
|
+
for (let childId of w[childrenSym]) {
|
|
92
|
+
removeWatcher(childId);
|
|
93
|
+
}
|
|
94
|
+
w[parentSym] = null;
|
|
95
|
+
w[childrenSym] = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (w?.[pathsSubsSym]) {
|
|
99
|
+
for (let subset of w[pathsSubsSym]) {
|
|
100
|
+
if (subset.has(id)) {
|
|
101
|
+
subset.delete(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
w[pathsSubsSym] = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
allWatchers.delete(id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Creates a new deeply reactive store object that triggers `watch`
|
|
114
|
+
* update on change of a specific watched store property.
|
|
115
|
+
*
|
|
116
|
+
* Example:
|
|
117
|
+
*
|
|
118
|
+
* ```js
|
|
119
|
+
* const data = store({ count: 0, name: "test" })
|
|
120
|
+
*
|
|
121
|
+
* watch(() => console.log(data.count)) // should fire twice: 0 (initial), 1 (on increment)
|
|
122
|
+
*
|
|
123
|
+
* data.count++
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* Getters are also supported out of the box and they are invoked every
|
|
127
|
+
* time when any of their dependencies change.
|
|
128
|
+
* If a getter is used in a reactive function, its resulting value is cached,
|
|
129
|
+
* aka. if the final value hasn't changed it will not trigger an unnecessery reactive update.
|
|
130
|
+
*
|
|
131
|
+
* Multiple changes from one or many stores are also automatically batched in a microtask.
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} obj
|
|
134
|
+
* @return {Object} Proxied object.
|
|
135
|
+
*/
|
|
136
|
+
export function store(obj) {
|
|
137
|
+
let pathWatcherIds = new Map();
|
|
138
|
+
|
|
139
|
+
return createProxy(obj, pathWatcherIds);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createProxy(obj, pathWatcherIds) {
|
|
143
|
+
// extract props info to identify getters
|
|
144
|
+
let descriptors =
|
|
145
|
+
typeof obj == "object" && !Array.isArray(obj)
|
|
146
|
+
? Object.getOwnPropertyDescriptors(obj)
|
|
147
|
+
: {};
|
|
148
|
+
|
|
149
|
+
let handler = {
|
|
150
|
+
get(obj, prop, target) {
|
|
151
|
+
// getter?
|
|
152
|
+
let getterProp;
|
|
153
|
+
if (descriptors[prop]?.get) {
|
|
154
|
+
// if not invoked inside a watch function, call the original
|
|
155
|
+
// getter to ensure that an up-to-date value is computed
|
|
156
|
+
if (!activeWatcher) {
|
|
157
|
+
return descriptors[prop]?.get?.call(obj);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getterProp = prop;
|
|
161
|
+
|
|
162
|
+
// replace with an internal "@prop" property so that
|
|
163
|
+
// reactive statements can be cached
|
|
164
|
+
prop = "@" + prop;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// directly return symbols and functions (pop, push, etc.)
|
|
168
|
+
if (typeof prop == "symbol" || typeof obj[prop] == "function") {
|
|
169
|
+
return obj[prop];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// wrap child object or array as sub store
|
|
173
|
+
if (
|
|
174
|
+
typeof obj[prop] == "object" &&
|
|
175
|
+
obj[prop] !== null &&
|
|
176
|
+
!obj[prop][parentSym]
|
|
177
|
+
) {
|
|
178
|
+
obj[prop][parentSym] = [obj, prop];
|
|
179
|
+
obj[prop] = createProxy(obj[prop], pathWatcherIds);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// register watch subscriber (if any)
|
|
183
|
+
if (activeWatcher) {
|
|
184
|
+
let currentPath = getPath(obj, prop);
|
|
185
|
+
let activeWatcherId = activeWatcher[idSym];
|
|
186
|
+
|
|
187
|
+
let subs = pathWatcherIds.get(currentPath);
|
|
188
|
+
if (!subs) {
|
|
189
|
+
subs = new Set();
|
|
190
|
+
pathWatcherIds.set(currentPath, subs);
|
|
191
|
+
}
|
|
192
|
+
subs.add(activeWatcherId);
|
|
193
|
+
|
|
194
|
+
activeWatcher[pathsSubsSym] = activeWatcher[pathsSubsSym] || new Set();
|
|
195
|
+
activeWatcher[pathsSubsSym].add(subs);
|
|
196
|
+
|
|
197
|
+
// register a child watcher to update the custom getter prop replacement
|
|
198
|
+
// (should be removed automatically with the removal of the parent watcher)
|
|
199
|
+
if (
|
|
200
|
+
getterProp &&
|
|
201
|
+
!descriptors[getterProp]._watchers?.has(activeWatcherId)
|
|
202
|
+
) {
|
|
203
|
+
descriptors[getterProp]._watchers =
|
|
204
|
+
descriptors[getterProp]._watchers || new Set();
|
|
205
|
+
descriptors[getterProp]._watchers.add(activeWatcherId);
|
|
206
|
+
|
|
207
|
+
let getFunc = descriptors[getterProp].get.bind(obj);
|
|
208
|
+
|
|
209
|
+
let getWatcher = watch(() => (target[prop] = getFunc()));
|
|
210
|
+
|
|
211
|
+
getWatcher[onRemoveSym] = () => {
|
|
212
|
+
descriptors[getterProp]?.watchers?.delete(watcherId);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return obj[prop];
|
|
218
|
+
},
|
|
219
|
+
set(obj, prop, value) {
|
|
220
|
+
if (typeof prop == "symbol") {
|
|
221
|
+
obj[prop] = value;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let oldValue = obj[prop];
|
|
226
|
+
obj[prop] = value;
|
|
227
|
+
|
|
228
|
+
// trigger only on value change
|
|
229
|
+
// (exclude length since the old value would have been already changed on access)
|
|
230
|
+
if (value != oldValue || prop === "length") {
|
|
231
|
+
callWatchers(obj, prop, pathWatcherIds);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return true;
|
|
235
|
+
},
|
|
236
|
+
deleteProperty(obj, prop) {
|
|
237
|
+
if (typeof prop != "symbol") {
|
|
238
|
+
callWatchers(obj, prop, pathWatcherIds);
|
|
239
|
+
|
|
240
|
+
let currentPath = getPath(obj, prop);
|
|
241
|
+
if (pathWatcherIds.has(currentPath)) {
|
|
242
|
+
pathWatcherIds.delete(currentPath);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
delete obj[prop];
|
|
247
|
+
|
|
248
|
+
return true;
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return new Proxy(obj, handler);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getPath(obj, prop) {
|
|
256
|
+
let currentPath = prop;
|
|
257
|
+
|
|
258
|
+
let parentData = obj?.[parentSym];
|
|
259
|
+
while (parentData) {
|
|
260
|
+
currentPath = parentData[1] + "." + currentPath;
|
|
261
|
+
parentData = parentData[0][parentSym];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return currentPath;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function callWatchers(obj, prop, pathWatcherIds) {
|
|
268
|
+
let currentPath = getPath(obj, prop);
|
|
269
|
+
|
|
270
|
+
let watcherIds = pathWatcherIds.get(currentPath);
|
|
271
|
+
|
|
272
|
+
if (!watcherIds) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (let id of watcherIds) {
|
|
277
|
+
flushQueue.add(id);
|
|
278
|
+
|
|
279
|
+
if (flushQueue.size != 1) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
queueMicrotask(() => {
|
|
284
|
+
let watcher;
|
|
285
|
+
for (let runId of flushQueue) {
|
|
286
|
+
watcher = allWatchers.get(runId);
|
|
287
|
+
if (!watcher || watcher[unwatchedSym]) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// if both parent and child watcher exists,
|
|
292
|
+
// execute only the parent because the child
|
|
293
|
+
// watchers will be invoked automatically
|
|
294
|
+
if (watcher[parentSym] && flushQueue.has(watcher[parentSym])) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
watcher.run();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
flushQueue.clear();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { watch } from "./state.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proxy object for creating and returning a new HTML element in the format `t.[tag](attrs, ...children)`.
|
|
5
|
+
*
|
|
6
|
+
* For example:
|
|
7
|
+
*
|
|
8
|
+
* ```js
|
|
9
|
+
* t.div({ className: "test-div" },
|
|
10
|
+
* t.span({ textContent: "child1"}),
|
|
11
|
+
* t.span({ textContent: "child2"}),
|
|
12
|
+
* )
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `attrs` is an object where the keys are:
|
|
16
|
+
* - valid element's [JS property](https://developer.mozilla.org/en-US/docs/Web/API/Element#instance_properties)
|
|
17
|
+
* _(note that some HTML attribute names are different from their JS property equivalent, e.g. `class` vs `className`, `for` vs `htmlFor`, etc.)_
|
|
18
|
+
* - regular or custom HTML attribute if it has `html-` prefix _(it is stripped from the final attribute)_, e.g. `html-data-name`
|
|
19
|
+
*
|
|
20
|
+
* The attributes value could be a plain JS value or reactive function that returns such value _(e.g. `() => data.count`)_.
|
|
21
|
+
*
|
|
22
|
+
* `children` is an optional list of child elements that could be:
|
|
23
|
+
* - plain text (inserted as `TextNode`)
|
|
24
|
+
* - single tag
|
|
25
|
+
* - array of tags
|
|
26
|
+
* - reactive function that returns any of the above
|
|
27
|
+
*
|
|
28
|
+
* Each constructed tag has 3 additional optional lifecycle attributes:
|
|
29
|
+
* - `onmount: func` - optional callback called when the element is inserted in the DOM
|
|
30
|
+
* - `onunmount: func` - optional callback called when the element is removed from the DOM
|
|
31
|
+
* - `rid: any` - "replacement id" is an identifier based on which we can decide whether to reuse the element or not during rerendering (e.g. on list change); the value could be anything comparable with `==`
|
|
32
|
+
*
|
|
33
|
+
* @param {string} tagName
|
|
34
|
+
* @param {Object} attrs
|
|
35
|
+
* @param {...Node} children
|
|
36
|
+
* @return {HTMLElement}
|
|
37
|
+
*/
|
|
38
|
+
export const t = new Proxy(
|
|
39
|
+
{},
|
|
40
|
+
{
|
|
41
|
+
get(_, prop) {
|
|
42
|
+
return function () {
|
|
43
|
+
initMutationObserver();
|
|
44
|
+
|
|
45
|
+
return tag.call(undefined, prop, ...arguments);
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// -------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
let isMutationObserverInited = false;
|
|
54
|
+
function initMutationObserver() {
|
|
55
|
+
if (isMutationObserverInited) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isMutationObserverInited = true;
|
|
60
|
+
|
|
61
|
+
function recursiveObserveCall(method, nodes) {
|
|
62
|
+
for (let n of nodes) {
|
|
63
|
+
if (n[method]) {
|
|
64
|
+
n[method]();
|
|
65
|
+
}
|
|
66
|
+
if (n.childNodes) {
|
|
67
|
+
recursiveObserveCall(method, n.childNodes);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const observer = new MutationObserver((mutations) => {
|
|
73
|
+
for (let m of mutations) {
|
|
74
|
+
recursiveObserveCall("onmount", m.addedNodes);
|
|
75
|
+
recursiveObserveCall("onunmount", m.removedNodes);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
observer.observe(document, { childList: true, subtree: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let watchFuncsSym = Symbol();
|
|
83
|
+
let registeredWatchersSym = Symbol();
|
|
84
|
+
let isMountedSym = Symbol();
|
|
85
|
+
let cleanupFuncsSym = Symbol();
|
|
86
|
+
|
|
87
|
+
function tag(tagName, attrs = {}, ...children) {
|
|
88
|
+
let el = document.createElement(tagName);
|
|
89
|
+
|
|
90
|
+
if (attrs) {
|
|
91
|
+
for (let attr in attrs) {
|
|
92
|
+
let val = attrs[attr];
|
|
93
|
+
let useSetAttr = false;
|
|
94
|
+
|
|
95
|
+
if (attr.length > 5 && attr.startsWith("html-")) {
|
|
96
|
+
useSetAttr = true;
|
|
97
|
+
attr = attr.substring(5);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
// JS property or regular HTML attribute
|
|
102
|
+
typeof val != "function" ||
|
|
103
|
+
// event
|
|
104
|
+
(attr.length > 2 && attr.startsWith("on"))
|
|
105
|
+
) {
|
|
106
|
+
if (useSetAttr) {
|
|
107
|
+
el.setAttribute(attr, val);
|
|
108
|
+
} else {
|
|
109
|
+
el[attr] = val;
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
el[watchFuncsSym] = el[watchFuncsSym] || [];
|
|
113
|
+
el[watchFuncsSym].push(() => {
|
|
114
|
+
if (!el) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (useSetAttr) {
|
|
119
|
+
el.setAttribute(attr, val());
|
|
120
|
+
} else {
|
|
121
|
+
el[attr] = val();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let customMount = el.onmount;
|
|
129
|
+
el.onmount = () => {
|
|
130
|
+
if (el[isMountedSym]) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
el[isMountedSym] = true;
|
|
135
|
+
|
|
136
|
+
if (el[watchFuncsSym]) {
|
|
137
|
+
el[registeredWatchersSym] = el[registeredWatchersSym] || [];
|
|
138
|
+
for (let fn of el[watchFuncsSym]) {
|
|
139
|
+
el[registeredWatchersSym].push(watch(fn));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
customMount?.();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let customUnmount = el.onunmount;
|
|
147
|
+
el.onunmount = () => {
|
|
148
|
+
if (!el[isMountedSym]) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
el[isMountedSym] = false;
|
|
153
|
+
|
|
154
|
+
if (el[registeredWatchersSym]) {
|
|
155
|
+
for (let w of el[registeredWatchersSym]) {
|
|
156
|
+
w.unwatch();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
el[registeredWatchersSym] = null;
|
|
160
|
+
|
|
161
|
+
if (el[cleanupFuncsSym]) {
|
|
162
|
+
for (let cleanup of el[cleanupFuncsSym]) {
|
|
163
|
+
cleanup();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
el[cleanupFuncsSym] = null;
|
|
167
|
+
|
|
168
|
+
customUnmount?.();
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
setChildren(el, children);
|
|
172
|
+
|
|
173
|
+
return el;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function setChildren(el, children) {
|
|
177
|
+
children = toArray(children);
|
|
178
|
+
|
|
179
|
+
for (let childOrFunc of children) {
|
|
180
|
+
if (Array.isArray(childOrFunc)) {
|
|
181
|
+
// nested array
|
|
182
|
+
setChildren(el, childOrFunc);
|
|
183
|
+
} else if (typeof childOrFunc == "function") {
|
|
184
|
+
initChildrenFuncWatcher(el, childOrFunc);
|
|
185
|
+
} else {
|
|
186
|
+
// plain elem
|
|
187
|
+
el.appendChild(normalizeNode(childOrFunc));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function initChildrenFuncWatcher(el, childrenFunc) {
|
|
193
|
+
let endPlaceholder = document.createComment("");
|
|
194
|
+
el.appendChild(endPlaceholder);
|
|
195
|
+
|
|
196
|
+
let oldChildren = [];
|
|
197
|
+
let oldKeysMap = new Map();
|
|
198
|
+
|
|
199
|
+
let elMoveBefore = el.moveBefore || el.insertBefore;
|
|
200
|
+
|
|
201
|
+
el[cleanupFuncsSym] = el[cleanupFuncsSym] || [];
|
|
202
|
+
el[cleanupFuncsSym].push(() => {
|
|
203
|
+
oldChildren = null;
|
|
204
|
+
oldKeysMap = null;
|
|
205
|
+
endPlaceholder = null;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
el[watchFuncsSym] = el[watchFuncsSym] || [];
|
|
209
|
+
el[watchFuncsSym].push(() => {
|
|
210
|
+
if (!el) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let newChildren = toArray(childrenFunc());
|
|
215
|
+
let totalNewLength = newChildren.length;
|
|
216
|
+
let newKeysMap = new Map();
|
|
217
|
+
|
|
218
|
+
// no previous children
|
|
219
|
+
if (!oldChildren?.length) {
|
|
220
|
+
let fragment = document.createDocumentFragment();
|
|
221
|
+
for (let i = 0; i < totalNewLength; i++) {
|
|
222
|
+
newChildren[i] = normalizeNode(newChildren[i]);
|
|
223
|
+
|
|
224
|
+
fragment.appendChild(newChildren[i]);
|
|
225
|
+
|
|
226
|
+
let rid = newChildren[i].rid;
|
|
227
|
+
if (typeof rid != "undefined") {
|
|
228
|
+
if (newKeysMap.has(rid)) {
|
|
229
|
+
console.warn("Duplicated rid:", rid, newChildren[i]);
|
|
230
|
+
} else {
|
|
231
|
+
newKeysMap.set(rid, i);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
el.insertBefore(fragment, endPlaceholder);
|
|
236
|
+
fragment = null;
|
|
237
|
+
|
|
238
|
+
oldChildren = newChildren;
|
|
239
|
+
oldKeysMap = newKeysMap;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let toMove = [];
|
|
244
|
+
let toInsert = [];
|
|
245
|
+
let reused = new Set();
|
|
246
|
+
let orderedActiveOldIndexes = [];
|
|
247
|
+
|
|
248
|
+
// identify new items for reuse or insert
|
|
249
|
+
for (let newI = 0; newI < totalNewLength; newI++) {
|
|
250
|
+
newChildren[newI] = normalizeNode(newChildren[newI]);
|
|
251
|
+
|
|
252
|
+
let rid = newChildren[newI].rid;
|
|
253
|
+
if (typeof rid != "undefined") {
|
|
254
|
+
if (newKeysMap.has(rid)) {
|
|
255
|
+
console.warn("Duplicated rid:", rid, newChildren[newI]);
|
|
256
|
+
} else {
|
|
257
|
+
newKeysMap.set(rid, newI);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// reuse
|
|
261
|
+
let oldI = oldKeysMap.get(rid);
|
|
262
|
+
if (oldI >= 0) {
|
|
263
|
+
reused.add(oldChildren[oldI]);
|
|
264
|
+
newChildren[newI] = oldChildren[oldI];
|
|
265
|
+
orderedActiveOldIndexes.push(oldI);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
toInsert.push({
|
|
271
|
+
child: newChildren[newI],
|
|
272
|
+
prev: newChildren[newI - 1],
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// since the "reused" children could be in different order from the original ones,
|
|
277
|
+
// try to find the longest subsequence that is in the correct order
|
|
278
|
+
// so that we can minimize the required DOM move operations,
|
|
279
|
+
// aka. only the elements not found in the resulting subsequence must be reordered
|
|
280
|
+
let okSubsequence = getLongestSubsequence(orderedActiveOldIndexes);
|
|
281
|
+
if (orderedActiveOldIndexes.length != okSubsequence.length) {
|
|
282
|
+
orderedActiveOldIndexes.forEach((idx, i) => {
|
|
283
|
+
if (!okSubsequence.has(idx)) {
|
|
284
|
+
toMove.push({
|
|
285
|
+
child: oldChildren[idx],
|
|
286
|
+
currentPos: idx,
|
|
287
|
+
targetPos: i,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// reorder old children
|
|
294
|
+
for (let m of toMove) {
|
|
295
|
+
let before = oldChildren[m.targetPos];
|
|
296
|
+
arrayMove(oldChildren, m.currentPos, m.targetPos);
|
|
297
|
+
elMoveBefore.call(el, m.child, before);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// insert new children
|
|
301
|
+
for (let ins of toInsert) {
|
|
302
|
+
if (ins.prev) {
|
|
303
|
+
ins.prev.after(ins.child);
|
|
304
|
+
} else {
|
|
305
|
+
(oldChildren[0] || endPlaceholder).before(ins.child);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// remove missing old children
|
|
310
|
+
for (let i = 0; i < oldChildren.length; i++) {
|
|
311
|
+
if (!reused.has(oldChildren[i])) {
|
|
312
|
+
oldChildren[i].remove?.();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
oldChildren = newChildren;
|
|
317
|
+
oldKeysMap = newKeysMap;
|
|
318
|
+
|
|
319
|
+
// clear to make sure no lingering references remain
|
|
320
|
+
newChildren = null;
|
|
321
|
+
newKeysMap = null;
|
|
322
|
+
reused = null;
|
|
323
|
+
toMove = null;
|
|
324
|
+
toInsert = null;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Returns the elements of the Longest Increasing Subsequence (LIS) for an array of indexes.
|
|
329
|
+
//
|
|
330
|
+
// Note that the returned sequence is in reverse order but for our case
|
|
331
|
+
// it doesn't matter because we are interested only in the elements.
|
|
332
|
+
//
|
|
333
|
+
// For more details and visual representation of the the algorithm, please check:
|
|
334
|
+
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence#Efficient_algorithms
|
|
335
|
+
function getLongestSubsequence(arr) {
|
|
336
|
+
let ends = [];
|
|
337
|
+
let predecessors = [];
|
|
338
|
+
|
|
339
|
+
for (let i = 0; i < arr.length; i++) {
|
|
340
|
+
let current = arr[i];
|
|
341
|
+
|
|
342
|
+
let low = 0;
|
|
343
|
+
let mid = 0;
|
|
344
|
+
let high = ends.length;
|
|
345
|
+
while (low < high) {
|
|
346
|
+
mid = Math.floor((low + high) / 2);
|
|
347
|
+
if (arr[ends[mid]] >= current) {
|
|
348
|
+
high = mid;
|
|
349
|
+
} else {
|
|
350
|
+
low = mid + 1;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (low > 0) {
|
|
355
|
+
predecessors[i] = ends[low - 1];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
ends[low] = i;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let result = new Set();
|
|
362
|
+
|
|
363
|
+
// reconstruct the LIS elements via backtracking
|
|
364
|
+
let lastIdx = ends[ends.length - 1];
|
|
365
|
+
while (typeof lastIdx != "undefined") {
|
|
366
|
+
result.add(arr[lastIdx]);
|
|
367
|
+
lastIdx = predecessors[lastIdx];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function arrayMove(arr, from, to) {
|
|
374
|
+
if (from == to) {
|
|
375
|
+
return arr;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let dir = from > to ? -1 : 1;
|
|
379
|
+
let target = arr[from];
|
|
380
|
+
|
|
381
|
+
for (let i = from; i != to; i += dir) {
|
|
382
|
+
arr[i] = arr[i + dir];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
arr[to] = target;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function toArray(val) {
|
|
389
|
+
if (typeof val == "undefined" || val === null) {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return Array.isArray(val) ? val : [val];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeNode(child) {
|
|
397
|
+
// wrap as TextNode so that it can be "tracked" and used with appendChild or other similar methods
|
|
398
|
+
if (typeof child == "string" || typeof child == "number") {
|
|
399
|
+
let childNode = document.createTextNode(child);
|
|
400
|
+
childNode.rid = child;
|
|
401
|
+
return childNode;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return child;
|
|
405
|
+
}
|