rasti 3.0.0 → 4.0.0-alpha.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/README.md +45 -14
- package/dist/rasti.js +1854 -679
- package/dist/rasti.min.js +1 -1
- package/es/Component.js +770 -479
- package/es/Emitter.js +182 -28
- package/es/Model.js +237 -51
- package/es/View.js +95 -53
- package/es/core/Element.js +55 -0
- package/es/core/EventsManager.js +41 -0
- package/es/core/Interpolation.js +70 -0
- package/es/core/InterpolationWrapper.js +14 -0
- package/es/core/Partial.js +12 -0
- package/es/core/PathManager.js +88 -0
- package/es/core/SafeHTML.js +17 -0
- package/es/index.js +13 -0
- package/es/utils/deepFlat.js +4 -2
- package/es/utils/findComment.js +42 -0
- package/es/utils/getAttributesDiff.js +33 -0
- package/es/utils/getAttributesHTML.js +25 -0
- package/es/utils/getResult.js +4 -2
- package/es/utils/parseHTML.js +14 -0
- package/es/utils/syncNode.js +109 -0
- package/es/utils/validateListener.js +14 -0
- package/lib/Component.cjs +771 -480
- package/lib/Emitter.cjs +182 -28
- package/lib/Model.cjs +237 -51
- package/lib/View.cjs +95 -53
- package/lib/core/Element.cjs +57 -0
- package/lib/core/EventsManager.cjs +43 -0
- package/lib/core/Interpolation.cjs +72 -0
- package/lib/core/InterpolationWrapper.cjs +16 -0
- package/lib/core/Partial.cjs +14 -0
- package/lib/core/PathManager.cjs +90 -0
- package/lib/core/SafeHTML.cjs +19 -0
- package/lib/index.cjs +13 -0
- package/lib/utils/deepFlat.cjs +4 -2
- package/lib/utils/findComment.cjs +44 -0
- package/lib/utils/getAttributesDiff.cjs +35 -0
- package/lib/utils/getAttributesHTML.cjs +27 -0
- package/lib/utils/getResult.cjs +4 -2
- package/lib/utils/parseHTML.cjs +16 -0
- package/lib/utils/syncNode.cjs +111 -0
- package/lib/utils/validateListener.cjs +16 -0
- package/package.json +11 -8
- package/src/Component.js +767 -478
- package/src/Emitter.js +182 -28
- package/src/Model.js +236 -51
- package/src/View.js +95 -53
- package/src/core/Element.js +55 -0
- package/src/core/EventsManager.js +41 -0
- package/src/core/Interpolation.js +70 -0
- package/src/core/InterpolationWrapper.js +14 -0
- package/src/core/Partial.js +12 -0
- package/src/core/PathManager.js +88 -0
- package/src/core/SafeHTML.js +17 -0
- package/src/index.js +4 -5
- package/src/utils/deepFlat.js +4 -2
- package/src/utils/findComment.js +40 -0
- package/src/utils/getAttributesDiff.js +31 -0
- package/src/utils/getAttributesHTML.js +23 -0
- package/src/utils/getResult.js +6 -2
- package/src/utils/parseHTML.js +12 -0
- package/src/utils/syncNode.js +107 -0
- package/src/utils/validateListener.js +12 -0
package/es/View.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import Emitter from './Emitter.js';
|
|
2
2
|
import getResult from './utils/getResult.js';
|
|
3
|
+
import validateListener from './utils/validateListener.js';
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
* These option keys will be extended on the view instance.
|
|
6
7
|
*/
|
|
7
|
-
const viewOptions =
|
|
8
|
-
el : true,
|
|
9
|
-
tag : true,
|
|
10
|
-
attributes : true,
|
|
11
|
-
events : true,
|
|
12
|
-
model : true,
|
|
13
|
-
template : true,
|
|
14
|
-
onDestroy : true
|
|
15
|
-
};
|
|
8
|
+
const viewOptions = ['el', 'tag', 'attributes', 'events', 'model', 'template', 'onDestroy'];
|
|
16
9
|
|
|
17
10
|
/**
|
|
18
11
|
* - Listens for changes and renders the UI.
|
|
@@ -29,14 +22,15 @@ const viewOptions = {
|
|
|
29
22
|
* @module
|
|
30
23
|
* @extends Emitter
|
|
31
24
|
* @param {object} options Object containing options. The following keys will be merged into the view instance: `el`, `tag`, `attributes`, `events`, `model`, `template`, `onDestroy`.
|
|
32
|
-
* @property {node|
|
|
33
|
-
* @property {string|
|
|
34
|
-
* @property {object|
|
|
35
|
-
* @property {object|
|
|
25
|
+
* @property {node|Function} el Every view has a root DOM element stored at `this.el`. If not present, it will be created. If `this.el` is a function, it will be called to get the element at `this.ensureElement`, bound to the view instance. See {@link module_view__ensureelement View.ensureElement}.
|
|
26
|
+
* @property {string|Function} tag If `this.el` is not present, an element will be created using `this.tag` and `this.attributes`. Default is `div`. If it is a function, it will be called to get the tag, bound to the view instance. See {@link module_view__ensureelement View.ensureElement}.
|
|
27
|
+
* @property {object|Function} attributes If `this.el` is not present, an element will be created using `this.tag` and `this.attributes`. If it is a function, it will be called to get the attributes object, bound to the view instance. See {@link module_view__ensureelement View.ensureElement}.
|
|
28
|
+
* @property {object|Function} events Object in the format `{'event selector' : 'listener'}`. It will be used to bind delegated event listeners to the root element. If it is a function, it will be called to get the events object, bound to the view instance. See {@link module_view_delegateevents View.delegateEvents}.
|
|
36
29
|
* @property {object} model A model or any object containing data and business logic.
|
|
37
|
-
* @property {
|
|
30
|
+
* @property {Function} template A function that returns a string with the view's inner HTML. See {@link module_view__render View.render}.
|
|
31
|
+
* @property {number} uid Unique identifier for the view instance. This can be used to generate unique IDs for elements within the view. It is automatically generated and should not be set manually.
|
|
38
32
|
* @example
|
|
39
|
-
* import { View } from 'rasti';
|
|
33
|
+
* import { View, Model } from 'rasti';
|
|
40
34
|
*
|
|
41
35
|
* class Timer extends View {
|
|
42
36
|
* constructor(options) {
|
|
@@ -61,9 +55,6 @@ class View extends Emitter {
|
|
|
61
55
|
super();
|
|
62
56
|
// Call preinitialize.
|
|
63
57
|
this.preinitialize.apply(this, arguments);
|
|
64
|
-
// Generate unique id.
|
|
65
|
-
// Useful to generate element ids.
|
|
66
|
-
this.uid = `uid${++View.uid}`;
|
|
67
58
|
// Store delegated event listeners,
|
|
68
59
|
// so they can be unbound later.
|
|
69
60
|
this.delegatedEventListeners = [];
|
|
@@ -72,17 +63,23 @@ class View extends Emitter {
|
|
|
72
63
|
this.children = [];
|
|
73
64
|
// Mutable array to store handlers to be called on destroy.
|
|
74
65
|
this.destroyQueue = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
66
|
+
this.viewOptions = [];
|
|
67
|
+
// Extend "this" with options.
|
|
68
|
+
viewOptions.forEach(key => {
|
|
69
|
+
if (key in options) {
|
|
70
|
+
this[key] = options[key];
|
|
71
|
+
this.viewOptions.push(key);
|
|
72
|
+
}
|
|
78
73
|
});
|
|
74
|
+
// Ensure that the view has a unique id at `this.uid`.
|
|
75
|
+
this.ensureUid();
|
|
79
76
|
// Ensure that the view has a root element at `this.el`.
|
|
80
77
|
this.ensureElement();
|
|
81
78
|
}
|
|
82
79
|
|
|
83
80
|
/**
|
|
84
81
|
* If you define a preinitialize method, it will be invoked when the view is first created, before any instantiation logic is run.
|
|
85
|
-
* @param {object}
|
|
82
|
+
* @param {object} options The view options.
|
|
86
83
|
*/
|
|
87
84
|
preinitialize() {}
|
|
88
85
|
|
|
@@ -117,6 +114,8 @@ class View extends Emitter {
|
|
|
117
114
|
this.destroyChildren();
|
|
118
115
|
// Undelegate `this.el` event listeners
|
|
119
116
|
this.undelegateEvents();
|
|
117
|
+
// Stop listening to events.
|
|
118
|
+
this.stopListening();
|
|
120
119
|
// Unbind `this` events.
|
|
121
120
|
this.off();
|
|
122
121
|
// Call destroy queue.
|
|
@@ -124,6 +123,8 @@ class View extends Emitter {
|
|
|
124
123
|
this.destroyQueue = [];
|
|
125
124
|
// Call onDestroy lifecycle method
|
|
126
125
|
this.onDestroy.apply(this, arguments);
|
|
126
|
+
// Set destroyed flag.
|
|
127
|
+
this.destroyed = true;
|
|
127
128
|
// Return `this` for chaining.
|
|
128
129
|
return this;
|
|
129
130
|
}
|
|
@@ -155,6 +156,13 @@ class View extends Emitter {
|
|
|
155
156
|
this.children = [];
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Ensure that the view has a unique id at `this.uid`.
|
|
161
|
+
*/
|
|
162
|
+
ensureUid() {
|
|
163
|
+
if (!this.uid) this.uid = `r-${++View.uid}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
158
166
|
/**
|
|
159
167
|
* Ensure that the view has a root element at `this.el`.
|
|
160
168
|
* You shouldn't call this method directly. It's called from the constructor.
|
|
@@ -221,26 +229,43 @@ class View extends Emitter {
|
|
|
221
229
|
* All attached listeners are bound to the view, ensuring that `this` refers to the view object when the listeners are invoked.
|
|
222
230
|
* When `delegateEvents` is called again, possibly with a different events object, all previous listeners are removed and delegated afresh.
|
|
223
231
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
232
|
+
* **Listener signature:** `(event, view, matched)`
|
|
233
|
+
* - `event`: The native DOM event object.
|
|
234
|
+
* - `view`: The current view instance (`this`).
|
|
235
|
+
* - `matched`: The element that satisfies the selector. If no selector is provided, it will be the view's root element (`this.el`).
|
|
236
|
+
*
|
|
237
|
+
* If more than one ancestor between `event.target` and the view's root element matches the selector, the listener will be
|
|
238
|
+
* invoked **once for each matched element** (from inner to outer).
|
|
239
|
+
*
|
|
226
240
|
* @param {object} [events] Object in the format `{'event selector' : 'listener'}`. Used to bind delegated event listeners to the root element.
|
|
227
241
|
* @return {Rasti.View} Returns `this` for chaining.
|
|
228
242
|
* @example
|
|
229
|
-
* // Using
|
|
243
|
+
* // Using prototype (recommended for static events)
|
|
230
244
|
* class Modal extends View {
|
|
245
|
+
* onClickOk(event, view, matched) {
|
|
246
|
+
* // matched === the button.ok element that was clicked
|
|
247
|
+
* this.close();
|
|
248
|
+
* }
|
|
249
|
+
*
|
|
250
|
+
* onClickCancel() {
|
|
251
|
+
* this.destroy();
|
|
252
|
+
* }
|
|
253
|
+
* }
|
|
254
|
+
* Modal.prototype.events = {
|
|
255
|
+
* 'click button.ok': 'onClickOk',
|
|
256
|
+
* 'click button.cancel': 'onClickCancel',
|
|
257
|
+
* 'submit form': 'onSubmit'
|
|
258
|
+
* };
|
|
259
|
+
*
|
|
260
|
+
* // Using a function for dynamic events
|
|
261
|
+
* class DynamicView extends View {
|
|
231
262
|
* events() {
|
|
232
263
|
* return {
|
|
233
|
-
*
|
|
234
|
-
* 'click
|
|
264
|
+
* [`click .${this.model.buttonClass}`]: 'onButtonClick',
|
|
265
|
+
* 'click': 'onRootClick'
|
|
235
266
|
* };
|
|
236
267
|
* }
|
|
237
268
|
* }
|
|
238
|
-
*
|
|
239
|
-
* // Using an object.
|
|
240
|
-
* Modal.prototype.events = {
|
|
241
|
-
* 'click button.ok' : 'onClickOkButton',
|
|
242
|
-
* 'click button.cancel' : function() {}
|
|
243
|
-
* };
|
|
244
269
|
*/
|
|
245
270
|
delegateEvents(events) {
|
|
246
271
|
if (!events) events = getResult(this.events, this);
|
|
@@ -257,13 +282,10 @@ class View extends Emitter {
|
|
|
257
282
|
const selector = keyParts.join(' ');
|
|
258
283
|
|
|
259
284
|
let listener = events[key];
|
|
260
|
-
// Listener may be a string representing a method name on the view,
|
|
261
|
-
|
|
262
|
-
listener
|
|
263
|
-
|
|
264
|
-
this[listener] :
|
|
265
|
-
listener
|
|
266
|
-
).bind(this);
|
|
285
|
+
// Listener may be a string representing a method name on the view, or a function.
|
|
286
|
+
if (typeof listener === 'string') listener = this[listener];
|
|
287
|
+
// Validate listener is a function.
|
|
288
|
+
validateListener(listener);
|
|
267
289
|
|
|
268
290
|
if (!eventTypes[type]) eventTypes[type] = [];
|
|
269
291
|
|
|
@@ -275,7 +297,20 @@ class View extends Emitter {
|
|
|
275
297
|
const typeListener = (event) => {
|
|
276
298
|
// Iterate and run every individual listener if the selector matches.
|
|
277
299
|
eventTypes[type].forEach(({ selector, listener }) => {
|
|
278
|
-
|
|
300
|
+
// No selector provided: invoke listener once with root element.
|
|
301
|
+
if (!selector) {
|
|
302
|
+
listener.call(this, event, this, this.el);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let node = event.target;
|
|
307
|
+
// Traverse ancestors until reaching the view root (`this.el`).
|
|
308
|
+
while (node && node !== this.el) {
|
|
309
|
+
if (node.matches && node.matches(selector)) {
|
|
310
|
+
listener.call(this, event, this, node);
|
|
311
|
+
}
|
|
312
|
+
node = node.parentElement;
|
|
313
|
+
}
|
|
279
314
|
});
|
|
280
315
|
};
|
|
281
316
|
|
|
@@ -303,17 +338,16 @@ class View extends Emitter {
|
|
|
303
338
|
}
|
|
304
339
|
|
|
305
340
|
/**
|
|
306
|
-
* Renders the view.
|
|
341
|
+
* Renders the view.
|
|
307
342
|
* This method should be overridden with custom logic.
|
|
308
343
|
* The only convention is to manipulate the DOM within the scope of `this.el`,
|
|
309
|
-
* and to return `this` for chaining.
|
|
310
|
-
* If you add any child views, you should call `this.destroyChildren` before re-rendering.
|
|
311
|
-
* The default implementation
|
|
312
|
-
* of calling `this.template`, passing `this.model` as
|
|
313
|
-
* <br><br> ⚠ **Security Notice:** The default implementation utilizes `innerHTML
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* static method to escape HTML entities in a string.
|
|
344
|
+
* and to return `this` for chaining.
|
|
345
|
+
* If you add any child views, you should call `this.destroyChildren` before re-rendering.
|
|
346
|
+
* The default implementation updates `this.el`'s innerHTML with the result
|
|
347
|
+
* of calling `this.template`, passing `this.model` as the argument.
|
|
348
|
+
* <br><br> ⚠ **Security Notice:** The default implementation utilizes `innerHTML`, which may introduce Cross-Site Scripting (XSS) risks.
|
|
349
|
+
* Ensure that any user-generated content is properly sanitized before inserting it into the DOM.
|
|
350
|
+
* You can use the {@link #module_view_sanitize View.sanitize} static method to escape HTML entities in a string.
|
|
317
351
|
* For best practices on secure data handling, refer to the
|
|
318
352
|
* [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
|
|
319
353
|
* @return {Rasti.View} Returns `this` for chaining.
|
|
@@ -330,7 +364,7 @@ class View extends Emitter {
|
|
|
330
364
|
* Override this method to provide a custom escape function.
|
|
331
365
|
* This method is inherited by {@link #module_component Component} and used to escape template interpolations.
|
|
332
366
|
* @static
|
|
333
|
-
* @param {string}
|
|
367
|
+
* @param {string} value String to escape.
|
|
334
368
|
* @return {string} Escaped string.
|
|
335
369
|
*/
|
|
336
370
|
static sanitize(value) {
|
|
@@ -344,8 +378,16 @@ class View extends Emitter {
|
|
|
344
378
|
}
|
|
345
379
|
}
|
|
346
380
|
|
|
347
|
-
|
|
348
|
-
*
|
|
381
|
+
/**
|
|
382
|
+
* Counter for generating unique IDs for view instances.
|
|
383
|
+
* This is primarily used to assign unique identifiers to each view instance (`this.uid`), which can be helpful for tasks like
|
|
384
|
+
* generating element IDs.
|
|
385
|
+
* {@link #module_component Component}s use `this.uid` to generate data attributes for their elements, to be looked up on hydration.
|
|
386
|
+
* For server-side rendering, this counter should be reset to `0` on every request to ensure that the generated
|
|
387
|
+
* unique IDs match those on the client, enabling seamless hydration of components.
|
|
388
|
+
* @static
|
|
389
|
+
* @type {number}
|
|
390
|
+
* @default 0
|
|
349
391
|
*/
|
|
350
392
|
View.uid = 0;
|
|
351
393
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import getAttributesDiff from '../utils/getAttributesDiff.js';
|
|
2
|
+
|
|
3
|
+
const SYNC_PROPS = ['value', 'checked', 'selected'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Element reference for managing DOM element attributes.
|
|
7
|
+
* @param {Object} options The options object.
|
|
8
|
+
* @param {Function} options.getSelector Function that returns the CSS selector for the element.
|
|
9
|
+
* @param {Function} options.getAttributes Function that returns the attributes object for the element.
|
|
10
|
+
* @private
|
|
11
|
+
*/
|
|
12
|
+
class Element {
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.getSelector = options.getSelector;
|
|
15
|
+
this.getAttributes = options.getAttributes;
|
|
16
|
+
this.previousAttributes = {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Attach the element reference to a DOM element.
|
|
21
|
+
* @param {Node} parent The parent node to search in.
|
|
22
|
+
*/
|
|
23
|
+
hydrate(parent) {
|
|
24
|
+
this.ref = parent.querySelector(this.getSelector());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Update the element's attributes based on the difference with previous attributes.
|
|
29
|
+
*/
|
|
30
|
+
update() {
|
|
31
|
+
// Attributes diff.
|
|
32
|
+
const attributes = this.getAttributes();
|
|
33
|
+
const { remove, add } = getAttributesDiff(attributes, this.previousAttributes);
|
|
34
|
+
// Store previous attributes.
|
|
35
|
+
this.previousAttributes = attributes;
|
|
36
|
+
// Remove attributes first so later `setAttribute` overrides if needed.
|
|
37
|
+
remove.forEach(attr => {
|
|
38
|
+
this.ref.removeAttribute(attr);
|
|
39
|
+
if (SYNC_PROPS.includes(attr) && attr in this.ref) {
|
|
40
|
+
// Reset property to default.
|
|
41
|
+
this.ref[attr] = attr === 'value' ? '' : false;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
// Add / update attributes.
|
|
45
|
+
Object.keys(add).forEach(attr => {
|
|
46
|
+
const value = add[attr];
|
|
47
|
+
this.ref.setAttribute(attr, value);
|
|
48
|
+
if (SYNC_PROPS.includes(attr) && attr in this.ref) {
|
|
49
|
+
this.ref[attr] = attr === 'value' ? value : value !== false && value !== 'false';
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { Element as default };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manager for delegated events.
|
|
3
|
+
* @private
|
|
4
|
+
*/
|
|
5
|
+
class EventsManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.listeners = [];
|
|
8
|
+
this.types = new Set();
|
|
9
|
+
this.previousSize = 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Add a listener to the events manager.
|
|
14
|
+
* @param {Function} listener The listener to add.
|
|
15
|
+
* @param {string} type The type of event.
|
|
16
|
+
* @return {number} The index of the listener.
|
|
17
|
+
*/
|
|
18
|
+
addListener(listener, type) {
|
|
19
|
+
this.types.add(type);
|
|
20
|
+
this.listeners.push(listener);
|
|
21
|
+
return this.listeners.length - 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reset the events manager.
|
|
26
|
+
*/
|
|
27
|
+
reset() {
|
|
28
|
+
this.listeners = [];
|
|
29
|
+
this.previousSize = this.types.size;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if there are pending types.
|
|
34
|
+
* @return {boolean} True if there are pending types, false otherwise.
|
|
35
|
+
*/
|
|
36
|
+
hasPendingTypes() {
|
|
37
|
+
return this.types.size > this.previousSize;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { EventsManager as default };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import syncNode from '../utils/syncNode.js';
|
|
2
|
+
import findComment from '../utils/findComment.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interpolation reference for managing dynamic content between comment markers.
|
|
6
|
+
* Handles the lifecycle of content that can change between renders, including
|
|
7
|
+
* component recycling and DOM synchronization.
|
|
8
|
+
* @param {Object} options The options object.
|
|
9
|
+
* @param {Function} options.getStart Function that returns the start comment marker text.
|
|
10
|
+
* @param {Function} options.getEnd Function that returns the end comment marker text.
|
|
11
|
+
* @param {any} options.expression The expression to be evaluated for the interpolation.
|
|
12
|
+
* @param {Function} options.isComponent Function that checks if an element is a component root element.
|
|
13
|
+
* @param {Function} options.isElement Function that checks if an element has the Rasti data attribute.
|
|
14
|
+
* @private
|
|
15
|
+
*/
|
|
16
|
+
class Interpolation {
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.getStart = options.getStart;
|
|
19
|
+
this.getEnd = options.getEnd;
|
|
20
|
+
this.expression = options.expression;
|
|
21
|
+
this.isComponent = options.isComponent;
|
|
22
|
+
this.isElement = options.isElement;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Attach the interpolation reference to comment markers in the DOM.
|
|
27
|
+
* Searches for start and end comment markers, skipping component subtrees.
|
|
28
|
+
* @param {Node} parent The parent node to search in.
|
|
29
|
+
*/
|
|
30
|
+
hydrate(parent) {
|
|
31
|
+
const startComment = findComment(parent, this.getStart(), this.isComponent);
|
|
32
|
+
const endComment = findComment(parent, this.getEnd(), this.isComponent, startComment);
|
|
33
|
+
|
|
34
|
+
this.ref = [
|
|
35
|
+
startComment,
|
|
36
|
+
endComment
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Update the interpolation content with a new fragment.
|
|
42
|
+
* Optimizes updates by syncing single non-component elements or replacing content entirely.
|
|
43
|
+
* @param {DocumentFragment} fragment The new content fragment to insert.
|
|
44
|
+
*/
|
|
45
|
+
update(fragment) {
|
|
46
|
+
const [startComment, endComment] = this.ref;
|
|
47
|
+
|
|
48
|
+
const currentFirstElement = startComment.nextSibling;
|
|
49
|
+
const currentSingleChildElement = currentFirstElement.nextSibling === endComment;
|
|
50
|
+
const currentEmpty = currentFirstElement == endComment;
|
|
51
|
+
const fragmentChildren = fragment.children;
|
|
52
|
+
|
|
53
|
+
if (currentSingleChildElement && fragmentChildren.length === 1 && !this.isElement(currentFirstElement)) {
|
|
54
|
+
// There is a single child element that is not a component's root element. Sync node attributes and content.
|
|
55
|
+
syncNode(currentFirstElement, fragmentChildren[0]);
|
|
56
|
+
} else if (currentEmpty) {
|
|
57
|
+
// Interpolation is empty. Insert the fragment.
|
|
58
|
+
endComment.parentNode.insertBefore(fragment, endComment);
|
|
59
|
+
} else {
|
|
60
|
+
// Interpolation is not empty. Replace the interpolation content.
|
|
61
|
+
const range = document.createRange();
|
|
62
|
+
range.setStartAfter(startComment);
|
|
63
|
+
range.setEndBefore(endComment);
|
|
64
|
+
range.deleteContents();
|
|
65
|
+
range.insertNode(fragment);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { Interpolation as default };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper for interpolation results with markers.
|
|
3
|
+
* @param {number} interpolationUid The interpolation UID for markers.
|
|
4
|
+
* @param {any} result The interpolation result.
|
|
5
|
+
* @private
|
|
6
|
+
*/
|
|
7
|
+
class InterpolationWrapper {
|
|
8
|
+
constructor(interpolationUid, result) {
|
|
9
|
+
this.interpolationUid = interpolationUid;
|
|
10
|
+
this.result = result;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { InterpolationWrapper as default };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manager for component position tracking and recycling.
|
|
3
|
+
* @private
|
|
4
|
+
*/
|
|
5
|
+
class PathManager {
|
|
6
|
+
constructor() {}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reset before render.
|
|
10
|
+
*/
|
|
11
|
+
reset() {
|
|
12
|
+
this.paused = 0;
|
|
13
|
+
this.previous = this.tracked || new Map();
|
|
14
|
+
this.tracked = new Map();
|
|
15
|
+
this.positionStack = [0];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Push position to stack.
|
|
20
|
+
*/
|
|
21
|
+
push() {
|
|
22
|
+
this.positionStack.push(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pop position from stack.
|
|
27
|
+
*/
|
|
28
|
+
pop() {
|
|
29
|
+
this.positionStack.pop();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Increment position.
|
|
34
|
+
*/
|
|
35
|
+
increment() {
|
|
36
|
+
this.positionStack[this.positionStack.length - 1]++;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pause tracking.
|
|
41
|
+
*/
|
|
42
|
+
pause() {
|
|
43
|
+
this.paused++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resume tracking.
|
|
48
|
+
*/
|
|
49
|
+
resume() {
|
|
50
|
+
this.paused--;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get current path as array.
|
|
55
|
+
* @return {string} Current position path.
|
|
56
|
+
*/
|
|
57
|
+
getPath() {
|
|
58
|
+
return this.positionStack.join('-');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Track component at current path.
|
|
63
|
+
* @param {Component} component The component to track.
|
|
64
|
+
* @return {Component} The component.
|
|
65
|
+
*/
|
|
66
|
+
track(component) {
|
|
67
|
+
if (this.paused === 0) {
|
|
68
|
+
this.tracked.set(
|
|
69
|
+
this.getPath(),
|
|
70
|
+
component
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return component;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Find recyclable component by path and type.
|
|
79
|
+
* @param {Function} constructor The component constructor.
|
|
80
|
+
* @return {Component|null} The recyclable component or null.
|
|
81
|
+
*/
|
|
82
|
+
findRecyclable(constructor) {
|
|
83
|
+
const prev = this.previous.get(this.getPath());
|
|
84
|
+
return prev && prev.constructor === constructor && !prev.key ? prev : null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { PathManager as default };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper class for HTML strings marked as safe.
|
|
3
|
+
* @param {string} value The HTML string to be marked as safe.
|
|
4
|
+
* @property {string} value The HTML string.
|
|
5
|
+
* @private
|
|
6
|
+
*/
|
|
7
|
+
class SafeHTML {
|
|
8
|
+
constructor(value) {
|
|
9
|
+
this.value = value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
toString() {
|
|
13
|
+
return this.value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { SafeHTML as default };
|
package/es/index.js
CHANGED
|
@@ -2,5 +2,18 @@ export { default as Emitter } from './Emitter.js';
|
|
|
2
2
|
export { default as Model } from './Model.js';
|
|
3
3
|
export { default as View } from './View.js';
|
|
4
4
|
export { default as Component } from './Component.js';
|
|
5
|
+
import './utils/validateListener.js';
|
|
5
6
|
import './utils/getResult.js';
|
|
7
|
+
import './core/SafeHTML.js';
|
|
8
|
+
import './core/Partial.js';
|
|
9
|
+
import './core/InterpolationWrapper.js';
|
|
10
|
+
import './core/EventsManager.js';
|
|
11
|
+
import './core/PathManager.js';
|
|
12
|
+
import './core/Element.js';
|
|
13
|
+
import './utils/getAttributesDiff.js';
|
|
14
|
+
import './core/Interpolation.js';
|
|
15
|
+
import './utils/syncNode.js';
|
|
16
|
+
import './utils/findComment.js';
|
|
6
17
|
import './utils/deepFlat.js';
|
|
18
|
+
import './utils/parseHTML.js';
|
|
19
|
+
import './utils/getAttributesHTML.js';
|
package/es/utils/deepFlat.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
* Flatten an array recursively
|
|
1
|
+
/**
|
|
2
|
+
* Flatten an array recursively.
|
|
3
3
|
* @param {Array} arr Array to flat recursively
|
|
4
4
|
* @return {Array} Flat array
|
|
5
|
+
* @module
|
|
6
|
+
* @private
|
|
5
7
|
*/
|
|
6
8
|
const deepFlat = (arr) => arr.reduce((acc, val) => {
|
|
7
9
|
if (Array.isArray(val)) acc.push(...deepFlat(val));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the first comment node whose text matches exactly the given `text`.
|
|
3
|
+
* Uses manual DOM traversal. Skips entire subtrees if `shouldSkip(element)` returns true.
|
|
4
|
+
* Starts searching from `startNode` if provided, otherwise from `root.firstChild`.
|
|
5
|
+
* @param {Node} root - Root node or fragment that limits the search scope.
|
|
6
|
+
* @param {string} text - Exact comment text to match.
|
|
7
|
+
* @param {Function} [shouldSkip] - Function that receives an element and returns true if its subtree should be skipped.
|
|
8
|
+
* @param {Node} [startNode] - Node to start searching from (defaults to root.firstChild).
|
|
9
|
+
* @return {Comment|null} The first matching comment node, or null if not found.
|
|
10
|
+
* @module
|
|
11
|
+
* @private
|
|
12
|
+
*/
|
|
13
|
+
function findComment(
|
|
14
|
+
root,
|
|
15
|
+
text,
|
|
16
|
+
shouldSkip = () => false,
|
|
17
|
+
startNode
|
|
18
|
+
) {
|
|
19
|
+
let node = startNode || root.firstChild;
|
|
20
|
+
|
|
21
|
+
while (node) {
|
|
22
|
+
// Check if current node is a comment with matching text.
|
|
23
|
+
if (node.nodeType === Node.COMMENT_NODE && node.data.trim() === text) {
|
|
24
|
+
return node;
|
|
25
|
+
}
|
|
26
|
+
// Descend into children if allowed and present.
|
|
27
|
+
if (node.nodeType === Node.ELEMENT_NODE && !shouldSkip(node) && node.firstChild) {
|
|
28
|
+
node = node.firstChild;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Move to next sibling, or climb up until a sibling is found.
|
|
32
|
+
while (node && !node.nextSibling) {
|
|
33
|
+
node = node.parentNode;
|
|
34
|
+
if (!node || node === root) return null;
|
|
35
|
+
}
|
|
36
|
+
if (node) node = node.nextSibling;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { findComment as default };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get difference between current and previous attributes.
|
|
3
|
+
* @param {Object} attributes Current attributes object.
|
|
4
|
+
* @param {Object} previous Previous attributes object.
|
|
5
|
+
* @return {Object} Object with add and remove properties.
|
|
6
|
+
* @module
|
|
7
|
+
* @private
|
|
8
|
+
*/
|
|
9
|
+
function getAttributesDiff(attributes, previous = {}) {
|
|
10
|
+
const add = {};
|
|
11
|
+
const remove = [];
|
|
12
|
+
// Find attributes to add/update.
|
|
13
|
+
Object.keys(attributes).forEach(key => {
|
|
14
|
+
let value = attributes[key];
|
|
15
|
+
|
|
16
|
+
if (value === true) {
|
|
17
|
+
add[key] = '';
|
|
18
|
+
} else if (value !== false) {
|
|
19
|
+
if (value === null || typeof value === 'undefined') value = '';
|
|
20
|
+
add[key] = value;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
// Find attributes to remove.
|
|
24
|
+
Object.keys(previous).forEach(key => {
|
|
25
|
+
if (!(key in attributes) || ((previous[key] !== attributes[key]) && attributes[key] === false)) {
|
|
26
|
+
remove.push(key);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return { add, remove };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { getAttributesDiff as default };
|