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.
Files changed (64) hide show
  1. package/README.md +45 -14
  2. package/dist/rasti.js +1854 -679
  3. package/dist/rasti.min.js +1 -1
  4. package/es/Component.js +770 -479
  5. package/es/Emitter.js +182 -28
  6. package/es/Model.js +237 -51
  7. package/es/View.js +95 -53
  8. package/es/core/Element.js +55 -0
  9. package/es/core/EventsManager.js +41 -0
  10. package/es/core/Interpolation.js +70 -0
  11. package/es/core/InterpolationWrapper.js +14 -0
  12. package/es/core/Partial.js +12 -0
  13. package/es/core/PathManager.js +88 -0
  14. package/es/core/SafeHTML.js +17 -0
  15. package/es/index.js +13 -0
  16. package/es/utils/deepFlat.js +4 -2
  17. package/es/utils/findComment.js +42 -0
  18. package/es/utils/getAttributesDiff.js +33 -0
  19. package/es/utils/getAttributesHTML.js +25 -0
  20. package/es/utils/getResult.js +4 -2
  21. package/es/utils/parseHTML.js +14 -0
  22. package/es/utils/syncNode.js +109 -0
  23. package/es/utils/validateListener.js +14 -0
  24. package/lib/Component.cjs +771 -480
  25. package/lib/Emitter.cjs +182 -28
  26. package/lib/Model.cjs +237 -51
  27. package/lib/View.cjs +95 -53
  28. package/lib/core/Element.cjs +57 -0
  29. package/lib/core/EventsManager.cjs +43 -0
  30. package/lib/core/Interpolation.cjs +72 -0
  31. package/lib/core/InterpolationWrapper.cjs +16 -0
  32. package/lib/core/Partial.cjs +14 -0
  33. package/lib/core/PathManager.cjs +90 -0
  34. package/lib/core/SafeHTML.cjs +19 -0
  35. package/lib/index.cjs +13 -0
  36. package/lib/utils/deepFlat.cjs +4 -2
  37. package/lib/utils/findComment.cjs +44 -0
  38. package/lib/utils/getAttributesDiff.cjs +35 -0
  39. package/lib/utils/getAttributesHTML.cjs +27 -0
  40. package/lib/utils/getResult.cjs +4 -2
  41. package/lib/utils/parseHTML.cjs +16 -0
  42. package/lib/utils/syncNode.cjs +111 -0
  43. package/lib/utils/validateListener.cjs +16 -0
  44. package/package.json +11 -8
  45. package/src/Component.js +767 -478
  46. package/src/Emitter.js +182 -28
  47. package/src/Model.js +236 -51
  48. package/src/View.js +95 -53
  49. package/src/core/Element.js +55 -0
  50. package/src/core/EventsManager.js +41 -0
  51. package/src/core/Interpolation.js +70 -0
  52. package/src/core/InterpolationWrapper.js +14 -0
  53. package/src/core/Partial.js +12 -0
  54. package/src/core/PathManager.js +88 -0
  55. package/src/core/SafeHTML.js +17 -0
  56. package/src/index.js +4 -5
  57. package/src/utils/deepFlat.js +4 -2
  58. package/src/utils/findComment.js +40 -0
  59. package/src/utils/getAttributesDiff.js +31 -0
  60. package/src/utils/getAttributesHTML.js +23 -0
  61. package/src/utils/getResult.js +6 -2
  62. package/src/utils/parseHTML.js +12 -0
  63. package/src/utils/syncNode.js +107 -0
  64. package/src/utils/validateListener.js +12 -0
package/dist/rasti.js CHANGED
@@ -4,12 +4,52 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Rasti = {}));
5
5
  })(this, (function (exports) { 'use strict';
6
6
 
7
+ /**
8
+ * Validates that the listener is a function.
9
+ * @param {Function} listener The listener to validate.
10
+ * @throws {TypeError} If the listener is not a function.
11
+ * @module
12
+ * @private
13
+ */
14
+ function validateListener(listener) {
15
+ if (typeof listener !== 'function') {
16
+ throw new TypeError('Listener must be a function');
17
+ }
18
+ }
19
+
7
20
  /**
8
21
  * `Emitter` is a class that provides an easy way to implement the observer pattern
9
22
  * in your applications.
10
23
  * It can be extended to create new classes that have the ability to emit and bind custom named events.
11
24
  * Emitter is used by `Model` and `View` classes, which inherit from it to implement
12
25
  * event-driven functionality.
26
+ *
27
+ * ## Inverse of Control Pattern
28
+ *
29
+ * The Emitter class includes "inverse of control" methods (`listenTo`, `listenToOnce`, `stopListening`)
30
+ * that allow an object to manage its own listening relationships. Instead of:
31
+ *
32
+ * ```javascript
33
+ * // Traditional approach - harder to clean up
34
+ * otherObject.on('change', this.myHandler);
35
+ * otherObject.on('destroy', this.cleanup);
36
+ * // Later you need to remember to clean up each listener
37
+ * otherObject.off('change', this.myHandler);
38
+ * otherObject.off('destroy', this.cleanup);
39
+ * ```
40
+ *
41
+ * You can use:
42
+ *
43
+ * ```javascript
44
+ * // Inverse of control - easier cleanup
45
+ * this.listenTo(otherObject, 'change', this.myHandler);
46
+ * this.listenTo(otherObject, 'destroy', this.cleanup);
47
+ * // Later, clean up ALL listeners at once
48
+ * this.stopListening(); // Removes all listening relationships
49
+ * ```
50
+ *
51
+ * This pattern is particularly useful for preventing memory leaks and simplifying cleanup
52
+ * in component lifecycle management.
13
53
  *
14
54
  * @module
15
55
  * @example
@@ -45,20 +85,20 @@
45
85
  /**
46
86
  * Adds event listener.
47
87
  * @param {string} type Type of the event (e.g. `change`).
48
- * @param {function} listener Callback function to be called when the event is emitted.
88
+ * @param {Function} listener Callback function to be called when the event is emitted.
89
+ * @return {Function} A function to remove the listener.
49
90
  * @example
50
91
  * // Re render when model changes.
51
92
  * this.model.on('change', this.render.bind(this));
52
93
  */
53
94
  on(type, listener) {
54
95
  // Validate listener.
55
- if (typeof listener !== 'function') {
56
- throw new TypeError('Listener must be a function');
57
- }
96
+ validateListener(listener);
58
97
  // Create listeners object if it doesn't exist.
59
98
  if (!this.listeners) this.listeners = {};
99
+ // Every type must have an array of listeners.
60
100
  if (!this.listeners[type]) this.listeners[type] = [];
61
- // Add listener.
101
+ // Add listener to the array of listeners.
62
102
  this.listeners[type].push(listener);
63
103
  // Return a function to remove the listener.
64
104
  return () => this.off(type, listener);
@@ -67,33 +107,47 @@
67
107
  /**
68
108
  * Adds event listener that executes once.
69
109
  * @param {string} type Type of the event (e.g. `change`).
70
- * @param {function} listener Callback function to be called when the event is emitted.
110
+ * @param {Function} listener Callback function to be called when the event is emitted.
111
+ * @return {Function} A function to remove the listener.
71
112
  * @example
72
113
  * // Log a message once when model changes.
73
114
  * this.model.once('change', () => console.log('This will happen once'));
74
115
  */
75
116
  once(type, listener) {
76
- // If listener is a function, wrap it to remove it after it is called.
77
- if (typeof listener === 'function') {
78
- const self = this;
79
- const originalListener = listener;
80
-
81
- listener = function(...args) {
82
- originalListener(...args);
83
- self.off(type, listener);
84
- };
85
- }
117
+ // Validate listener.
118
+ validateListener(listener);
119
+ // Wrap listener to remove it after it is called.
120
+ const wrapper = (...args) => {
121
+ listener(...args);
122
+ this.off(type, wrapper);
123
+ };
86
124
  // Add listener.
87
- return this.on(type, listener);
125
+ return this.on(type, wrapper);
88
126
  }
89
127
 
90
128
  /**
91
- * Removes event listeners.
92
- * @param {string} [type] Type of the event (e.g. `change`). If is not provided, it removes all listeners.
93
- * @param {function} [listener] Callback function to be called when the event is emitted. If listener is not provided, it removes all listeners for specified type.
129
+ * Removes event listeners with flexible parameter combinations.
130
+ * @param {string} [type] Type of the event (e.g. `change`). If not provided, removes ALL listeners from this emitter.
131
+ * @param {Function} [listener] Specific callback function to remove. If not provided, removes all listeners for the specified type.
132
+ *
133
+ * **Behavior based on parameters:**
134
+ * - `off()` - Removes ALL listeners from this emitter
135
+ * - `off(type)` - Removes all listeners for the specified event type
136
+ * - `off(type, listener)` - Removes the specific listener for the specified event type
137
+ *
138
+ * @example
139
+ * // Remove all listeners from this emitter
140
+ * this.model.off();
141
+ *
94
142
  * @example
95
- * // Stop listening to changes.
143
+ * // Remove all 'change' event listeners
96
144
  * this.model.off('change');
145
+ *
146
+ * @example
147
+ * // Remove specific listener for 'change' events
148
+ * const myListener = () => console.log('changed');
149
+ * this.model.on('change', myListener);
150
+ * this.model.off('change', myListener);
97
151
  */
98
152
  off(type, listener) {
99
153
  // No listeners.
@@ -120,33 +174,146 @@
120
174
  /**
121
175
  * Emits event of specified type. Listeners will receive specified arguments.
122
176
  * @param {string} type Type of the event (e.g. `change`).
123
- * @param {any} [...args] Arguments to be passed to listener.
177
+ * @param {...any} [args] Optional arguments to be passed to listeners.
124
178
  * @example
125
- * // Emit validation error event.
179
+ * // Emit validation error event with no arguments
126
180
  * this.emit('invalid');
181
+ *
182
+ * @example
183
+ * // Emit change event with data
184
+ * this.emit('change', { field : 'name', value : 'John' });
127
185
  */
128
186
  emit(type, ...args) {
129
187
  // No listeners.
130
188
  if (!this.listeners || !this.listeners[type]) return;
131
189
  // Call listeners. Use `slice` to make a copy and prevent errors when
132
190
  // removing listeners inside a listener.
133
- this.listeners[type]
134
- .slice()
135
- .forEach(function(fn) {
136
- fn(...args);
137
- });
191
+ this.listeners[type].slice().forEach(fn => fn(...args));
192
+ }
193
+
194
+ /**
195
+ * Listen to an event of another emitter (Inverse of Control pattern).
196
+ *
197
+ * This method allows this object to manage its own listening relationships,
198
+ * making cleanup easier and preventing memory leaks. Instead of calling
199
+ * `otherEmitter.on()`, you call `this.listenTo(otherEmitter, ...)` which
200
+ * allows this object to track and clean up all its listeners at once.
201
+ *
202
+ * @param {Emitter} emitter The emitter to listen to.
203
+ * @param {string} type The type of the event to listen to.
204
+ * @param {Function} listener The listener to call when the event is emitted.
205
+ * @return {Function} A function to stop listening to the event.
206
+ *
207
+ * @example
208
+ * // Instead of: otherModel.on('change', this.render.bind(this));
209
+ * // Use: this.listenTo(otherModel, 'change', this.render.bind(this));
210
+ * // This way you can later call this.stopListening() to clean up all listeners
211
+ */
212
+ listenTo(emitter, type, listener) {
213
+ // Add listener to the emitter.
214
+ emitter.on(type, listener);
215
+ // Create listeningTo array if it doesn't exist.
216
+ if (!this.listeningTo) this.listeningTo = [];
217
+ // Add listener to the array of listeners.
218
+ this.listeningTo.push({ emitter, type, listener });
219
+ // Return a function to stop listening to the event.
220
+ return () => this.stopListening(emitter, type, listener);
221
+ }
222
+
223
+ /**
224
+ * Listen to an event of another emitter and remove the listener after it is called (Inverse of Control pattern).
225
+ *
226
+ * Similar to `listenTo()` but automatically removes the listener after the first execution,
227
+ * like `once()` but with the inverse of control benefits for cleanup management.
228
+ *
229
+ * @param {Emitter} emitter The emitter to listen to.
230
+ * @param {string} type The type of the event to listen to.
231
+ * @param {Function} listener The listener to call when the event is emitted.
232
+ * @return {Function} A function to stop listening to the event.
233
+ *
234
+ * @example
235
+ * // Listen once to another emitter's initialization event
236
+ * this.listenToOnce(otherModel, 'initialized', () => {
237
+ * console.log('Other model initialized');
238
+ * });
239
+ */
240
+ listenToOnce(emitter, type, listener) {
241
+ validateListener(listener);
242
+ // Wrap listener to remove it after it is called.
243
+ const wrapper = (...args) => {
244
+ listener(...args);
245
+ this.stopListening(emitter, type, wrapper);
246
+ };
247
+ // Add listener.
248
+ return this.listenTo(emitter, type, wrapper);
249
+ }
250
+
251
+ /**
252
+ * Stop listening to events from other emitters (Inverse of Control pattern).
253
+ *
254
+ * This method provides flexible cleanup of listening relationships established with `listenTo()`.
255
+ * All parameters are optional, allowing different levels of cleanup granularity.
256
+ *
257
+ * @param {Emitter} [emitter] The emitter to stop listening to. If not provided, stops listening to ALL emitters.
258
+ * @param {string} [type] The type of event to stop listening to. If not provided, stops listening to all event types from the specified emitter.
259
+ * @param {Function} [listener] The specific listener to remove. If not provided, removes all listeners for the specified event type from the specified emitter.
260
+ *
261
+ * **Behavior based on parameters:**
262
+ * - `stopListening()` - Stops listening to ALL events from ALL emitters
263
+ * - `stopListening(emitter)` - Stops listening to all events from the specified emitter
264
+ * - `stopListening(emitter, type)` - Stops listening to the specified event type from the specified emitter
265
+ * - `stopListening(emitter, type, listener)` - Stops listening to the specific listener for the specific event from the specific emitter
266
+ *
267
+ * @example
268
+ * // Stop listening to all events from all emitters (complete cleanup)
269
+ * this.stopListening();
270
+ *
271
+ * @example
272
+ * // Stop listening to all events from a specific emitter
273
+ * this.stopListening(otherModel);
274
+ *
275
+ * @example
276
+ * // Stop listening to 'change' events from a specific emitter
277
+ * this.stopListening(otherModel, 'change');
278
+ *
279
+ * @example
280
+ * // Stop listening to a specific listener
281
+ * const myListener = () => console.log('changed');
282
+ * this.listenTo(otherModel, 'change', myListener);
283
+ * this.stopListening(otherModel, 'change', myListener);
284
+ */
285
+ stopListening(emitter, type, listener) {
286
+ // No listeningTo object.
287
+ if (!this.listeningTo) return;
288
+ // Remove listener from the array of listeners.
289
+ this.listeningTo = this.listeningTo.filter(item => {
290
+ if (
291
+ !emitter ||
292
+ (emitter === item.emitter && !type) ||
293
+ (emitter === item.emitter && type === item.type && !listener) ||
294
+ (emitter === item.emitter && type === item.type && listener === item.listener)
295
+ ) {
296
+ item.emitter.off(item.type, item.listener);
297
+ return false;
298
+ }
299
+ return true;
300
+ });
301
+ // Remove listeningTo object if it's empty.
302
+ if (!this.listeningTo.length) delete this.listeningTo;
138
303
  }
139
304
  }
140
305
 
141
- /*
306
+ /**
142
307
  * Evaluate the expression. If it's a function, call it with the provided context and return the result.
143
308
  * Otherwise, return the expression as is.
144
309
  * @param {any} expression Expression to be evaluated.
145
310
  * @param {any} context Context to call the expression.
146
311
  * @param {...any} args Arguments to pass to the expression.
147
312
  * @return {any} The result of the expression.
313
+ * @module
314
+ * @private
148
315
  */
149
- var getResult = (expression, context, ...args) =>
316
+ const getResult = (expression, context, ...args) =>
150
317
  typeof expression !== 'function' ? expression :
151
318
  expression.apply(context, args);
152
319
 
@@ -157,55 +324,113 @@
157
324
  * A `Model` manages an internal table of data attributes and triggers change events when any of its data is modified.
158
325
  * Models may handle syncing data with a persistence layer. To design your models, create atomic, reusable objects
159
326
  * that contain all the necessary functions for manipulating their specific data.
160
- * Models should be easily passed throughout your app and used anywhere the corresponding data is needed.
161
- * Rasti models store their attributes in `this.attributes`, which is extended from `this.defaults` and the
162
- * constructor `attributes` parameter. For every attribute, a getter is generated to retrieve the model property
163
- * from `this.attributes`, and a setter is created to set the model property in `this.attributes` and emit `change`
164
- * and `change:attribute` events.
327
+ * Models should be easily passed throughout your app and used anywhere the corresponding data is needed.
328
+ *
329
+ * ## Construction Flow
330
+ * 1. `preinitialize()` is called with all constructor arguments
331
+ * 2. `this.defaults` are resolved (if function, it's called and bound to the model)
332
+ * 3. `parse()` is called with all constructor arguments to process the data
333
+ * 4. `this.attributes` is built by merging defaults and parsed data
334
+ * 5. Getters/setters are generated for each attribute to emit change events
335
+ *
165
336
  * @module
166
- * @extends Rasti.Emitter
167
- * @param {object} attributes Object containing model attributes to extend `this.attributes`. Getters and setters are generated for `this.attributes`, in order to emit `change` events.
168
- * @property {object|function} defaults Object containing default attributes for the model. It will extend `this.attributes`. If a function is passed, it will be called to get the defaults. It will be bound to the model instance.
337
+ * @extends Emitter
338
+ * @param {object} [attributes={}] Primary data object containing model attributes
339
+ * @param {...*} [args] Additional arguments passed to `preinitialize` and `parse` methods
340
+ * @property {object|Function} defaults Default attributes for the model. If a function, it's called bound to the model instance to get defaults.
169
341
  * @property {object} previous Object containing previous attributes when a change occurs.
342
+ * @property {string} attributePrefix Static property that defines a prefix for generated getters/setters. Defaults to empty string.
170
343
  * @example
171
344
  * import { Model } from 'rasti';
172
- * // Product model
173
- * class ProductModel extends Model {
345
+ *
346
+ * // User model
347
+ * class User extends Model {
174
348
  * preinitialize() {
175
- * // The Product model has `name` and `price` default attributes.
176
- * // `defaults` will extend `this.attributes`.
177
- * // Getters and setters are generated for `this.attributes`,
178
- * // in order to emit `change` events.
349
+ * this.defaults = { name : '', email : '', role : 'user' };
350
+ * }
351
+ * }
352
+ * // Order model with nested User and custom methods
353
+ * class Order extends Model {
354
+ * preinitialize(attributes, options = {}) {
179
355
  * this.defaults = {
180
- * name: '',
181
- * price: 0
356
+ * id : null,
357
+ * total : 0,
358
+ * status : 'pending',
359
+ * user : null
182
360
  * };
361
+ *
362
+ * this.apiUrl = options.apiUrl || '/api/orders';
183
363
  * }
184
364
  *
185
- * setDiscount(discountPercentage) {
186
- * // Apply a discount to the price property.
187
- * // This will call a setter that will update `price` in `this.attributes`,
188
- * // and emit `change` and `change:price` events.
189
- * const discount = this.price * (discountPercentage / 100);
190
- * this.price -= discount;
365
+ * parse(data, options = {}) {
366
+ * const parsed = { ...data };
367
+ *
368
+ * // Convert user object to User model instance
369
+ * if (data.user && !(data.user instanceof User)) {
370
+ * parsed.user = new User(data.user);
371
+ * }
372
+ *
373
+ * return parsed;
374
+ * }
375
+ *
376
+ * toJSON() {
377
+ * const result = {};
378
+ * for (const [key, value] of Object.entries(this.attributes)) {
379
+ * if (value instanceof Model) {
380
+ * result[key] = value.toJSON();
381
+ * } else {
382
+ * result[key] = value;
383
+ * }
384
+ * }
385
+ * return result;
386
+ * }
387
+ *
388
+ * async fetch() {
389
+ * try {
390
+ * const response = await fetch(`${this.apiUrl}/${this.id}`);
391
+ * const data = await response.json();
392
+ *
393
+ * // Parse the fetched data and update model
394
+ * const parsed = this.parse(data);
395
+ * this.set(parsed, { source : 'fetch' });
396
+ *
397
+ * return this;
398
+ * } catch (error) {
399
+ * console.error('Failed to fetch order:', error);
400
+ * throw error;
401
+ * }
191
402
  * }
192
403
  * }
193
- * // Create a product instance with a name and price.
194
- * const product = new ProductModel({ name: 'Smartphone', price: 1000 });
195
- * // Listen to the `change:price` event.
196
- * product.on('change:price', () => console.log('New Price:', product.price));
197
- * // Apply a 10% discount to the product.
198
- * product.setDiscount(10); // Output: "New Price: 900"
404
+ *
405
+ * // Create order with nested user data
406
+ * const order = new Order({
407
+ * id : 123,
408
+ * total : 99.99,
409
+ * user : { name : 'Alice', email : 'alice@example.com' }
410
+ * });
411
+ *
412
+ * console.log(order.user instanceof User); // true
413
+ * // Serialize with nested models
414
+ * const json = order.toJSON();
415
+ * console.log(json); // { id: 123, total: 99.99, status: 'pending', user: { name: 'Alice', email: 'alice@example.com', role: 'user' } }
416
+ *
417
+ * // Listen to fetch updates
418
+ * order.on('change', (model, changed, options) => {
419
+ * if (options?.source === 'fetch') {
420
+ * console.log('Order updated from server:', changed);
421
+ * }
422
+ * });
423
+ *
424
+ * // Fetch latest data from server
425
+ * await order.fetch();
199
426
  */
200
427
  class Model extends Emitter {
201
- constructor(attributes = {}) {
428
+ constructor() {
202
429
  super();
203
430
  // Call preinitialize.
204
431
  this.preinitialize.apply(this, arguments);
205
- // Get defaults. If `this.defaults` is a function, call it.
206
- const defaults = getResult(this.defaults, this) || {};
207
432
  // Set attributes object with defaults and passed attributes.
208
- this.attributes = Object.assign({}, defaults, attributes);
433
+ this.attributes = Object.assign({}, getResult(this.defaults, this), this.parse.apply(this, arguments));
209
434
  // Object to store previous attributes when a change occurs.
210
435
  this.previous = {};
211
436
  // Generate getters/setters for every attribute.
@@ -213,21 +438,53 @@
213
438
  }
214
439
 
215
440
  /**
216
- * If you define a preinitialize method, it will be invoked when the Model is first created, before any instantiation logic is run for the Model.
217
- * @param {object} attributes Object containing model attributes to extend `this.attributes`.
441
+ * Called before any instantiation logic runs for the Model.
442
+ * Receives all constructor arguments, allowing for flexible initialization patterns.
443
+ * Use this to set up `defaults`, configure the model, or handle custom constructor arguments.
444
+ * @param {object} [attributes={}] Primary data object containing model attributes
445
+ * @param {...*} [args] Additional arguments passed from the constructor
446
+ * @example
447
+ * class User extends Model {
448
+ * preinitialize(attributes, options = {}) {
449
+ * this.defaults = { name : '', role : options.defaultRole || 'user' };
450
+ * this.apiEndpoint = options.apiEndpoint || '/users';
451
+ * }
452
+ * }
453
+ * const user = new User({ name : 'Alice' }, { defaultRole : 'admin', apiEndpoint : '/api/users' });
218
454
  */
219
455
  preinitialize() {}
220
456
 
221
457
  /**
222
- * Generate getter/setter for the given key. In order to emit `change` events.
223
- * This method is called internally by the constructor
224
- * for `this.attributes`.
225
- * @param {string} key Attribute key.
458
+ * Generate getter/setter for the given attribute key to emit `change` events.
459
+ * The property name uses `attributePrefix` + key (e.g., with prefix 'attr_', key 'name' becomes 'attr_name').
460
+ * Called internally by the constructor for each key in `this.attributes`.
461
+ * Override with an empty method if you don't want automatic getters/setters.
462
+ *
463
+ * @param {string} key Attribute key from `this.attributes`
464
+ * @example
465
+ * // Custom prefix for all attributes
466
+ * class PrefixedModel extends Model {
467
+ * static attributePrefix = 'attr_';
468
+ * }
469
+ * const model = new PrefixedModel({ name: 'Alice' });
470
+ * console.log(model.attr_name); // 'Alice'
471
+ *
472
+ * // Disable automatic getters/setters
473
+ * class ManualModel extends Model {
474
+ * defineAttribute() {
475
+ * // Empty - no getters/setters generated
476
+ * }
477
+ *
478
+ * getName() {
479
+ * return this.get('name'); // Manual getter
480
+ * }
481
+ * }
226
482
  */
227
483
  defineAttribute(key) {
228
484
  Object.defineProperty(
229
485
  this,
230
- key, {
486
+ `${this.constructor.attributePrefix}${key}`,
487
+ {
231
488
  get : () => this.get(key),
232
489
  set : (value) => { this.set(key, value); }
233
490
  }
@@ -245,18 +502,28 @@
245
502
  }
246
503
 
247
504
  /**
248
- * Set an attribute into `this.attributes`.
249
- * Emit `change` and `change:attribute` if a value changes.
250
- * Could be called in two forms, `this.set('key', value)` and
251
- * `this.set({ key : value })`.
252
- * This method is called internally by generated setters.
253
- * The `change` event listener will receive the model instance, an object containing the changed attributes, and the rest of the arguments passed to `set` method.
254
- * The `change:attribute` event listener will receive the model instance, the new attribute value, and the rest of the arguments passed to `set` method.
255
- * @param {string} key Attribute key or object containing keys/values.
256
- * @param [value] Attribute value.
257
- * @return {this} This model.
258
- * @emits change
259
- * @emits change:attribute
505
+ * Set one or more attributes into `this.attributes` and emit change events.
506
+ * Supports two call signatures: `set(key, value, ...args)` or `set(object, ...args)`.
507
+ * Additional arguments are passed to change event listeners, enabling custom behavior.
508
+ *
509
+ * @param {string|object} key Attribute key (string) or object containing key-value pairs
510
+ * @param {*} [value] Attribute value (when key is string)
511
+ * @param {...*} [args] Additional arguments passed to event listeners
512
+ * @return {Model} This model instance for chaining
513
+ * @emits change Emitted when any attribute changes. Listeners receive `(model, changedAttributes, ...args)`
514
+ * @emits change:attribute Emitted for each changed attribute. Listeners receive `(model, newValue, ...args)`
515
+ * @example
516
+ * // Basic usage
517
+ * model.set('name', 'Alice');
518
+ * model.set({ name : 'Alice', age : 30 });
519
+ *
520
+ * // With options for listeners
521
+ * model.set('name', 'Bob', { silent : false, validate : true });
522
+ * model.on('change:name', (model, value, options) => {
523
+ * if (options?.validate) {
524
+ * // Custom validation logic
525
+ * }
526
+ * });
260
527
  */
261
528
  set(key, value, ...rest) {
262
529
  let attrs, args;
@@ -310,28 +577,105 @@
310
577
  return this;
311
578
  }
312
579
 
580
+ /**
581
+ * Transforms and validates data before it becomes model attributes.
582
+ * Called during construction with all constructor arguments, allowing flexible data processing.
583
+ * Override this method to transform incoming data, create nested models, or handle different data formats.
584
+ *
585
+ * @param {object} [data={}] Primary data object to be parsed into attributes
586
+ * @param {...*} [args] Additional arguments from constructor, useful for parsing options
587
+ * @return {object} Processed data that will become the model's attributes
588
+ * @example
589
+ * // Transform nested objects into models
590
+ * class User extends Model {}
591
+ * class Order extends Model {
592
+ * parse(data, options = {}) {
593
+ * // Skip parsing if requested
594
+ * if (options.raw) return data;
595
+ * // Transform user data into User model
596
+ * const parsed = { ...data };
597
+ * if (data.user && !(data.user instanceof User)) {
598
+ * parsed.user = new User(data.user);
599
+ * }
600
+ * return parsed;
601
+ * }
602
+ * }
603
+ *
604
+ * // Usage with parsing options
605
+ * const order1 = new Order({ id : 1, user : { name : 'Alice' } }); // user becomes User model
606
+ * const order2 = new Order({ id : 2, user : { name : 'Bob' } }, { raw : true }); // user stays plain object
607
+ */
608
+ parse(data) {
609
+ return data;
610
+ }
611
+
313
612
  /**
314
613
  * Return object representation of the model to be used for JSON serialization.
315
614
  * By default returns a copy of `this.attributes`.
615
+ * You can override this method to customize serialization behavior, such as calling `toJSON` recursively on nested Model instances.
316
616
  * @return {object} Object representation of the model to be used for JSON serialization.
617
+ * @example
618
+ * // Basic usage - returns a copy of model attributes:
619
+ * const user = new Model({ name : 'Alice', age : 30 });
620
+ * const json = user.toJSON();
621
+ * console.log(json); // { name : 'Alice', age : 30 }
622
+ *
623
+ * // Override toJSON for recursive serialization of nested models:
624
+ * class User extends Model {}
625
+ * class Order extends Model {
626
+ * parse(data) {
627
+ * // Ensure user is always a User model
628
+ * return { ...data, user : data.user instanceof User ? data.user : new User(data.user) };
629
+ * }
630
+ *
631
+ * toJSON() {
632
+ * const result = {};
633
+ * for (const [key, value] of Object.entries(this.attributes)) {
634
+ * if (value instanceof Model) {
635
+ * result[key] = value.toJSON();
636
+ * } else {
637
+ * result[key] = value;
638
+ * }
639
+ * }
640
+ * return result;
641
+ * }
642
+ * }
643
+ * const order = new Order({ id : 1, user : { name : 'Alice' } });
644
+ * const json = order.toJSON();
645
+ * console.log(json); // { id : 1, user : { name : 'Alice' } }
317
646
  */
318
647
  toJSON() {
319
648
  return Object.assign({}, this.attributes);
320
649
  }
321
650
  }
322
651
 
652
+ /**
653
+ * Static property that defines a prefix for generated getters/setters.
654
+ * When set, all attribute properties will be prefixed (e.g., 'attr_name' instead of 'name').
655
+ * Useful for avoiding naming conflicts or creating a consistent property naming convention.
656
+ * @type {string}
657
+ * @default ''
658
+ * @example
659
+ * // Set prefix for all models of this class
660
+ * class ApiModel extends Model {
661
+ * static attributePrefix = 'attr_';
662
+ * }
663
+ *
664
+ * const user = new ApiModel({ name : 'Alice', email : 'alice@example.com' });
665
+ * console.log(user.attr_name); // 'Alice'
666
+ * console.log(user.attr_email); // 'alice@example.com'
667
+ *
668
+ * // Still access via get/set methods without prefix
669
+ * console.log(user.get('name')); // 'Alice'
670
+ * user.set('name', 'Bob');
671
+ * console.log(user.attr_name); // 'Bob'
672
+ */
673
+ Model.attributePrefix = '';
674
+
323
675
  /*
324
676
  * These option keys will be extended on the view instance.
325
677
  */
326
- const viewOptions = {
327
- el : true,
328
- tag : true,
329
- attributes : true,
330
- events : true,
331
- model : true,
332
- template : true,
333
- onDestroy : true
334
- };
678
+ const viewOptions = ['el', 'tag', 'attributes', 'events', 'model', 'template', 'onDestroy'];
335
679
 
336
680
  /**
337
681
  * - Listens for changes and renders the UI.
@@ -348,14 +692,15 @@
348
692
  * @module
349
693
  * @extends Emitter
350
694
  * @param {object} options Object containing options. The following keys will be merged into the view instance: `el`, `tag`, `attributes`, `events`, `model`, `template`, `onDestroy`.
351
- * @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}.
352
- * @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}.
353
- * @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}.
354
- * @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}.
695
+ * @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}.
696
+ * @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}.
697
+ * @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}.
698
+ * @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}.
355
699
  * @property {object} model A model or any object containing data and business logic.
356
- * @property {function} template A function that returns a string with the view's inner HTML. See {@link module_view__render View.render}.
700
+ * @property {Function} template A function that returns a string with the view's inner HTML. See {@link module_view__render View.render}.
701
+ * @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.
357
702
  * @example
358
- * import { View } from 'rasti';
703
+ * import { View, Model } from 'rasti';
359
704
  *
360
705
  * class Timer extends View {
361
706
  * constructor(options) {
@@ -380,9 +725,6 @@
380
725
  super();
381
726
  // Call preinitialize.
382
727
  this.preinitialize.apply(this, arguments);
383
- // Generate unique id.
384
- // Useful to generate element ids.
385
- this.uid = `uid${++View.uid}`;
386
728
  // Store delegated event listeners,
387
729
  // so they can be unbound later.
388
730
  this.delegatedEventListeners = [];
@@ -391,17 +733,23 @@
391
733
  this.children = [];
392
734
  // Mutable array to store handlers to be called on destroy.
393
735
  this.destroyQueue = [];
394
- // Extend "this" with options, mapping viewOptions keys.
395
- Object.keys(options).forEach(key => {
396
- if (viewOptions[key]) this[key] = options[key];
736
+ this.viewOptions = [];
737
+ // Extend "this" with options.
738
+ viewOptions.forEach(key => {
739
+ if (key in options) {
740
+ this[key] = options[key];
741
+ this.viewOptions.push(key);
742
+ }
397
743
  });
744
+ // Ensure that the view has a unique id at `this.uid`.
745
+ this.ensureUid();
398
746
  // Ensure that the view has a root element at `this.el`.
399
747
  this.ensureElement();
400
748
  }
401
749
 
402
750
  /**
403
751
  * If you define a preinitialize method, it will be invoked when the view is first created, before any instantiation logic is run.
404
- * @param {object} attrs Object containing model attributes to extend `this.attributes`.
752
+ * @param {object} options The view options.
405
753
  */
406
754
  preinitialize() {}
407
755
 
@@ -436,6 +784,8 @@
436
784
  this.destroyChildren();
437
785
  // Undelegate `this.el` event listeners
438
786
  this.undelegateEvents();
787
+ // Stop listening to events.
788
+ this.stopListening();
439
789
  // Unbind `this` events.
440
790
  this.off();
441
791
  // Call destroy queue.
@@ -443,6 +793,8 @@
443
793
  this.destroyQueue = [];
444
794
  // Call onDestroy lifecycle method
445
795
  this.onDestroy.apply(this, arguments);
796
+ // Set destroyed flag.
797
+ this.destroyed = true;
446
798
  // Return `this` for chaining.
447
799
  return this;
448
800
  }
@@ -474,6 +826,13 @@
474
826
  this.children = [];
475
827
  }
476
828
 
829
+ /**
830
+ * Ensure that the view has a unique id at `this.uid`.
831
+ */
832
+ ensureUid() {
833
+ if (!this.uid) this.uid = `r-${++View.uid}`;
834
+ }
835
+
477
836
  /**
478
837
  * Ensure that the view has a root element at `this.el`.
479
838
  * You shouldn't call this method directly. It's called from the constructor.
@@ -540,26 +899,43 @@
540
899
  * All attached listeners are bound to the view, ensuring that `this` refers to the view object when the listeners are invoked.
541
900
  * When `delegateEvents` is called again, possibly with a different events object, all previous listeners are removed and delegated afresh.
542
901
  *
543
- * The listeners will be invoked with the event and the view as arguments.
544
- *
902
+ * **Listener signature:** `(event, view, matched)`
903
+ * - `event`: The native DOM event object.
904
+ * - `view`: The current view instance (`this`).
905
+ * - `matched`: The element that satisfies the selector. If no selector is provided, it will be the view's root element (`this.el`).
906
+ *
907
+ * If more than one ancestor between `event.target` and the view's root element matches the selector, the listener will be
908
+ * invoked **once for each matched element** (from inner to outer).
909
+ *
545
910
  * @param {object} [events] Object in the format `{'event selector' : 'listener'}`. Used to bind delegated event listeners to the root element.
546
911
  * @return {Rasti.View} Returns `this` for chaining.
547
912
  * @example
548
- * // Using a function.
913
+ * // Using prototype (recommended for static events)
549
914
  * class Modal extends View {
915
+ * onClickOk(event, view, matched) {
916
+ * // matched === the button.ok element that was clicked
917
+ * this.close();
918
+ * }
919
+ *
920
+ * onClickCancel() {
921
+ * this.destroy();
922
+ * }
923
+ * }
924
+ * Modal.prototype.events = {
925
+ * 'click button.ok': 'onClickOk',
926
+ * 'click button.cancel': 'onClickCancel',
927
+ * 'submit form': 'onSubmit'
928
+ * };
929
+ *
930
+ * // Using a function for dynamic events
931
+ * class DynamicView extends View {
550
932
  * events() {
551
933
  * return {
552
- * 'click button.ok': 'onClickOkButton',
553
- * 'click button.cancel': function() {}
934
+ * [`click .${this.model.buttonClass}`]: 'onButtonClick',
935
+ * 'click': 'onRootClick'
554
936
  * };
555
937
  * }
556
938
  * }
557
- *
558
- * // Using an object.
559
- * Modal.prototype.events = {
560
- * 'click button.ok' : 'onClickOkButton',
561
- * 'click button.cancel' : function() {}
562
- * };
563
939
  */
564
940
  delegateEvents(events) {
565
941
  if (!events) events = getResult(this.events, this);
@@ -576,13 +952,10 @@
576
952
  const selector = keyParts.join(' ');
577
953
 
578
954
  let listener = events[key];
579
- // Listener may be a string representing a method name on the view,
580
- // or a function.
581
- listener = (
582
- typeof listener === 'string' ?
583
- this[listener] :
584
- listener
585
- ).bind(this);
955
+ // Listener may be a string representing a method name on the view, or a function.
956
+ if (typeof listener === 'string') listener = this[listener];
957
+ // Validate listener is a function.
958
+ validateListener(listener);
586
959
 
587
960
  if (!eventTypes[type]) eventTypes[type] = [];
588
961
 
@@ -594,7 +967,20 @@
594
967
  const typeListener = (event) => {
595
968
  // Iterate and run every individual listener if the selector matches.
596
969
  eventTypes[type].forEach(({ selector, listener }) => {
597
- if (!selector || event.target.closest(selector)) listener(event, this);
970
+ // No selector provided: invoke listener once with root element.
971
+ if (!selector) {
972
+ listener.call(this, event, this, this.el);
973
+ return;
974
+ }
975
+
976
+ let node = event.target;
977
+ // Traverse ancestors until reaching the view root (`this.el`).
978
+ while (node && node !== this.el) {
979
+ if (node.matches && node.matches(selector)) {
980
+ listener.call(this, event, this, node);
981
+ }
982
+ node = node.parentElement;
983
+ }
598
984
  });
599
985
  };
600
986
 
@@ -622,17 +1008,16 @@
622
1008
  }
623
1009
 
624
1010
  /**
625
- * Renders the view.
1011
+ * Renders the view.
626
1012
  * This method should be overridden with custom logic.
627
1013
  * The only convention is to manipulate the DOM within the scope of `this.el`,
628
- * and to return `this` for chaining.
629
- * If you add any child views, you should call `this.destroyChildren` before re-rendering.
630
- * The default implementation sets the innerHTML of `this.el` with the result
631
- * of calling `this.template`, passing `this.model` as an argument.
632
- * <br><br> &#9888; **Security Notice:** The default implementation utilizes `innerHTML` on the root element
633
- * for rendering, which may introduce Cross-Site Scripting (XSS) risks. Ensure that any user-generated
634
- * content is properly sanitized before inserting it into the DOM. You can use the @link{#module_view_sanitize View.sanitize}
635
- * static method to escape HTML entities in a string.
1014
+ * and to return `this` for chaining.
1015
+ * If you add any child views, you should call `this.destroyChildren` before re-rendering.
1016
+ * The default implementation updates `this.el`'s innerHTML with the result
1017
+ * of calling `this.template`, passing `this.model` as the argument.
1018
+ * <br><br> &#9888; **Security Notice:** The default implementation utilizes `innerHTML`, which may introduce Cross-Site Scripting (XSS) risks.
1019
+ * Ensure that any user-generated content is properly sanitized before inserting it into the DOM.
1020
+ * You can use the {@link #module_view_sanitize View.sanitize} static method to escape HTML entities in a string.
636
1021
  * For best practices on secure data handling, refer to the
637
1022
  * [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
638
1023
  * @return {Rasti.View} Returns `this` for chaining.
@@ -649,7 +1034,7 @@
649
1034
  * Override this method to provide a custom escape function.
650
1035
  * This method is inherited by {@link #module_component Component} and used to escape template interpolations.
651
1036
  * @static
652
- * @param {string} str String to escape.
1037
+ * @param {string} value String to escape.
653
1038
  * @return {string} Escaped string.
654
1039
  */
655
1040
  static sanitize(value) {
@@ -663,24 +1048,24 @@
663
1048
  }
664
1049
  }
665
1050
 
666
- /*
667
- * Unique Id
1051
+ /**
1052
+ * Counter for generating unique IDs for view instances.
1053
+ * This is primarily used to assign unique identifiers to each view instance (`this.uid`), which can be helpful for tasks like
1054
+ * generating element IDs.
1055
+ * {@link #module_component Component}s use `this.uid` to generate data attributes for their elements, to be looked up on hydration.
1056
+ * For server-side rendering, this counter should be reset to `0` on every request to ensure that the generated
1057
+ * unique IDs match those on the client, enabling seamless hydration of components.
1058
+ * @static
1059
+ * @type {number}
1060
+ * @default 0
668
1061
  */
669
1062
  View.uid = 0;
670
1063
 
671
- /*
672
- * Flatten an array recursively
673
- * @param {Array} arr Array to flat recursively
674
- * @return {Array} Flat array
675
- */
676
- const deepFlat = (arr) => arr.reduce((acc, val) => {
677
- if (Array.isArray(val)) acc.push(...deepFlat(val));
678
- else acc.push(val);
679
- return acc;
680
- }, []);
681
-
682
- /*
1064
+ /**
683
1065
  * Wrapper class for HTML strings marked as safe.
1066
+ * @param {string} value The HTML string to be marked as safe.
1067
+ * @property {string} value The HTML string.
1068
+ * @private
684
1069
  */
685
1070
  class SafeHTML {
686
1071
  constructor(value) {
@@ -692,413 +1077,1110 @@
692
1077
  }
693
1078
  }
694
1079
 
695
- /*
696
- * Same as getResult, but pass context as argument to the expression.
697
- * Used to evaluate expressions in the context of a component.
698
- * @param {any} expression The expression to be evaluated.
699
- * @param {any} context The context to call the expression with.
700
- * @return {any} The result of the evaluated expression.
1080
+ /**
1081
+ * Wrapper class for partial templates that preserves structure.
1082
+ * @param {Array} items The items in the partial.
1083
+ * @private
701
1084
  */
702
- const getExpressionResult = (expression, context) => getResult(expression, context, context);
1085
+ class Partial {
1086
+ constructor(items) {
1087
+ this.items = items;
1088
+ }
1089
+ }
703
1090
 
704
- /*
705
- * Generate string with placeholders for interpolated expressions.
706
- * @param strings {array} Array of strings.
707
- * @param expressions {array} Array of expressions.
708
- * @return {string} String with placeholders.
1091
+ /**
1092
+ * Wrapper for interpolation results with markers.
1093
+ * @param {number} interpolationUid The interpolation UID for markers.
1094
+ * @param {any} result The interpolation result.
1095
+ * @private
709
1096
  */
710
- const addPlaceholders = (strings, expressions) =>
711
- strings.reduce((out, string, i) => {
712
- // Add string part.
713
- out.push(string);
714
- // Add expression placeholders.
715
- if (typeof expressions[i] !== 'undefined') {
716
- out.push(Component.PLACEHOLDER_EXPRESSION(i));
717
- }
718
- return out;
719
- }, []).join('');
1097
+ class InterpolationWrapper {
1098
+ constructor(interpolationUid, result) {
1099
+ this.interpolationUid = interpolationUid;
1100
+ this.result = result;
1101
+ }
1102
+ }
720
1103
 
721
- /*
722
- * Generate one dimensional array with strings and expressions.
723
- * @param main {string} The main template containing placeholders.
724
- * @param expressions {array} Array of expressions to replace placeholders.
725
- * @return {array} Array containing strings and expressions.
1104
+ /**
1105
+ * Manager for delegated events.
1106
+ * @private
726
1107
  */
727
- const splitPlaceholders = (main, expressions) => {
728
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
729
- const regExp = new RegExp(`${PH}`, 'g');
730
- const out = [];
731
- let lastIndex = 0;
732
- let match;
733
- // Generate one dimensional array with strings and expressions,
734
- // so all the components are added as children by the parent component.
735
- while ((match = regExp.exec(main)) !== null) {
736
- const before = main.slice(lastIndex, match.index);
737
- out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
738
- lastIndex = match.index + match[0].length;
1108
+ class EventsManager {
1109
+ constructor() {
1110
+ this.listeners = [];
1111
+ this.types = new Set();
1112
+ this.previousSize = 0;
739
1113
  }
740
- out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
741
1114
 
742
- return out;
743
- };
1115
+ /**
1116
+ * Add a listener to the events manager.
1117
+ * @param {Function} listener The listener to add.
1118
+ * @param {string} type The type of event.
1119
+ * @return {number} The index of the listener.
1120
+ */
1121
+ addListener(listener, type) {
1122
+ this.types.add(type);
1123
+ this.listeners.push(listener);
1124
+ return this.listeners.length - 1;
1125
+ }
744
1126
 
745
- /*
746
- * Expand attributes.
747
- * @param attributes {array} Array of attributes as key, value pairs.
748
- * @param getExpressionResult {function} Function to render expressions.
749
- * @return {object}
750
- * @property {object} all All attributes.
751
- * @property {object} events Event listeners.
752
- * @property {object} attributes Attributes.
753
- */
754
- const expandAttributes = (attributes, getExpressionResult) => {
755
- const out = attributes.reduce((out, pair) => {
756
- const attribute = getExpressionResult(pair[0]);
757
- // Attribute without value.
758
- if (pair.length === 1) {
759
- if (typeof attribute === 'object') {
760
- // Expand objects as attributes.
761
- out.all = Object.assign(out.all, attribute);
762
- } else if (typeof attribute === 'string') {
763
- // Treat as boolean.
764
- out.all[attribute] = true;
765
- }
766
- } else {
767
- // Attribute with value.
768
- const value = getExpressionResult(pair[1]);
769
- out.all[attribute] = value;
770
- }
1127
+ /**
1128
+ * Reset the events manager.
1129
+ */
1130
+ reset() {
1131
+ this.listeners = [];
1132
+ this.previousSize = this.types.size;
1133
+ }
771
1134
 
772
- return out;
773
- }, { all : {}, events : {}, attributes : {} });
1135
+ /**
1136
+ * Check if there are pending types.
1137
+ * @return {boolean} True if there are pending types, false otherwise.
1138
+ */
1139
+ hasPendingTypes() {
1140
+ return this.types.size > this.previousSize;
1141
+ }
1142
+ }
774
1143
 
775
- Object.keys(out.all).forEach(key => {
776
- // Check if key is an event listener.
777
- const match = key.match(/on(([A-Z]{1}[a-z]+)+)/);
778
- if (match && match[1]) {
779
- // Add event listener.
780
- out.events[match[1].toLowerCase()] = out.all[key];
781
- } else {
782
- // Add attribute.
783
- out.attributes[key] = out.all[key];
784
- }
785
- });
1144
+ /**
1145
+ * Manager for component position tracking and recycling.
1146
+ * @private
1147
+ */
1148
+ class PathManager {
1149
+ constructor() {}
786
1150
 
787
- return out;
788
- };
1151
+ /**
1152
+ * Reset before render.
1153
+ */
1154
+ reset() {
1155
+ this.paused = 0;
1156
+ this.previous = this.tracked || new Map();
1157
+ this.tracked = new Map();
1158
+ this.positionStack = [0];
1159
+ }
789
1160
 
790
- /*
791
- * Replace component tags with expressions.
792
- * `<${Component} />` or `<${Component}></${Component}>` will be replaced
793
- * by a function that mounts the component.
794
- * Returns the template with component tags replaced by expressions placeholders
795
- * modifies the expressions array adding the mount functions.
796
- * @param main {string} The main template.
797
- * @return {string} The template with components tags replaced by expressions
798
- * placeholders.
799
- */
800
- const expandComponents = (main, expressions) => {
801
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
802
- // Match component tags.
803
- return main.replace(
804
- new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</(${PH})>|<(${PH})([^>]*)/>`,'g'),
805
- function() {
806
- const { tag, attributes, inner, close, raw } = parseMatch(arguments, expressions);
807
- // No component found.
808
- if (!(tag.prototype instanceof Component)) return raw;
1161
+ /**
1162
+ * Push position to stack.
1163
+ */
1164
+ push() {
1165
+ this.positionStack.push(0);
1166
+ }
809
1167
 
810
- let renderChildren;
811
- // Non void component.
812
- if (close) {
813
- // Close component tag must match open component tag.
814
- if (tag !== close) return raw;
1168
+ /**
1169
+ * Pop position from stack.
1170
+ */
1171
+ pop() {
1172
+ this.positionStack.pop();
1173
+ }
1174
+
1175
+ /**
1176
+ * Increment position.
1177
+ */
1178
+ increment() {
1179
+ this.positionStack[this.positionStack.length - 1]++;
1180
+ }
1181
+
1182
+ /**
1183
+ * Pause tracking.
1184
+ */
1185
+ pause() {
1186
+ this.paused++;
1187
+ }
1188
+
1189
+ /**
1190
+ * Resume tracking.
1191
+ */
1192
+ resume() {
1193
+ this.paused--;
1194
+ }
1195
+
1196
+ /**
1197
+ * Get current path as array.
1198
+ * @return {string} Current position path.
1199
+ */
1200
+ getPath() {
1201
+ return this.positionStack.join('-');
1202
+ }
1203
+
1204
+ /**
1205
+ * Track component at current path.
1206
+ * @param {Component} component The component to track.
1207
+ * @return {Component} The component.
1208
+ */
1209
+ track(component) {
1210
+ if (this.paused === 0) {
1211
+ this.tracked.set(
1212
+ this.getPath(),
1213
+ component
1214
+ );
1215
+ }
1216
+
1217
+ return component;
1218
+ }
1219
+
1220
+ /**
1221
+ * Find recyclable component by path and type.
1222
+ * @param {Function} constructor The component constructor.
1223
+ * @return {Component|null} The recyclable component or null.
1224
+ */
1225
+ findRecyclable(constructor) {
1226
+ const prev = this.previous.get(this.getPath());
1227
+ return prev && prev.constructor === constructor && !prev.key ? prev : null;
1228
+ }
1229
+ }
1230
+
1231
+ /**
1232
+ * Get difference between current and previous attributes.
1233
+ * @param {Object} attributes Current attributes object.
1234
+ * @param {Object} previous Previous attributes object.
1235
+ * @return {Object} Object with add and remove properties.
1236
+ * @module
1237
+ * @private
1238
+ */
1239
+ function getAttributesDiff(attributes, previous = {}) {
1240
+ const add = {};
1241
+ const remove = [];
1242
+ // Find attributes to add/update.
1243
+ Object.keys(attributes).forEach(key => {
1244
+ let value = attributes[key];
1245
+
1246
+ if (value === true) {
1247
+ add[key] = '';
1248
+ } else if (value !== false) {
1249
+ if (value === null || typeof value === 'undefined') value = '';
1250
+ add[key] = value;
1251
+ }
1252
+ });
1253
+ // Find attributes to remove.
1254
+ Object.keys(previous).forEach(key => {
1255
+ if (!(key in attributes) || ((previous[key] !== attributes[key]) && attributes[key] === false)) {
1256
+ remove.push(key);
1257
+ }
1258
+ });
1259
+
1260
+ return { add, remove };
1261
+ }
1262
+
1263
+ const SYNC_PROPS$1 = ['value', 'checked', 'selected'];
1264
+
1265
+ /**
1266
+ * Element reference for managing DOM element attributes.
1267
+ * @param {Object} options The options object.
1268
+ * @param {Function} options.getSelector Function that returns the CSS selector for the element.
1269
+ * @param {Function} options.getAttributes Function that returns the attributes object for the element.
1270
+ * @private
1271
+ */
1272
+ class Element {
1273
+ constructor(options) {
1274
+ this.getSelector = options.getSelector;
1275
+ this.getAttributes = options.getAttributes;
1276
+ this.previousAttributes = {};
1277
+ }
1278
+
1279
+ /**
1280
+ * Attach the element reference to a DOM element.
1281
+ * @param {Node} parent The parent node to search in.
1282
+ */
1283
+ hydrate(parent) {
1284
+ this.ref = parent.querySelector(this.getSelector());
1285
+ }
1286
+
1287
+ /**
1288
+ * Update the element's attributes based on the difference with previous attributes.
1289
+ */
1290
+ update() {
1291
+ // Attributes diff.
1292
+ const attributes = this.getAttributes();
1293
+ const { remove, add } = getAttributesDiff(attributes, this.previousAttributes);
1294
+ // Store previous attributes.
1295
+ this.previousAttributes = attributes;
1296
+ // Remove attributes first so later `setAttribute` overrides if needed.
1297
+ remove.forEach(attr => {
1298
+ this.ref.removeAttribute(attr);
1299
+ if (SYNC_PROPS$1.includes(attr) && attr in this.ref) {
1300
+ // Reset property to default.
1301
+ this.ref[attr] = attr === 'value' ? '' : false;
1302
+ }
1303
+ });
1304
+ // Add / update attributes.
1305
+ Object.keys(add).forEach(attr => {
1306
+ const value = add[attr];
1307
+ this.ref.setAttribute(attr, value);
1308
+ if (SYNC_PROPS$1.includes(attr) && attr in this.ref) {
1309
+ this.ref[attr] = attr === 'value' ? value : value !== false && value !== 'false';
1310
+ }
1311
+ });
1312
+ }
1313
+ }
1314
+
1315
+ /**
1316
+ * Properties that should be mirrored from source to target.
1317
+ * @type {string[]}
1318
+ * @private
1319
+ */
1320
+ const SYNC_PROPS = ['value', 'checked', 'selected'];
1321
+
1322
+ /**
1323
+ * Compares two elements to see if their child nodes are structurally equal.
1324
+ * @param {Element} a First element to compare.
1325
+ * @param {Element} b Second element to compare.
1326
+ * @return {boolean} True if all child nodes are deeply equal.
1327
+ * @private
1328
+ */
1329
+ const areChildNodesEqual = (a, b) => {
1330
+ const aChildren = a.childNodes;
1331
+ const bChildren = b.childNodes;
1332
+ const aLength = aChildren.length;
1333
+
1334
+ if (aLength !== bChildren.length) return false;
1335
+
1336
+ for (let i = 0; i < aLength; i++) {
1337
+ if (!aChildren[i].isEqualNode(bChildren[i])) {
1338
+ return false;
1339
+ }
1340
+ }
1341
+ return true;
1342
+ };
1343
+
1344
+ /**
1345
+ * Synchronizes attributes and key DOM properties (value, checked, selected).
1346
+ * Assumes tagName is already the same.
1347
+ * @param {Element} targetEl Target DOM element to update.
1348
+ * @param {Element} sourceEl Source DOM element to copy attributes from.
1349
+ * @private
1350
+ */
1351
+ const syncAttributes = (targetEl, sourceEl) => {
1352
+ // Add, update and remove attributes.
1353
+ const srcAttrs = sourceEl.attributes;
1354
+ const tgtAttrs = targetEl.attributes;
1355
+ const srcAttrNames = new Set();
1356
+
1357
+ for (let i = 0, l = srcAttrs.length; i < l; i++) {
1358
+ const { name, value } = srcAttrs[i];
1359
+ srcAttrNames.add(name);
1360
+ if (targetEl.getAttribute(name) !== value) targetEl.setAttribute(name, value);
1361
+ }
1362
+
1363
+ for (let i = tgtAttrs.length - 1; i >= 0; i--) {
1364
+ const { name } = tgtAttrs[i];
1365
+ if (!srcAttrNames.has(name)) targetEl.removeAttribute(name);
1366
+ }
1367
+ // Sync key DOM properties.
1368
+ for (let i = 0, l = SYNC_PROPS.length; i < l; i++) {
1369
+ const prop = SYNC_PROPS[i];
1370
+ if (prop in targetEl && targetEl[prop] !== sourceEl[prop]) {
1371
+ targetEl[prop] = sourceEl[prop];
1372
+ }
1373
+ }
1374
+ };
1375
+
1376
+ /**
1377
+ * Replaces all child nodes of targetEl with those from sourceEl.
1378
+ * Moves the nodes without cloning.
1379
+ * @param {Element} targetEl The element whose children will be replaced.
1380
+ * @param {Element} sourceEl The element providing new children.
1381
+ * @module
1382
+ * @private
1383
+ */
1384
+ const syncNodeContent = (targetEl, sourceEl) => {
1385
+ const children = Array.from(sourceEl.childNodes);
1386
+ targetEl.replaceChildren(...children);
1387
+ };
1388
+
1389
+ /**
1390
+ * Synchronizes the contents of one DOM node to match another.
1391
+ * It performs a shallow sync: tag name, attributes, properties, and child nodes.
1392
+ * If tag names or node types differ, it replaces the node entirely.
1393
+ * @param {Node} targetNode The node currently in the DOM to be updated.
1394
+ * @param {Node} sourceNode The reference node to sync from (not in the DOM).
1395
+ * @private
1396
+ */
1397
+ function syncNode(targetNode, sourceNode) {
1398
+ // Replace if node types are different (e.g. element vs text).
1399
+ if (targetNode.nodeType !== sourceNode.nodeType) {
1400
+ targetNode.replaceWith(sourceNode);
1401
+ return;
1402
+ }
1403
+ // Text node: sync value.
1404
+ if (targetNode.nodeType === Node.TEXT_NODE) {
1405
+ if (targetNode.nodeValue !== sourceNode.nodeValue) {
1406
+ targetNode.nodeValue = sourceNode.nodeValue;
1407
+ }
1408
+ return;
1409
+ }
1410
+ // Element node: check tag.
1411
+ if (targetNode.tagName !== sourceNode.tagName) {
1412
+ targetNode.replaceWith(sourceNode);
1413
+ return;
1414
+ }
1415
+ // Sync attributes and DOM properties.
1416
+ syncAttributes(targetNode, sourceNode);
1417
+ // Sync child nodes if necessary.
1418
+ if (!areChildNodesEqual(targetNode, sourceNode)) {
1419
+ syncNodeContent(targetNode, sourceNode);
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * Finds the first comment node whose text matches exactly the given `text`.
1425
+ * Uses manual DOM traversal. Skips entire subtrees if `shouldSkip(element)` returns true.
1426
+ * Starts searching from `startNode` if provided, otherwise from `root.firstChild`.
1427
+ * @param {Node} root - Root node or fragment that limits the search scope.
1428
+ * @param {string} text - Exact comment text to match.
1429
+ * @param {Function} [shouldSkip] - Function that receives an element and returns true if its subtree should be skipped.
1430
+ * @param {Node} [startNode] - Node to start searching from (defaults to root.firstChild).
1431
+ * @return {Comment|null} The first matching comment node, or null if not found.
1432
+ * @module
1433
+ * @private
1434
+ */
1435
+ function findComment(
1436
+ root,
1437
+ text,
1438
+ shouldSkip = () => false,
1439
+ startNode
1440
+ ) {
1441
+ let node = startNode || root.firstChild;
1442
+
1443
+ while (node) {
1444
+ // Check if current node is a comment with matching text.
1445
+ if (node.nodeType === Node.COMMENT_NODE && node.data.trim() === text) {
1446
+ return node;
1447
+ }
1448
+ // Descend into children if allowed and present.
1449
+ if (node.nodeType === Node.ELEMENT_NODE && !shouldSkip(node) && node.firstChild) {
1450
+ node = node.firstChild;
1451
+ continue;
1452
+ }
1453
+ // Move to next sibling, or climb up until a sibling is found.
1454
+ while (node && !node.nextSibling) {
1455
+ node = node.parentNode;
1456
+ if (!node || node === root) return null;
1457
+ }
1458
+ if (node) node = node.nextSibling;
1459
+ }
1460
+
1461
+ return null;
1462
+ }
1463
+
1464
+ /**
1465
+ * Interpolation reference for managing dynamic content between comment markers.
1466
+ * Handles the lifecycle of content that can change between renders, including
1467
+ * component recycling and DOM synchronization.
1468
+ * @param {Object} options The options object.
1469
+ * @param {Function} options.getStart Function that returns the start comment marker text.
1470
+ * @param {Function} options.getEnd Function that returns the end comment marker text.
1471
+ * @param {any} options.expression The expression to be evaluated for the interpolation.
1472
+ * @param {Function} options.isComponent Function that checks if an element is a component root element.
1473
+ * @param {Function} options.isElement Function that checks if an element has the Rasti data attribute.
1474
+ * @private
1475
+ */
1476
+ class Interpolation {
1477
+ constructor(options) {
1478
+ this.getStart = options.getStart;
1479
+ this.getEnd = options.getEnd;
1480
+ this.expression = options.expression;
1481
+ this.isComponent = options.isComponent;
1482
+ this.isElement = options.isElement;
1483
+ }
1484
+
1485
+ /**
1486
+ * Attach the interpolation reference to comment markers in the DOM.
1487
+ * Searches for start and end comment markers, skipping component subtrees.
1488
+ * @param {Node} parent The parent node to search in.
1489
+ */
1490
+ hydrate(parent) {
1491
+ const startComment = findComment(parent, this.getStart(), this.isComponent);
1492
+ const endComment = findComment(parent, this.getEnd(), this.isComponent, startComment);
1493
+
1494
+ this.ref = [
1495
+ startComment,
1496
+ endComment
1497
+ ];
1498
+ }
1499
+
1500
+ /**
1501
+ * Update the interpolation content with a new fragment.
1502
+ * Optimizes updates by syncing single non-component elements or replacing content entirely.
1503
+ * @param {DocumentFragment} fragment The new content fragment to insert.
1504
+ */
1505
+ update(fragment) {
1506
+ const [startComment, endComment] = this.ref;
1507
+
1508
+ const currentFirstElement = startComment.nextSibling;
1509
+ const currentSingleChildElement = currentFirstElement.nextSibling === endComment;
1510
+ const currentEmpty = currentFirstElement == endComment;
1511
+ const fragmentChildren = fragment.children;
1512
+
1513
+ if (currentSingleChildElement && fragmentChildren.length === 1 && !this.isElement(currentFirstElement)) {
1514
+ // There is a single child element that is not a component's root element. Sync node attributes and content.
1515
+ syncNode(currentFirstElement, fragmentChildren[0]);
1516
+ } else if (currentEmpty) {
1517
+ // Interpolation is empty. Insert the fragment.
1518
+ endComment.parentNode.insertBefore(fragment, endComment);
1519
+ } else {
1520
+ // Interpolation is not empty. Replace the interpolation content.
1521
+ const range = document.createRange();
1522
+ range.setStartAfter(startComment);
1523
+ range.setEndBefore(endComment);
1524
+ range.deleteContents();
1525
+ range.insertNode(fragment);
1526
+ }
1527
+ }
1528
+ }
1529
+
1530
+ /**
1531
+ * Flatten an array recursively.
1532
+ * @param {Array} arr Array to flat recursively
1533
+ * @return {Array} Flat array
1534
+ * @module
1535
+ * @private
1536
+ */
1537
+ const deepFlat = (arr) => arr.reduce((acc, val) => {
1538
+ if (Array.isArray(val)) acc.push(...deepFlat(val));
1539
+ else acc.push(val);
1540
+ return acc;
1541
+ }, []);
1542
+
1543
+ /**
1544
+ * Parse HTML string to a DocumentFragment.
1545
+ * @param {string} html The HTML string to parse.
1546
+ * @return {DocumentFragment} The parsed DocumentFragment.
1547
+ * @module
1548
+ * @private
1549
+ */
1550
+ function parseHTML(html) {
1551
+ const fragment = document.createElement('template');
1552
+ fragment.innerHTML = `${html}`.trim();
1553
+ return fragment.content;
1554
+ }
1555
+
1556
+ /**
1557
+ * Generate HTML string from attributes object.
1558
+ * @param {Object} attributes Object containing attribute names and values.
1559
+ * @return {string} HTML string of attributes.
1560
+ * @module
1561
+ * @private
1562
+ */
1563
+ function getAttributesHTML(attributes) {
1564
+ const html = [];
1565
+
1566
+ Object.keys(attributes).forEach(key => {
1567
+ let value = attributes[key];
1568
+
1569
+ if (value === true) {
1570
+ html.push(key);
1571
+ } else if (value !== false) {
1572
+ if (value === null || typeof value === 'undefined') value = '';
1573
+ html.push(`${key}="${value}"`);
1574
+ }
1575
+ });
1576
+
1577
+ return html.join(' ');
1578
+ }
1579
+
1580
+ /**
1581
+ * Same as getResult, but pass context as argument to the expression.
1582
+ * Used to evaluate expressions in the context of a component.
1583
+ * @param {any} expression The expression to be evaluated.
1584
+ * @param {any} context The context to call the expression with.
1585
+ * @return {any} The result of the evaluated expression.
1586
+ * @private
1587
+ */
1588
+ const getExpressionResult = (expression, context) => getResult(expression, context, context);
1589
+
1590
+ /**
1591
+ * Check if an element is a component root element.
1592
+ * Component root elements have the data attribute ending with '-1'.
1593
+ * @param {Element} el The element to check.
1594
+ * @return {boolean} True if the element is a component root element.
1595
+ * @private
1596
+ */
1597
+ const isComponent = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT) && el.getAttribute(Component.ATTRIBUTE_ELEMENT).endsWith('-1');
1598
+
1599
+ /**
1600
+ * Check if an element has the Rasti data attribute.
1601
+ * This includes both component root elements and regular tracked elements.
1602
+ * @param {Element} el The element to check.
1603
+ * @return {boolean} True if the element has the data attribute.
1604
+ * @private
1605
+ */
1606
+ const isElement = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT);
1607
+
1608
+ /**
1609
+ * Generate string with placeholders for interpolated expressions.
1610
+ * @param {Array<string>} strings Array of strings.
1611
+ * @param {Array<any>} expressions Array of expressions.
1612
+ * @return {string} String with placeholders.
1613
+ * @private
1614
+ */
1615
+ const addPlaceholders = (strings, expressions) =>
1616
+ strings.reduce((out, string, i) => {
1617
+ // Add string part.
1618
+ out.push(string);
1619
+ // Add expression placeholders.
1620
+ if (typeof expressions[i] !== 'undefined') {
1621
+ out.push(Component.PLACEHOLDER(i));
1622
+ }
1623
+ return out;
1624
+ }, []).join('');
1625
+
1626
+ /**
1627
+ * Generate one dimensional array with strings and expressions.
1628
+ * @param main {string} The main template containing placeholders.
1629
+ * @param {Array<any>} expressions Array of expressions to replace placeholders.
1630
+ * @return {array} Array containing strings and expressions.
1631
+ * @private
1632
+ */
1633
+ const splitPlaceholders = (main, expressions) => {
1634
+ const PH = Component.PLACEHOLDER('(\\d+)');
1635
+ const regExp = new RegExp(`${PH}`, 'g');
1636
+ const out = [];
1637
+ let lastIndex = 0;
1638
+ let match;
1639
+ // Generate one dimensional array with strings and expressions,
1640
+ // so all the components are added as children by the parent component.
1641
+ while ((match = regExp.exec(main)) !== null) {
1642
+ const before = main.slice(lastIndex, match.index);
1643
+ out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
1644
+ lastIndex = match.index + match[0].length;
1645
+ }
1646
+ out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
1647
+
1648
+ return out;
1649
+ };
1650
+
1651
+ /**
1652
+ * Expand attributes.
1653
+ * @param {Array<Array<any>>} attributes Array of attributes as key, value pairs.
1654
+ * @param {Function} getExpressionResult Function to render expressions.
1655
+ * @return {object}
1656
+ * @property {object} all All attributes.
1657
+ * @property {object} events Event listeners.
1658
+ * @property {object} attributes Attributes.
1659
+ * @private
1660
+ */
1661
+ const expandAttributes = (attributes, getExpressionResult) => attributes.reduce((out, pair) => {
1662
+ const attribute = getExpressionResult(pair[0]);
1663
+ // Attribute without value.
1664
+ if (pair.length === 1) {
1665
+ if (typeof attribute === 'object') {
1666
+ // Expand objects as attributes.
1667
+ out = Object.assign(out, attribute);
1668
+ } else if (typeof attribute === 'string') {
1669
+ // Treat as boolean.
1670
+ out[attribute] = true;
1671
+ }
1672
+ } else {
1673
+ // Attribute with value.
1674
+ const value = pair[2] ? getExpressionResult(pair[1]) : pair[1];
1675
+ out[attribute] = value;
1676
+ }
1677
+
1678
+ return out;
1679
+ }, {});
1680
+
1681
+ /**
1682
+ * Expand events.
1683
+ * @param {object} attributes Attributes object.
1684
+ * @param {EventsManager} eventsManager Events manager.
1685
+ * @return {object} Attributes object.
1686
+ * @private
1687
+ */
1688
+ const expandEvents = (attributes, eventsManager) => {
1689
+ const out = {};
1690
+ Object.keys(attributes).forEach(key => {
1691
+ // Check if key is an event listener.
1692
+ const match = key.match(/on(([A-Z]{1}[a-z]+)+)/);
1693
+
1694
+ if (match && match[1]) {
1695
+ const type = match[1].toLowerCase();
1696
+ const listener = attributes[key];
1697
+ if (listener) {
1698
+ const index = eventsManager.addListener(listener, type);
1699
+ // Add event listener index.
1700
+ out[Component.ATTRIBUTE_EVENT(type)] = index;
1701
+ }
1702
+ } else {
1703
+ // Add attribute.
1704
+ out[key] = attributes[key];
1705
+ }
1706
+ });
1707
+ return out;
1708
+ };
1709
+
1710
+ /**
1711
+ * Replace component tags with expressions.
1712
+ * `<${Component} />` or `<${Component}></${Component}>` will be replaced
1713
+ * by a function that mounts the component.
1714
+ * Returns the template with component tags replaced by expressions placeholders
1715
+ * modifies the expressions array adding the mount functions.
1716
+ * @param main {string} The main template.
1717
+ * @param {Array<any>} expressions Array of expressions.
1718
+ * @return {string} The template with components tags replaced by expressions
1719
+ * placeholders.
1720
+ * @private
1721
+ */
1722
+ const expandComponents = (main, expressions) => {
1723
+ const PH = Component.PLACEHOLDER('(\\d+)');
1724
+ // Match component tags.
1725
+ return main.replace(
1726
+ new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</(${PH})>|<(${PH})([^>]*)/>`,'g'),
1727
+ (match, openTag, openIdx, nonVoidAttrs, inner, closeTag, closeIdx, selfClosingTag, selfClosingIdx, selfClosingAttrs) => {
1728
+ let tag, close, attributesStr;
1729
+
1730
+ if (openTag) {
1731
+ tag = typeof openIdx !== 'undefined' ? expressions[openIdx] : openTag;
1732
+ close = typeof closeIdx !== 'undefined' ? expressions[closeIdx] : closeTag;
1733
+ attributesStr = nonVoidAttrs;
1734
+ } else {
1735
+ tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
1736
+ attributesStr = selfClosingAttrs;
1737
+ }
1738
+ // No component found.
1739
+ if (!(tag.prototype instanceof Component)) return match;
1740
+
1741
+ let innerList;
1742
+ // Non void component.
1743
+ if (close) {
1744
+ // Close component tag must match open component tag.
1745
+ if (tag !== close) return match;
1746
+ // Process inner content same way as partial().
815
1747
  // Recursively expand inner components.
816
- const list = splitPlaceholders(expandComponents(inner, expressions), expressions);
817
- // Create renderChildren function.
818
- renderChildren = function() {
819
- return deepFlat(list.map(item => getExpressionResult(item, this)));
820
- };
1748
+ const innerTemplate = expandComponents(inner, expressions);
1749
+ // Parse partial elements to handle dynamic attributes and events.
1750
+ const parsedInner = parsePartialElements(innerTemplate, expressions);
1751
+ // Split into items.
1752
+ innerList = splitPlaceholders(parsedInner, expressions);
821
1753
  }
1754
+ // Parse attributes.
1755
+ const attributes = parseAttributes(attributesStr, expressions);
822
1756
  // Create mount function.
823
1757
  const mount = function() {
824
- const options = expandAttributes(attributes, value => getExpressionResult(value, this)).all;
825
- // Add renderChildren function to options.
826
- if (renderChildren) options.renderChildren = renderChildren.bind(this);
1758
+ const options = expandAttributes(attributes, value => getExpressionResult(value, this));
1759
+ // Add `renderChildren` function to options.
1760
+ if (innerList) {
1761
+ // Evaluate items in parent context and create Partial.
1762
+ options.renderChildren = () => new Partial(innerList.map(item => getExpressionResult(item, this)));
1763
+ }
827
1764
  // Mount component.
828
1765
  return tag.mount(options);
829
1766
  };
830
1767
  // Add mount function to expression.
831
1768
  expressions.push(mount);
832
1769
  // Replace whole string with expression placeholder.
833
- return Component.PLACEHOLDER_EXPRESSION(expressions.length - 1);
1770
+ return Component.PLACEHOLDER(expressions.length - 1);
834
1771
  }
835
1772
  );
836
1773
  };
837
1774
 
838
- /*
839
- * Parse match data to get tag, attributes, inner html and close tag.
840
- * @param match {array}
841
- * @return {object}
842
- * @property {string} tag The tag.
843
- * @property {string} inner The inner html.
844
- * @property {string} close The closing tag.
845
- * @property {array} attributes Array of attributes as key, value pairs.
846
- * @property {string} raw The whole match.
1775
+ /**
1776
+ * Replace elements in template.
1777
+ * @param {string} template Template string.
1778
+ * @param {Function} replacer Replacer function.
1779
+ * @return {string} Template string with replaced elements.
1780
+ * @private
847
1781
  */
848
- const parseMatch = (match, expressions) => {
849
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
1782
+ const replaceElements = (template, replacer) => {
1783
+ const PH = Component.PLACEHOLDER('(?:\\d+)');
1784
+ return template.replace(
1785
+ new RegExp(`<(${PH}|[a-z]+[1-6]?)(?:\\s*)((?:"[^"]*"|'[^']*'|[^>])*)(/?>)`, 'gi'),
1786
+ replacer
1787
+ );
1788
+ };
850
1789
 
851
- const [all, openTag, openIdx, nonVoidAttrs, inner, closeTag, closeIdx,
852
- selfClosingTag, selfClosingIdx, selfClosingAttrs] = match;
1790
+ /**
1791
+ * Parse all HTML elements in template and extract their attributes.
1792
+ * @param {string} template Template string with placeholders.
1793
+ * @param {Array} expressions Array of expressions.
1794
+ * @param {Array} elements Array to store element references.
1795
+ * @return {string} Template with parsed attributes.
1796
+ * @throws {SyntaxError} If the template does not have a single root element or is a container component.
1797
+ * @private
1798
+ */
1799
+ const parseElements = (template, expressions, elements) => {
1800
+ const PH = Component.PLACEHOLDER('(?:\\d+)');
1801
+ // Check if template is a container (single placeholder, no tag).
1802
+ const containerMatch = template.match(new RegExp(`^\\s*${PH}\\s*$`));
1803
+ if (containerMatch) return template;
1804
+ // Validate that template has a root element.
1805
+ const rootElementMatch = template.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`));
1806
+ if (!rootElementMatch) throw new SyntaxError(`Template must have a single root element or be a container component: "${template.trim()}"`);
1807
+
1808
+ let elementUid = 0;
1809
+ // Match all HTML elements including placeholders and self-closed elements.
1810
+ return replaceElements(template, (match, tag, attributesStr, ending) => {
1811
+ const isRoot = elementUid === 0;
1812
+ const currentElementUid = ++elementUid;
1813
+ // If there are no dynamic attributes, return original match.
1814
+ if (!isRoot && !attributesStr.match(new RegExp(PH))) {
1815
+ return match;
1816
+ }
1817
+ // Parse attributes.
1818
+ const parsedAttributes = parseAttributes(attributesStr, expressions);
1819
+ // Create element reference.
1820
+ const generateElementUid = componentUid => `${componentUid}-${currentElementUid}`;
1821
+ // Create function that returns attributes object.
1822
+ const getAttributes = function() {
1823
+ // Expand attributes and events.
1824
+ const attributes = expandEvents(
1825
+ expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
1826
+ this.eventsManager
1827
+ );
1828
+ // Extend template attributes with `options.attributes`.
1829
+ if (isRoot && this.attributes) {
1830
+ Object.assign(attributes, getResult(this.attributes, this));
1831
+ }
1832
+ // Add data attribute for element identification.
1833
+ // First element gets the component uid, others get element uid.
1834
+ attributes[Component.ATTRIBUTE_ELEMENT] = generateElementUid(this.uid);
1835
+
1836
+ return attributes;
1837
+ };
1838
+
1839
+ const getSelector = function() {
1840
+ return `[${Component.ATTRIBUTE_ELEMENT}="${generateElementUid(this.uid)}"]`;
1841
+ };
1842
+ // Add element reference to elements array.
1843
+ elements.push({
1844
+ getSelector,
1845
+ getAttributes,
1846
+ });
1847
+ // Add new expression to expressions array.
1848
+ expressions.push(function() {
1849
+ const attributes = getAttributes.call(this);
1850
+ return Component.markAsSafeHTML(getAttributesHTML(attributes));
1851
+ });
1852
+ // Replace attributes with placeholder.
1853
+ const placeholder = Component.PLACEHOLDER(expressions.length - 1);
1854
+ // Preserve original tag ending (> or />)
1855
+ return `<${tag} ${placeholder}${ending}`;
1856
+ });
1857
+ };
853
1858
 
854
- const data = { raw : all, attributes : [] };
1859
+ /**
1860
+ * Parse elements in partial template.
1861
+ * @param {string} template Template string with placeholders.
1862
+ * @param {Array} expressions Array of expressions.
1863
+ * @return {string} Template with parsed attributes.
1864
+ * @private
1865
+ */
1866
+ const parsePartialElements = (template, expressions) => {
1867
+ const PH = Component.PLACEHOLDER('(?:\\d+)');
1868
+ // Match all HTML elements including placeholders and self-closed elements.
1869
+ return replaceElements(template, (match, tag, attributesStr, ending) => {
1870
+ // If there are no dynamic attributes, return original match.
1871
+ if (!attributesStr.match(new RegExp(PH))) {
1872
+ return match;
1873
+ }
1874
+ // Parse attributes.
1875
+ const parsedAttributes = parseAttributes(attributesStr, expressions);
1876
+ // Create function that returns attributes object.
1877
+ const getAttributes = function() {
1878
+ const attributes = expandEvents(
1879
+ expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
1880
+ this.eventsManager
1881
+ );
1882
+
1883
+ return attributes;
1884
+ };
1885
+ // Add new expression to expressions array.
1886
+ expressions.push(function() {
1887
+ const attributes = getAttributes.call(this);
1888
+ return Component.markAsSafeHTML(getAttributesHTML(attributes));
1889
+ });
1890
+ // Replace attributes with placeholder.
1891
+ const placeholder = Component.PLACEHOLDER(expressions.length - 1);
1892
+ // Preserve original tag ending (> or />)
1893
+ return `<${tag} ${placeholder}${ending}`;
1894
+ });
1895
+ };
855
1896
 
856
- let attributesStr;
857
- if (openTag) {
858
- // Non void element.
859
- data.tag = typeof openIdx !== 'undefined' ? expressions[openIdx] : openTag;
860
- data.inner = inner;
861
- data.close = typeof closeIdx !== 'undefined' ? expressions[closeIdx] : closeTag;
862
- attributesStr = nonVoidAttrs;
863
- } else {
864
- // Self closing element.
865
- data.tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
866
- attributesStr = selfClosingAttrs;
867
- }
868
- // Parse attributes.
1897
+ /**
1898
+ * Parse all interpolations in template text content.
1899
+ * @param {string} template Template string with placeholders.
1900
+ * @param {Array} expressions Array of expressions.
1901
+ * @param {Array} interpolations Array to store interpolation references.
1902
+ * @return {string} Template with interpolation markers.
1903
+ * @private
1904
+ */
1905
+ const parseInterpolations = (template, expressions, interpolations) => {
1906
+ const PH = Component.PLACEHOLDER('(\\d+)');
1907
+ let interpolationUid = 0;
1908
+ // Match all expression placeholders.
1909
+ return template.replace(
1910
+ new RegExp(PH, 'g'),
1911
+ function(match, expressionIndex, offset) {
1912
+ // Check if this placeholder is inside an element tag (attribute).
1913
+ // `offset` is the index of the match in the original string.
1914
+ const beforeMatch = template.substring(0, offset);
1915
+ const lastOpenTag = beforeMatch.lastIndexOf('<');
1916
+ const lastCloseTag = beforeMatch.lastIndexOf('>');
1917
+ // If we're inside an element tag, don't process as interpolation.
1918
+ if (lastOpenTag > lastCloseTag) {
1919
+ return match;
1920
+ }
1921
+
1922
+ const currentInterpolationUid = ++interpolationUid;
1923
+
1924
+ function getStart() {
1925
+ return Component.MARKER_START(`${this.uid}-${currentInterpolationUid}`);
1926
+ }
1927
+ function getEnd() {
1928
+ return Component.MARKER_END(`${this.uid}-${currentInterpolationUid}`);
1929
+ }
1930
+ // Add interpolation reference to interpolations array.
1931
+ interpolations.push({
1932
+ getStart,
1933
+ getEnd,
1934
+ expression : expressions[expressionIndex]
1935
+ });
1936
+ // Add new expression to expressions array.
1937
+ expressions.push(function() {
1938
+ const result = getExpressionResult(expressions[expressionIndex], this);
1939
+ const uid = `${this.uid}-${currentInterpolationUid}`;
1940
+ return new InterpolationWrapper(uid, result);
1941
+ });
1942
+ // Replace with new placeholder.
1943
+ return Component.PLACEHOLDER(expressions.length - 1);
1944
+ }
1945
+ );
1946
+ };
1947
+
1948
+ /**
1949
+ * Parse attributes string to extract dynamic attributes.
1950
+ * @param {string} attributesStr Attributes string from HTML element.
1951
+ * @param {Array} expressions Array of expressions.
1952
+ * @return {Array} Array of attribute pairs [key, value] or [key, value, hasQuotes].
1953
+ * @private
1954
+ */
1955
+ const parseAttributes = (attributesStr, expressions) => {
1956
+ const PH = Component.PLACEHOLDER('(\\d+)');
1957
+ const attributes = [];
1958
+ // Parse attributes string with support for placeholders in both names and values.
869
1959
  const regExp = new RegExp(`(${PH}|[\\w-]+)(?:=(["']?)(?:${PH}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`, 'g');
870
1960
 
871
1961
  let attributeMatch;
872
1962
  while ((attributeMatch = regExp.exec(attributesStr)) !== null) {
873
- const [, attribute, attributeIdx,, valueIdx, value] = attributeMatch;
1963
+ const [, attribute, attributeIdx, quotes, valueIdx, value] = attributeMatch;
874
1964
 
875
- const attr = typeof attributeIdx !== 'undefined' ? expressions[attributeIdx] : attribute;
876
- const val = typeof valueIdx !== 'undefined' ? expressions[valueIdx] : value;
1965
+ const attr = typeof attributeIdx !== 'undefined' ? expressions[parseInt(attributeIdx, 10)] : attribute;
1966
+ const val = typeof valueIdx !== 'undefined' ? expressions[parseInt(valueIdx, 10)] : value;
877
1967
 
878
1968
  if (typeof val !== 'undefined') {
879
- data.attributes.push([attr, val]);
1969
+ attributes.push([attr, val, !!quotes]);
880
1970
  } else {
881
- data.attributes.push([attr]);
1971
+ attributes.push([attr]);
882
1972
  }
883
1973
  }
884
1974
 
885
- return data;
886
- };
887
-
888
- /*
889
- * HTML tags that are self closing.
890
- */
891
- const selfClosingTags = {
892
- area : true, base : true, br : true, col : true, embed : true, hr : true,
893
- img : true, input : true, link : true, meta : true, source : true, track : true, wbr : true
1975
+ return attributes;
894
1976
  };
895
1977
 
896
1978
  /*
897
1979
  * These option keys will be extended on the component instance.
898
1980
  */
899
- const componentOptions = {
900
- key : true,
901
- state : true,
902
- onCreate : true,
903
- onChange : true,
904
- onRender : true
905
- };
1981
+ const componentOptions = ['key', 'state', 'onCreate', 'onChange', 'onHydrate', 'onRecycle', 'onUpdate'];
906
1982
 
907
1983
  /**
908
- * Components are a special kind of `View` that is designed to be easily composable,
909
- * making it simple to add child views and build complex user interfaces.
910
- * Unlike views, which are render-agnostic, components have a specific set of rendering
911
- * guidelines that allow for a more declarative development style.
912
- * Components are defined with the {@link #module_component_create Component.create} static method, which takes a tagged template string or a function that returns another component.
913
- * @module
914
- * @extends Rasti.View
915
- * @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onRender, onCreate, onChange.
916
- * @property {string} key A unique key to identify the component. Used to recycle child components.
917
- * @property {object} model A `Rasti.Model` or any emitter object containing data and business logic. The component will listen to `change` events and call `onChange` lifecycle method.
918
- * @property {object} state A `Rasti.Model` or any emitter object containing data and business logic, to be used as internal state. The component will listen to `change` events and call `onChange` lifecycle method.
919
- * @see {@link #module_component_create Component.create}
920
- * @example
921
- * import { Component, Model } from 'rasti';
922
- * // Create Timer component.
923
- * const Timer = Component.create`
924
- * <div>
925
- * Seconds: <span>${({ model }) => model.seconds}</span>
926
- * </div>
927
- * `;
928
- * // Create model to store seconds.
929
- * const model = new Model({ seconds: 0 });
930
- * // Mount timer on body.
931
- * Timer.mount({ model }, document.body);
932
- * // Increment `model.seconds` every second.
933
- * setInterval(() => model.seconds++, 1000);
1984
+ * @lends module:Component
934
1985
  */
935
1986
  class Component extends View {
936
1987
  constructor(options = {}) {
937
1988
  super(...arguments);
938
- // Extend "this" with options, mapping componentOptions keys.
1989
+ this.componentOptions = [];
1990
+ // Extend "this" with options.
1991
+ componentOptions.forEach(key => {
1992
+ if (key in options) {
1993
+ this[key] = options[key];
1994
+ this.componentOptions.push(key);
1995
+ }
1996
+ });
1997
+ // Extract props from options that aren't component or view options.
1998
+ const props = {};
939
1999
  Object.keys(options).forEach(key => {
940
- if (componentOptions[key]) this[key] = options[key];
2000
+ if (!this.viewOptions.includes(key) && !this.componentOptions.includes(key)) {
2001
+ props[key] = options[key];
2002
+ }
941
2003
  });
2004
+ // Store props as Model for reactive updates.
2005
+ this.props = new Model(props);
942
2006
  // Store options by default.
943
2007
  this.options = options;
944
2008
  // Bind `partial` method to `this`.
945
2009
  this.partial = this.partial.bind(this);
2010
+ // Bind `onChange` method to `this`.
2011
+ this.onChange = this.onChange.bind(this);
946
2012
  // Call lifecycle method.
947
2013
  this.onCreate.apply(this, arguments);
948
2014
  }
949
2015
 
950
2016
  /**
951
- * Listen to `change` event on a model or emitter object and call `onChange` lifecycle method.
952
- * The listener will be removed when the component is destroyed.
953
- * By default the component will be subscribed to `this.model` and `this.state`.
954
- * @param {Rasti.Model} model A model or emitter object to listen to changes.
955
- * @return {Rasti.Component} The component instance.
2017
+ * Get events object for automatic event delegation, based on data attributes.
2018
+ * @return {object} The events object.
2019
+ * @private
956
2020
  */
957
- subscribe(model) {
958
- // Check if model has `on` method.
959
- if (!model.on) return;
960
- // Store bound onChange method.
961
- const onChange = this.onChange.bind(this);
962
- // Listen to model changes and store unbind function.
963
- const off = model.on('change', onChange);
964
- // Add unbind function to destroy queue.
965
- // So the component stops listening to model changes when destroyed.
966
- this.destroyQueue.push(
967
- // Rasti `on` method returns an unbind function.
968
- // But other libraries may return the object itself.
969
- typeof off === 'function' ? off : () => model.off('change', onChange)
970
- );
2021
+ events() {
2022
+ const events = {};
2023
+ // Create events object.
2024
+ this.eventsManager.types.forEach(type => {
2025
+ const dataAttribute = Component.ATTRIBUTE_EVENT(type);
2026
+ // Create a listener function that gets the listener index from the data attribute and calls the listener.
2027
+ const listener = function(event, component, matched) {
2028
+ // Get the listener index from the data attribute.
2029
+ const index = matched.getAttribute(dataAttribute);
2030
+ // Root element listener may not have a data attribute.
2031
+ if (index) {
2032
+ let currentListener = this.eventsManager.listeners[parseInt(index, 10)];
2033
+ if (typeof currentListener === 'string') currentListener = this[currentListener];
2034
+ validateListener(currentListener);
2035
+ // Call the listener.
2036
+ currentListener.call(this, event, component, matched);
2037
+ }
2038
+ };
2039
+ // Add an event listener to the events object for each event type, using the data attribute
2040
+ // as both a CSS selector and to store the listener's index.
2041
+ events[`${type} [${dataAttribute}]`] = listener;
2042
+ // Add an event listener to the events object for each event type that matches the root element.
2043
+ events[type] = listener;
2044
+ });
971
2045
 
2046
+ return events;
2047
+ }
2048
+
2049
+ /**
2050
+ * Subscribes to a `change` event on a model or emitter object and invokes the `onChange` lifecycle method.
2051
+ * The subscription is automatically cleaned up when the component is destroyed.
2052
+ * By default, the component subscribes to changes on `this.model`, `this.state`, and `this.props`.
2053
+ *
2054
+ * @param {Object} model - The model or emitter object to listen to.
2055
+ * @param {string} [type='change'] - The event type to listen for.
2056
+ * @param {Function} [listener=this.onChange] - The callback to invoke when the event is emitted.
2057
+ * @returns {Component} The current component instance for chaining.
2058
+ */
2059
+ subscribe(model, type = 'change', listener = this.onChange) {
2060
+ // Check if model has `on` method.
2061
+ if (model.on) this.listenTo(model, type, listener);
972
2062
  return this;
973
2063
  }
974
2064
 
975
- /*
976
- * Tell if component is a container.
2065
+ /**
2066
+ * Tell if `Component` is a container.
977
2067
  * In which case, it will not have an element by itself.
978
2068
  * It will render a single expression which is expected to return a single component as child.
979
2069
  * `this.el` will be a reference to that child component's element.
980
2070
  * @return {boolean}
2071
+ * @private
981
2072
  */
982
2073
  isContainer() {
983
- return !!(!this.tag && this.template);
2074
+ return this.template.elements.length === 0 && this.template.interpolations.length === 1;
984
2075
  }
985
2076
 
986
- /*
987
- * Override. We don't want to ensure an element on instantiation.
2077
+ /**
2078
+ * Override super method. We don't want to ensure an element on instantiation.
988
2079
  * We will provide it later.
2080
+ * @private
989
2081
  */
990
2082
  ensureElement() {
2083
+ // Store data event listeners.
2084
+ this.eventsManager = new EventsManager();
2085
+ // Store position tracking for recycling.
2086
+ this.pathManager = new PathManager();
2087
+ // Call template function.
2088
+ this.template = getResult(this.template, this);
991
2089
  // If el is provided, delegate events.
992
2090
  if (this.el) {
993
2091
  // If "this.el" is a function, call it to get the element.
994
2092
  this.el = getResult(this.el, this);
995
- this.delegateEvents();
2093
+ // Render the component as a string to generate children components.
2094
+ this.toString();
2095
+ // Hydrate the component.
2096
+ this.hydrate(this.el.parentNode);
996
2097
  }
997
2098
  }
998
2099
 
999
- /*
1000
- * Find view's element on parent node, using its data attribute.
1001
- * @param parent {node} The parent node.
1002
- * @return {node} The component's element.
1003
- */
1004
- findElement(parent) {
1005
- return (parent || document).querySelector(`[${Component.DATA_ATTRIBUTE_UID}="${this.uid}"]`);
1006
- }
1007
-
1008
- /*
1009
- * Eval attributes expressions.
1010
- * @return {object} Object containing add, remove and html properties.
1011
- */
1012
- getAttributes() {
1013
- const add = {};
1014
- const remove = {};
1015
- const html = [];
1016
-
1017
- const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
1018
-
1019
- if (this.attributes) Object.assign(attributes, getResult(this.attributes, this));
1020
- // Store previous attributes.
1021
- const previousAttributes = this.previousAttributes || {};
1022
- this.previousAttributes = attributes;
1023
-
1024
- Object.keys(attributes).forEach(key => {
1025
- let value = attributes[key];
1026
- // Transform bool attribute values
1027
- if (value === false) {
1028
- remove[key] = true;
1029
- } else if (value === true) {
1030
- add[key] = '';
1031
- html.push(key);
1032
- } else {
1033
- if (value === null || typeof value === 'undefined') value = '';
1034
-
1035
- add[key] = value;
1036
- html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
1037
- }
1038
- });
1039
- // Remove attributes that were in previousAttributes but not in current attributes.
1040
- Object.keys(previousAttributes).forEach(key => {
1041
- if (!(key in attributes)) {
1042
- remove[key] = true;
1043
- }
1044
- });
1045
-
1046
- return { add, remove, html : html.join(' ') };
1047
- }
1048
-
1049
- /*
2100
+ /**
1050
2101
  * Used internally on the render process.
1051
- * Attach the view to the dom element.
2102
+ * Attach the `Component` to the dom element providing `this.el`, delegate events,
2103
+ * subscribe to model changes and call `onHydrate` lifecycle method.
1052
2104
  * @param parent {node} The parent node.
1053
- * @return {Rasti.Component} The component instance.
2105
+ * @return {Component} The component instance.
2106
+ * @private
1054
2107
  */
1055
2108
  hydrate(parent) {
1056
- // Listen to model changes and call onChange.
1057
- if (this.model) this.subscribe(this.model);
1058
- // Listen to state changes and call onChange.
1059
- if (this.state) this.subscribe(this.state);
2109
+ ['model', 'state', 'props'].forEach(key => {
2110
+ if (this[key]) this.subscribe(this[key]);
2111
+ });
1060
2112
 
1061
- if (!this.isContainer()) {
1062
- this.el = this.findElement(parent);
1063
- this.delegateEvents();
1064
- this.children.forEach(child => child.hydrate(this.el));
1065
- } else {
2113
+ if (this.isContainer()) {
2114
+ // Get references for interpolation marker comments
2115
+ this.template.interpolations[0].hydrate(parent);
2116
+ // Call hydrate on children.
1066
2117
  this.children[0].hydrate(parent);
2118
+ // Set the first element as the component's element.
1067
2119
  this.el = this.children[0].el;
2120
+ } else {
2121
+ // Search for every element in template using getSelector
2122
+ this.template.elements.forEach((element, index) => {
2123
+ if (index === 0) {
2124
+ element.hydrate(parent);
2125
+ if (this.el) element.ref = this.el;
2126
+ else this.el = element.ref;
2127
+ }
2128
+ else {
2129
+ element.hydrate(this.el);
2130
+ }
2131
+ });
2132
+ // Delegate events.
2133
+ this.delegateEvents();
2134
+ // Get references for interpolation marker comments
2135
+ this.template.interpolations.forEach(interpolation => interpolation.hydrate(this.el));
2136
+ this.children.forEach(child => child.hydrate(this.el));
1068
2137
  }
1069
- // Call `onRender` lifecycle method.
1070
- this.onRender.call(this, Component.RENDER_TYPE_HYDRATE);
2138
+ // Call `onHydrate` lifecycle method.
2139
+ this.onHydrate.call(this);
1071
2140
  // Return `this` for chaining.
1072
2141
  return this;
1073
2142
  }
1074
2143
 
1075
- /*
1076
- * Used internally in the render process.
1077
- * Reuse a view that has `key` when its parent is rendered.
1078
- * @param parent {node} The parent node.
1079
- * @return {Rasti.Component} The component instance.
2144
+ /**
2145
+ * Get a `comment` marker with same data attribute as this component.
2146
+ * Used to replace the component when it is recycled.
2147
+ * @return {string} The recycle placeholder.
2148
+ * @private
1080
2149
  */
1081
- recycle(parent) {
1082
- // If component is a container, call recycle on its child.
1083
- if (this.isContainer()) return this.children[0].recycle(parent);
1084
- // Find placeholder element to be replaced. It has same data attribute as this component.
1085
- const toBeReplaced = this.findElement(parent);
1086
- // Replace it with this.el.
1087
- toBeReplaced.replaceWith(this.el);
1088
- // Call `onRender` lifecycle method.
1089
- this.onRender.call(this, Component.RENDER_TYPE_RECYCLE);
1090
- // Return `this` for chaining.
1091
- return this;
2150
+ getRecycledMarker() {
2151
+ return `<!--${Component.MARKER_RECYCLED(this.uid)}-->`;
1092
2152
  }
1093
2153
 
1094
- /*
1095
- * Override. Add some custom logic to super `destroy` method.
1096
- * @param {object} options Options object or any arguments passed to `destroy` method will be passed to `onDestroy` method.
2154
+ /**
2155
+ * Get the component nodes to be inserted into the DOM.
2156
+ * Used internally during the render process, you usually don't need to call it if you use
2157
+ * `mount()`.
2158
+ * For components that render HTML elements you can safely rely on `this.el`.
2159
+ * For container components that render another component you need the wrapper nodes to insert
2160
+ * them into the DOM; use `getNodes()` for that.
2161
+ * @return {Node[]} The component nodes.
1097
2162
  */
1098
- destroy() {
1099
- super.destroy.apply(this, arguments);
1100
- // Set destroyed flag to prevent a last render after destroyed.
1101
- this.destroyed = true;
2163
+ getNodes() {
2164
+ return this.isContainer() ?
2165
+ [this.template.interpolations[0].ref[0], ...this.children[0].getNodes(), this.template.interpolations[0].ref[1]] :
2166
+ [this.el];
2167
+ }
2168
+
2169
+ /**
2170
+ * Used internally on the render process.
2171
+ * Reuse a `Component` by replacing the placeholder comment with the real nodes.
2172
+ * Call `onRecycle` lifecycle method.
2173
+ * @param parent {node} The parent node.
2174
+ * @return {Component} The component instance.
2175
+ * @private
2176
+ */
2177
+ recycle(parent) {
2178
+ // Locate the placeholder comment and replace it with the real nodes
2179
+ const toBeReplaced = findComment(parent, Component.MARKER_RECYCLED(this.uid), isComponent);
2180
+ // Replace it with this.el.
2181
+ toBeReplaced.replaceWith(...this.getNodes());
2182
+ // Call `onRecycle` lifecycle method.
2183
+ this.onRecycle.call(this);
1102
2184
  // Return `this` for chaining.
1103
2185
  return this;
1104
2186
  }
@@ -1124,13 +2206,22 @@
1124
2206
  }
1125
2207
 
1126
2208
  /**
1127
- * Lifecycle method. Called when the view is rendered.
1128
- * @param type {string} The render type. Can be `render`, `hydrate` or `recycle`.
2209
+ * Lifecycle method. Called when the component is rendered for the first time and hydrated.
1129
2210
  */
1130
- onRender() {}
2211
+ onHydrate() {}
1131
2212
 
1132
2213
  /**
1133
- * Lifecycle method. Called when the view is destroyed.
2214
+ * Lifecycle method. Called when the component is recycled (reused with the same key) and added to the DOM again.
2215
+ */
2216
+ onRecycle() {}
2217
+
2218
+ /**
2219
+ * Lifecycle method. Called when the component is updated or re-rendered.
2220
+ */
2221
+ onUpdate() {}
2222
+
2223
+ /**
2224
+ * Lifecycle method. Called when the component is destroyed.
1134
2225
  * @param {object} options Options object or any arguments passed to `destroy` method.
1135
2226
  */
1136
2227
  onDestroy() {}
@@ -1138,18 +2229,18 @@
1138
2229
  /**
1139
2230
  * Tagged template helper method.
1140
2231
  * Used to create a partial template.
1141
- * It will return a one-dimensional array with strings and expressions.
2232
+ * It will return a Partial object that preserves structure for position-based recycling.
1142
2233
  * Components will be added as children by the parent component. Template strings literals
1143
2234
  * will be marked as safe HTML to be rendered.
1144
2235
  * This method is bound to the component instance by default.
1145
2236
  * @param {TemplateStringsArray} strings - Template strings.
1146
2237
  * @param {...any} expressions - Template expressions.
1147
- * @return {Array} Array containing strings and expressions.
2238
+ * @return {Partial} Partial object containing strings and expressions.
1148
2239
  * @example
1149
2240
  * import { Component } from 'rasti';
1150
2241
  * // Create a Title component.
1151
2242
  * const Title = Component.create`
1152
- * <h1>${self => self.renderChildren()}</h1>
2243
+ * <h1>${({ props }) => props.children}</h1>
1153
2244
  * `;
1154
2245
  * // Create Main component.
1155
2246
  * const Main = Component.create`
@@ -1169,156 +2260,204 @@
1169
2260
  * });
1170
2261
  */
1171
2262
  partial(strings, ...expressions) {
1172
- return deepFlat(
1173
- splitPlaceholders(
1174
- expandComponents(addPlaceholders(strings, expressions), expressions), expressions
1175
- ).map(item => getExpressionResult(item, this))
1176
- );
2263
+ const items = splitPlaceholders(
2264
+ parsePartialElements(
2265
+ expandComponents(
2266
+ addPlaceholders(strings, expressions),
2267
+ expressions
2268
+ ),
2269
+ expressions
2270
+ ),
2271
+ expressions
2272
+ ).map(item => getExpressionResult(item, this));
2273
+
2274
+ return new Partial(items);
1177
2275
  }
1178
2276
 
1179
- getRecyclePlaceholder() {
1180
- if (this.isContainer()) return this.children[0].getRecyclePlaceholder();
1181
-
1182
- const tag = getResult(this.tag, this) || 'div';
1183
- const attributes = `${Component.DATA_ATTRIBUTE_UID}="${this.uid}"`;
2277
+ /**
2278
+ * Render a template part.
2279
+ * @param {any} part - The template part.
2280
+ * @param {function} addChild - The addChild function.
2281
+ * @return {string} The rendered template part.
2282
+ * @private
2283
+ */
2284
+ renderTemplatePart(part, addChild) {
2285
+ const result = getExpressionResult(part, this);
2286
+
2287
+ const parse = item => {
2288
+ if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
2289
+ if (item instanceof SafeHTML) return item;
2290
+ if (item instanceof Component) return addChild(item);
2291
+
2292
+ if (item instanceof Partial) {
2293
+ this.pathManager.push();
2294
+ const out = item.items.map(subItem => { this.pathManager.increment(); return parse(subItem); }).join('');
2295
+ this.pathManager.pop();
2296
+ return out;
2297
+ }
2298
+ // Handle arrays (user loops) - disable tracking.
2299
+ if (Array.isArray(item)) {
2300
+ this.pathManager.pause();
2301
+ const out = deepFlat(item).map(parse).join('');
2302
+ this.pathManager.resume();
2303
+ return out;
2304
+ }
2305
+ // InterpolationWrapper: add markers and process maintaining tracking.
2306
+ if (item instanceof InterpolationWrapper) {
2307
+ this.pathManager.increment();
2308
+ const startMarker = `<!--${Component.MARKER_START(item.interpolationUid)}-->`;
2309
+ const endMarker = `<!--${Component.MARKER_END(item.interpolationUid)}-->`;
2310
+ return `${startMarker}${parse(item.result)}${endMarker}`;
2311
+ }
1184
2312
 
1185
- return this.template || !selfClosingTags[tag] ?
1186
- `<${tag} ${attributes}></${tag}>` :
1187
- `<${tag} ${attributes} />`;
2313
+ return Component.sanitize(item);
2314
+ }
2315
+ // Return empty string if item is undefined, null, false, or true.
2316
+ return '';
2317
+ };
2318
+
2319
+ return `${parse(result)}`;
1188
2320
  }
1189
2321
 
1190
- /*
1191
- * Treat the whole view as a HTML string.
2322
+ /**
2323
+ * Render the component as a string.
2324
+ * Used internally on the render process.
2325
+ * Use it for server-side rendering or static site generation.
2326
+ * @return {string} The rendered component.
1192
2327
  */
1193
2328
  toString() {
1194
2329
  // Normally there won't be any children, but if there are, destroy them.
1195
2330
  this.destroyChildren();
1196
- // Container.
1197
- if (this.isContainer()) return this.template.call(this, this.addChild.bind(this));
1198
- // Get tag name.
1199
- const tag = getResult(this.tag, this) || 'div';
1200
- // Get attributes.
1201
- const attributes = this.getAttributes().html;
1202
- // Replace expressions of inner template.
1203
- const inner = this.template ? this.template.call(this, this.addChild.bind(this)) : '';
1204
- // Generate outer template.
1205
- return this.template || !selfClosingTags[tag] ?
1206
- `<${tag} ${attributes}>${inner}</${tag}>` :
1207
- `<${tag} ${attributes} />`;
1208
- }
1209
-
1210
- /*
1211
- * View render method.
2331
+ // Normally there won't be any data event listeners, but if there are, clear them.
2332
+ this.eventsManager.reset();
2333
+ // Reset position tracking.
2334
+ this.pathManager.reset();
2335
+ // Bind addChild method.
2336
+ const addChild = component => {
2337
+ this.pathManager.track(component);
2338
+ return this.addChild(component);
2339
+ };
2340
+ // Render the template parts.
2341
+ return this.template.parts
2342
+ .map(part => this.renderTemplatePart(part, addChild))
2343
+ .join('');
2344
+ }
2345
+
2346
+ /**
2347
+ * Render the `Component`.
2348
+ * - If `this.el` is not present, the `Component` will be rendered as a string inside a `DocumentFragment` and hydrated, making `this.el` available. The `onHydrate` lifecycle method will be called.
2349
+ * - If `this.el` is present, the method will update the attributes and inner HTML of the element, or recreate its child component in the case of a container. The `onUpdate` lifecycle method will be called.
2350
+ * - When rendering child components, recycling happens in two ways:
2351
+ * - Components with a `key` are recycled if a previous child with the same key exists.
2352
+ * - Unkeyed components are recycled if they have the same type and position in the template or partial.
2353
+ * A recycled `Component` will call the `onRecycle` lifecycle method.
2354
+ * - If the active element is inside the component, it will retain focus after the render.
2355
+ * @return {Component} The component instance.
1212
2356
  */
1213
2357
  render() {
1214
2358
  // Prevent a last re render if view is already destroyed.
1215
2359
  if (this.destroyed) return this;
1216
-
1217
- if (!this.isContainer()) {
1218
- // If `this.el` is not present, render the view as a string and hydrate it.
1219
- if (!this.el) {
1220
- const fragment = this.createElement('template');
1221
- fragment.innerHTML = this;
1222
- this.hydrate(fragment.content);
1223
- return this;
1224
- }
1225
- // Set `this.el` attributes.
1226
- const attributes = this.getAttributes();
1227
- // Remove attributes.
1228
- Object.keys(attributes.remove).forEach(key => {
1229
- this.el.removeAttribute(key);
1230
- });
1231
- // Add attributes.
1232
- Object.keys(attributes.add).forEach(key => {
1233
- this.el.setAttribute(key, attributes.add[key]);
1234
- });
2360
+ // If `this.el` is not present, render the view as a string and hydrate it.
2361
+ if (!this.el) {
2362
+ const fragment = parseHTML(this);
2363
+ this.hydrate(fragment);
2364
+ return this;
1235
2365
  }
1236
- // Check for `template` to see if view has innerHTML.
1237
- if (this.template) {
1238
- // Store active element.
1239
- const activeElement = document.activeElement;
1240
-
2366
+ // Clear event listeners.
2367
+ this.eventsManager.reset();
2368
+ // Reset position tracking.
2369
+ this.pathManager.reset();
2370
+ // Store active element.
2371
+ const activeElement = this.isContainer() ? null : document.activeElement;
2372
+ // Update elements.
2373
+ this.template.elements.forEach(element => element.update());
2374
+ // Store previous children.
2375
+ const previousChildren = this.children;
2376
+ // Clear current children.
2377
+ this.children = [];
2378
+ // Update interpolations.
2379
+ this.template.interpolations.forEach(interpolation => {
1241
2380
  const nextChildren = [];
1242
2381
  const recycledChildren = [];
1243
2382
 
1244
- const previousChildren = this.children;
1245
- this.children = [];
1246
- // Replace expressions. Set html inside of `this.el`.
1247
- const inner = this.template.call(this, component => {
2383
+ this.pathManager.increment();
2384
+
2385
+ const addChild = component => {
1248
2386
  let out = component;
1249
- // Check if child already exists.
1250
- const found = component.key && previousChildren.find(
1251
- previousChild => previousChild.key === component.key
1252
- );
2387
+ let found = null;
2388
+ // Check if child already exists by key.
2389
+ if (component.key) {
2390
+ found = previousChildren.find(prev => prev.key === component.key);
2391
+ } else {
2392
+ // Find by position and type.
2393
+ found = this.pathManager.findRecyclable(component.constructor);
2394
+ }
1253
2395
 
1254
2396
  if (found) {
1255
2397
  // If child already exists, replace it html by its root element.
1256
- out = found.getRecyclePlaceholder();
2398
+ out = found.getRecycledMarker();
1257
2399
  // Add child to recycled children.
1258
- recycledChildren.push(found);
1259
- // Destroy new child component. Use recycled one instead.
1260
- component.destroy();
2400
+ recycledChildren.push([found, component]);
2401
+ // Track the component.
2402
+ if (!found.key) this.pathManager.track(found);
1261
2403
  } else {
1262
- // Not found. Add new child component.
2404
+ // Add new component.
1263
2405
  nextChildren.push(component);
2406
+ // Track the component.
2407
+ this.pathManager.track(component);
1264
2408
  }
1265
- // Component html.
2409
+ // Return the component or placeholder.
1266
2410
  return out;
1267
- });
2411
+ };
1268
2412
 
1269
- if (this.isContainer()) {
1270
- if (nextChildren[0]) {
1271
- const fragment = this.createElement('template');
1272
- fragment.innerHTML = inner;
1273
- // Add new child to dom fragment and hydrate it.
1274
- this.addChild(nextChildren[0]).hydrate(fragment.content);
1275
- // Get next child element.
1276
- const nextEl = fragment.content.children[0];
1277
- // If `this.el` is present, replace it with nextEl.
1278
- if (this.el) this.el.replaceWith(nextEl);
1279
- // Set `this.el` to nextEl.
1280
- this.el = nextEl;
1281
- } else if (recycledChildren[0]) {
1282
- this.addChild(recycledChildren[0]);
1283
- } else {
1284
- throw new Error('Container component must have a child component');
1285
- }
1286
- } else {
1287
- this.el.innerHTML = inner;
1288
- // Add new children. Hydrate them.
1289
- nextChildren.forEach(nextChild => {
1290
- this.addChild(nextChild).hydrate(this.el);
1291
- });
1292
- // Replace children root elements with recycled components.
1293
- recycledChildren.forEach(recycledChild => {
1294
- this.addChild(recycledChild).recycle(this.el);
1295
- });
1296
- }
1297
- // Destroy unused children.
1298
- previousChildren.forEach(previousChild => {
1299
- const found = recycledChildren.indexOf(previousChild) > -1;
1300
- if (!found) previousChild.destroy();
2413
+ const fragment = parseHTML(this.renderTemplatePart(interpolation.expression, addChild));
2414
+ // Replace children root elements with recycled components.
2415
+ recycledChildren.forEach(([recycled, discarded]) => {
2416
+ this.addChild(recycled).recycle(fragment);
2417
+ // Update props.
2418
+ recycled.props.set(discarded.props.toJSON());
2419
+ // Destroy discarded component.
2420
+ discarded.destroy();
2421
+ });
2422
+ // Add new children. Hydrate them.
2423
+ nextChildren.forEach(child => {
2424
+ this.addChild(child).hydrate(fragment);
1301
2425
  });
1302
- // Restore focus.
1303
- if (this.el.contains(activeElement)) {
1304
- activeElement.focus();
2426
+
2427
+ interpolation.update(fragment);
2428
+ });
2429
+ // Destroy unused children.
2430
+ previousChildren.forEach(prev => {
2431
+ if (this.children.indexOf(prev) < 0) prev.destroy();
2432
+ });
2433
+ // If container, set el to the child element.
2434
+ if (this.isContainer()) {
2435
+ this.el = this.children[0].el;
2436
+ } else {
2437
+ // If there are pending event types, delegate events again.
2438
+ if (this.eventsManager.hasPendingTypes()) {
2439
+ this.delegateEvents();
1305
2440
  }
1306
2441
  }
1307
- // Call onRender lifecycle method.
1308
- this.onRender.call(this, Component.RENDER_TYPE_RENDER);
2442
+ // Restore focus.
2443
+ if (activeElement && this.el.contains(activeElement)) {
2444
+ activeElement.focus();
2445
+ }
2446
+ // Call onUpdate lifecycle method.
2447
+ this.onUpdate.call(this);
1309
2448
  // Return this for chaining.
1310
2449
  return this;
1311
2450
  }
1312
2451
 
1313
2452
  /**
1314
- * Mark a string as safe HTML to be rendered.
1315
- * Normally you don't need to use this method, as Rasti will automatically mark strings
1316
- * as safe HTML when the component is @link{#module_component_create created} and when
1317
- * using the @link{#module_component__partial Component.partial} method.
2453
+ * Mark a string as safe HTML to be rendered.
2454
+ * Normally you don't need to use this method, as Rasti will automatically mark string literals
2455
+ * as safe HTML when the component is {@link #module_component_create created} and when
2456
+ * using the {@link #module_component__partial Component.partial} method.
1318
2457
  * Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
1319
2458
  * @static
1320
2459
  * @param {string} value
1321
- * @return {Rasti.SafeHTML} A safe HTML object.
2460
+ * @return {SafeHTML} A safe HTML object.
1322
2461
  */
1323
2462
  static markAsSafeHTML(value) {
1324
2463
  return new SafeHTML(value);
@@ -1327,7 +2466,7 @@
1327
2466
  /**
1328
2467
  * Helper method used to extend a `Component`, creating a subclass.
1329
2468
  * @static
1330
- * @param {object} object Object containing methods to be added to the new `Component` subclass. Also can be a function that receives the parent prototype and returns an object.
2469
+ * @param {object|Function} object Object containing methods to be added to the new `Component` subclass. Also can be a function that receives the parent prototype and returns an object.
1331
2470
  */
1332
2471
  static extend(object) {
1333
2472
  const Current = this;
@@ -1348,27 +2487,28 @@
1348
2487
  * appends its element into the DOM (if `el` is provided).
1349
2488
  * And returns the view instance.
1350
2489
  * @static
1351
- * @param {object} options The view options.
1352
- * @param {node} el Dom element to append the view element.
1353
- * @param {boolean} hydrate If true, the view will hydrate existing DOM.
1354
- * @return {Rasti.Component}
2490
+ * @param {object} [options] The view options.
2491
+ * @param {node} [el] Dom element to append the view element.
2492
+ * @param {boolean} [hydrate] If true, the view will hydrate existing DOM.
2493
+ * @return {Component} The component instance.
1355
2494
  */
1356
2495
  static mount(options, el, hydrate) {
1357
- // Instantiate view.
1358
- const view = new this(options);
2496
+ // Instantiate component.
2497
+ const component = new this(options);
1359
2498
  // If `el` is passed, mount component.
1360
2499
  if (el) {
1361
2500
  if (hydrate) {
2501
+ // Generate subcomponents.
2502
+ component.toString();
1362
2503
  // Hydrate existing DOM.
1363
- view.toString();
1364
- view.hydrate(el);
2504
+ component.hydrate(el);
1365
2505
  } else {
1366
2506
  // Append element to the DOM.
1367
- el.appendChild(view.render().el);
2507
+ el.append(...component.render().getNodes());
1368
2508
  }
1369
2509
  }
1370
- // Return view instance.
1371
- return view;
2510
+ // Return component instance.
2511
+ return component;
1372
2512
  }
1373
2513
 
1374
2514
  /**
@@ -1381,16 +2521,45 @@
1381
2521
  * - Template interpolations that are functions will be evaluated during the render process, receiving the view instance as an argument and being bound to it. If the function returns `null`, `undefined`, `false`, or an empty string, the interpolation won't render any content.
1382
2522
  * ```javascript
1383
2523
  * const Button = Component.create`
1384
- * <button class="${({ options }) => options.className}">
1385
- * ${({ options }) => options.renderChildren()}
2524
+ * <button class="${({ props }) => props.className}">
2525
+ * ${({ props }) => props.children}
2526
+ * </button>
2527
+ * `;
2528
+ * ```
2529
+ * - Attach DOM event handlers per element using camel-cased attributes.
2530
+ * Event handlers are automatically bound to the component instance (`this`).
2531
+ * Internally, Rasti uses event delegation to the component's root element for performance.
2532
+ *
2533
+ * **Attribute Quoting:**
2534
+ * - **Quoted attributes** (`onClick="${handler}"`) evaluate the expression first, useful for dynamic values
2535
+ * - **Unquoted attributes** (`onClick=${handler}`) pass the function reference directly
2536
+ *
2537
+ * **Listener Signature:** `(event, component, matched)`
2538
+ * - `event`: The native DOM event object
2539
+ * - `component`: The component instance (same as `this`)
2540
+ * - `matched`: The element that matched the event (useful for delegation)
2541
+ *
2542
+ * ```javascript
2543
+ * const Button = Component.create`
2544
+ * <button
2545
+ * onClick=${function(event, component, matched) {
2546
+ * // this === component
2547
+ * console.log('Button clicked:', matched);
2548
+ * }}
2549
+ * onMouseOver="${({ model }) => () => model.isHovered = true}"
2550
+ * onMouseOut="${({ model }) => () => model.isHovered = false}"
2551
+ * >
2552
+ * Click me
1386
2553
  * </button>
1387
2554
  * `;
1388
2555
  * ```
1389
- * - Event handlers should be passed, at the root element as camelized attributes, in the format `onEventName=${{'selector' : listener }}`. They will be transformed to an event object and delegated to the root element. See {@link #module_view__delegateevents View.delegateEvents}.
2556
+ *
2557
+ * If you need custom delegation (e.g., `{'click .selector': 'handler'}`),
2558
+ * you may override the `events` property as described in {@link #module_view__delegateevents View.delegateEvents}.
1390
2559
  * - Boolean attributes should be passed in the format `attribute="${() => true}"`. `false` attributes won't be rendered. `true` attributes will be rendered without a value.
1391
2560
  * ```javascript
1392
2561
  * const Input = Component.create`
1393
- * <input type="text" disabled=${({ options }) => options.disabled} />
2562
+ * <input type="text" disabled=${({ props }) => props.disabled} />
1394
2563
  * `;
1395
2564
  * ```
1396
2565
  * - If the interpolated function returns a component instance, it will be added as a child component.
@@ -1399,21 +2568,21 @@
1399
2568
  * // Create a button component.
1400
2569
  * const Button = Component.create`
1401
2570
  * <button class="button">
1402
- * ${({ options }) => options.renderChildren()}
2571
+ * ${({ props }) => props.children}
1403
2572
  * </button>
1404
2573
  * `;
1405
2574
  * // Create a navigation component. Add buttons as children. Iterate over items.
1406
2575
  * const Navigation = Component.create`
1407
2576
  * <nav>
1408
- * ${({ options }) => options.items.map(
1409
- * item => Button.mount({ renderChildren: () => item.label })
2577
+ * ${({ props }) => props.items.map(
2578
+ * item => Button.mount({ children : item.label })
1410
2579
  * )}
1411
2580
  * </nav>
1412
2581
  * `;
1413
2582
  * // Create a header component. Add navigation as a child.
1414
2583
  * const Header = Component.create`
1415
2584
  * <header>
1416
- * ${({ options }) => Navigation.mount({ items : options.items})}
2585
+ * ${({ props }) => Navigation.mount({ items : props.items})}
1417
2586
  * </header>
1418
2587
  * `;
1419
2588
  * ```
@@ -1422,21 +2591,21 @@
1422
2591
  * // Create a button component.
1423
2592
  * const Button = Component.create`
1424
2593
  * <button class="button">
1425
- * ${({ options }) => options.renderChildren()}
2594
+ * ${({ props }) => props.children}
1426
2595
  * </button>
1427
2596
  * `;
1428
2597
  * // Create a navigation component. Add buttons as children. Iterate over items.
1429
2598
  * const Navigation = Component.create`
1430
2599
  * <nav>
1431
- * ${self => self.options.items.map(
1432
- * item => self.partial`<${Button}>${item.label}</${Button}>`
2600
+ * ${({ props, partial }) => props.items.map(
2601
+ * item => partial`<${Button}>${item.label}</${Button}>`
1433
2602
  * )}
1434
2603
  * </nav>
1435
2604
  * `;
1436
2605
  * // Create a header component. Add navigation as a child.
1437
2606
  * const Header = Component.create`
1438
2607
  * <header>
1439
- * <${Navigation} items="${({ options }) => options.items}" />
2608
+ * <${Navigation} items="${({ props }) => props.items}" />
1440
2609
  * </header>
1441
2610
  * `;
1442
2611
  * ```
@@ -1444,117 +2613,123 @@
1444
2613
  * ```javascript
1445
2614
  * // Create a button component.
1446
2615
  * const Button = Component.create`
1447
- * <button class="${({ options }) => options.className}">
1448
- * ${self => self.renderChildren()}
2616
+ * <button class="${({ props }) => props.className}">
2617
+ * ${({ props }) => props.children}
1449
2618
  * </button>
1450
2619
  * `;
1451
- * // Create a container using the button component
2620
+ * // Create a container that renders a Button component.
1452
2621
  * const ButtonOk = Component.create`
1453
2622
  * <${Button} className="ok">Ok</${Button}>
1454
2623
  * `;
1455
- * // Create a button component using a function
2624
+ * // Create a container that renders a Button component, using a function.
1456
2625
  * const ButtonCancel = Component.create(() => Button.mount({
1457
- * className: 'cancel',
1458
- * renderChildren: () => 'Cancel'
2626
+ * className : 'cancel',
2627
+ * children : 'Cancel'
1459
2628
  * }));
1460
2629
  * ```
1461
2630
  * @static
1462
- * @param {string|function} strings - HTML template for the component or a function that mounts a sub component.
2631
+ * @param {string|Function} strings - HTML template for the component or a function that mounts a sub component.
1463
2632
  * @param {...*} expressions - The expressions to be interpolated within the template.
1464
- * @return {Rasti.Component} The newly created component class.
2633
+ * @return {Component} The newly created component class.
1465
2634
  */
1466
2635
  static create(strings, ...expressions) {
1467
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
1468
2636
  // Containers can be created using create as a functions instead of a tagged template.
1469
2637
  if (typeof strings === 'function') {
1470
2638
  expressions = [strings];
1471
2639
  strings = ['', ''];
1472
2640
  }
1473
-
1474
- let tag, attributes, events, template;
1475
- // Create output string for main template. Add placeholders for new lines.
1476
- const main = expandComponents(addPlaceholders(strings, expressions), expressions);
1477
-
1478
- let match = main.match(
1479
- new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`)
2641
+ // Create elements, interpolations and parts arrays.
2642
+ const elements = [], interpolations = [];
2643
+ const parts = splitPlaceholders(
2644
+ parseInterpolations(
2645
+ parseElements(
2646
+ expandComponents(
2647
+ addPlaceholders(
2648
+ strings,
2649
+ expressions
2650
+ ).trim(),
2651
+ expressions
2652
+ ),
2653
+ expressions,
2654
+ elements
2655
+ ),
2656
+ expressions,
2657
+ interpolations
2658
+ ),
2659
+ expressions
1480
2660
  );
1481
-
1482
- if (match) {
1483
- // It's a component with tag.
1484
- const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
1485
- // Get tag, attributes.
1486
- tag = function() {
1487
- return Component.sanitize(getExpressionResult(tagExpression, this));
1488
- };
1489
- // Get attributes.
1490
- attributes = function() {
1491
- return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
1492
- };
1493
- // Get events.
1494
- events = function() {
1495
- const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
1496
-
1497
- return Object.keys(onlyEvents).reduce((out, key) => {
1498
- const typeListeners = getExpressionResult(onlyEvents[key], this);
1499
-
1500
- Object.keys(typeListeners).forEach(selector => {
1501
- out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
1502
- });
1503
-
1504
- return out;
1505
- }, {});
1506
- };
1507
- // If there is a closing tag, get template.
1508
- if (close) {
1509
- const list = inner ? splitPlaceholders(inner, expressions) : [];
1510
- template = function(addChild) {
1511
- return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
1512
- if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
1513
- if (item instanceof SafeHTML) return item;
1514
- if (item instanceof Component) return addChild(item);
1515
- return Component.sanitize(item);
1516
- }
1517
- return '';
1518
- }).join('');
2661
+ // Create subclass for this component.
2662
+ return this.extend({
2663
+ template() {
2664
+ return {
2665
+ elements : elements.map(element => new Element({
2666
+ getSelector : element.getSelector.bind(this),
2667
+ getAttributes : element.getAttributes.bind(this)
2668
+ })),
2669
+ interpolations : interpolations.map(interpolation => new Interpolation({
2670
+ getStart : interpolation.getStart.bind(this),
2671
+ getEnd : interpolation.getEnd.bind(this),
2672
+ expression : interpolation.expression,
2673
+ isComponent,
2674
+ isElement
2675
+ })),
2676
+ parts,
1519
2677
  };
1520
2678
  }
1521
- } else {
1522
- // It's a container.
1523
- match = main.match(new RegExp(`^\\s*${PH}\\s*$`));
1524
-
1525
- if (match) {
1526
- // If there is only one expression and no tag, is a container.
1527
- template = function(addChild) {
1528
- // Replace expressions.
1529
- return addChild(getExpressionResult(expressions[match[1]], this)).toString();
1530
- };
1531
- } else {
1532
- throw new SyntaxError('Invalid component');
1533
- }
1534
- }
1535
-
1536
- const Current = this;
1537
- // Create subclass for this component.
1538
- return Current.extend({
1539
- // Set root element tag.
1540
- tag,
1541
- // Set attributes.
1542
- attributes,
1543
- // Set events.
1544
- events,
1545
- // Set template.
1546
- template
1547
2679
  });
1548
2680
  }
1549
2681
  }
1550
2682
 
1551
- Component.PLACEHOLDER_EXPRESSION = (idx) => `__RASTI_{${idx}}__`;
1552
- Component.DATA_ATTRIBUTE_UID = 'data-rasti-uid';
1553
- Component.RENDER_TYPE_HYDRATE = 'hydrate';
1554
- Component.RENDER_TYPE_RECYCLE = 'recycle';
1555
- Component.RENDER_TYPE_RENDER = 'render';
2683
+ /*
2684
+ * Attributes used to identify elements and events.
2685
+ */
2686
+ Component.ATTRIBUTE_ELEMENT = 'data-rasti-el';
2687
+ Component.ATTRIBUTE_EVENT = (type) => `data-rasti-on-${type}`;
2688
+
2689
+ /*
2690
+ * Placeholders used to temporarily replace expressions in the template.
2691
+ */
2692
+ Component.PLACEHOLDER = (idx) => `__RASTI-${idx}__`;
2693
+
2694
+ /*
2695
+ * Markers used to identify interpolation and recycled components.
2696
+ */
2697
+ Component.MARKER_RECYCLED = (uid) => `rasti-recycled-${uid}`;
2698
+ Component.MARKER_START = (uid) => `rasti-start-${uid}`;
2699
+ Component.MARKER_END = (uid) => `rasti-end-${uid}`;
2700
+
2701
+ /**
2702
+ * Components are a special kind of `View` that is designed to be easily composable,
2703
+ * making it simple to add child views and build complex user interfaces.
2704
+ * Unlike views, which are render-agnostic, components have a specific set of rendering
2705
+ * guidelines that allow for a more declarative development style.
2706
+ * Components are defined with the {@link #module_component_create Component.create} static method, which takes a tagged template string or a function that returns another component.
2707
+ * @module
2708
+ * @extends View
2709
+ * @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onHydrate, onRecycle, onUpdate, onCreate, onChange. Any additional options not in the component or view options list will be automatically extracted as props and stored as `this.props`.
2710
+ * @property {string} [key] A unique key to identify the component. Components with keys are recycled when the same key is found in the previous render. Unkeyed components are recycled based on type and position.
2711
+ * @property {Rasti.Model} [model] A `Rasti.Model` or any emitter object containing data and business logic. The component will listen to `change` events and call `onChange` lifecycle method.
2712
+ * @property {Rasti.Model} [state] A `Rasti.Model` or any emitter object containing data and business logic, to be used as internal state. The component will listen to `change` events and call `onChange` lifecycle method.
2713
+ * @property {Rasti.Model} [props] Automatically created from any options not merged to the component instance. Contains props passed from parent component as a `Rasti.Model`. The component will listen to `change` events on props and call `onChange` lifecycle method. When a component with a `key` is recycled during parent re-render, new props are automatically updated and any changes trigger a re-render.
2714
+ * @see {@link #module_component_create Component.create}
2715
+ * @example
2716
+ * import { Component, Model } from 'rasti';
2717
+ * // Create Timer component.
2718
+ * const Timer = Component.create`
2719
+ * <div>
2720
+ * Seconds: <span>${({ model }) => model.seconds}</span>
2721
+ * </div>
2722
+ * `;
2723
+ * // Create model to store seconds.
2724
+ * const model = new Model({ seconds: 0 });
2725
+ * // Mount timer on body.
2726
+ * Timer.mount({ model }, document.body);
2727
+ * // Increment `model.seconds` every second.
2728
+ * setInterval(() => model.seconds++, 1000);
2729
+ */
2730
+ var Component$1 = Component.create`<div></div>`;
1556
2731
 
1557
- exports.Component = Component;
2732
+ exports.Component = Component$1;
1558
2733
  exports.Emitter = Emitter;
1559
2734
  exports.Model = Model;
1560
2735
  exports.View = View;