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 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
@@ -0,0 +1,3 @@
1
+ export * from "./src/state.js";
2
+ export * from "./src/template.js";
3
+ export * from "./src/router.js";
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
+ }
@@ -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
+ }