rasti 3.0.1 → 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 +42 -11
  2. package/dist/rasti.js +1767 -622
  3. package/dist/rasti.min.js +1 -1
  4. package/es/Component.js +728 -467
  5. package/es/Emitter.js +182 -28
  6. package/es/Model.js +237 -51
  7. package/es/View.js +73 -31
  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 +729 -468
  25. package/lib/Emitter.cjs +182 -28
  26. package/lib/Model.cjs +237 -51
  27. package/lib/View.cjs +73 -31
  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 +725 -466
  46. package/src/Emitter.js +182 -28
  47. package/src/Model.js +236 -51
  48. package/src/View.js +73 -31
  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/es/Component.js CHANGED
@@ -1,24 +1,21 @@
1
+ import './Emitter.js';
2
+ import Model from './Model.js';
1
3
  import View from './View.js';
4
+ import SafeHTML from './core/SafeHTML.js';
5
+ import Partial from './core/Partial.js';
6
+ import InterpolationWrapper from './core/InterpolationWrapper.js';
7
+ import EventsManager from './core/EventsManager.js';
8
+ import PathManager from './core/PathManager.js';
9
+ import Element from './core/Element.js';
10
+ import Interpolation from './core/Interpolation.js';
11
+ import validateListener from './utils/validateListener.js';
2
12
  import getResult from './utils/getResult.js';
3
13
  import deepFlat from './utils/deepFlat.js';
4
- import './Emitter.js';
5
-
6
- /**
7
- * Wrapper class for HTML strings marked as safe.
8
- * @class SafeHTML
9
- * @param {string} value The HTML string to be marked as safe.
10
- * @property {string} value The HTML string.
11
- * @private
12
- */
13
- class SafeHTML {
14
- constructor(value) {
15
- this.value = value;
16
- }
17
-
18
- toString() {
19
- return this.value;
20
- }
21
- }
14
+ import parseHTML from './utils/parseHTML.js';
15
+ import findComment from './utils/findComment.js';
16
+ import getAttributesHTML from './utils/getAttributesHTML.js';
17
+ import './utils/getAttributesDiff.js';
18
+ import './utils/syncNode.js';
22
19
 
23
20
  /**
24
21
  * Same as getResult, but pass context as argument to the expression.
@@ -30,10 +27,28 @@ class SafeHTML {
30
27
  */
31
28
  const getExpressionResult = (expression, context) => getResult(expression, context, context);
32
29
 
30
+ /**
31
+ * Check if an element is a component root element.
32
+ * Component root elements have the data attribute ending with '-1'.
33
+ * @param {Element} el The element to check.
34
+ * @return {boolean} True if the element is a component root element.
35
+ * @private
36
+ */
37
+ const isComponent = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT) && el.getAttribute(Component.ATTRIBUTE_ELEMENT).endsWith('-1');
38
+
39
+ /**
40
+ * Check if an element has the Rasti data attribute.
41
+ * This includes both component root elements and regular tracked elements.
42
+ * @param {Element} el The element to check.
43
+ * @return {boolean} True if the element has the data attribute.
44
+ * @private
45
+ */
46
+ const isElement = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT);
47
+
33
48
  /**
34
49
  * Generate string with placeholders for interpolated expressions.
35
- * @param strings {array} Array of strings.
36
- * @param expressions {array} Array of expressions.
50
+ * @param {Array<string>} strings Array of strings.
51
+ * @param {Array<any>} expressions Array of expressions.
37
52
  * @return {string} String with placeholders.
38
53
  * @private
39
54
  */
@@ -43,7 +58,7 @@ const addPlaceholders = (strings, expressions) =>
43
58
  out.push(string);
44
59
  // Add expression placeholders.
45
60
  if (typeof expressions[i] !== 'undefined') {
46
- out.push(Component.PLACEHOLDER_EXPRESSION(i));
61
+ out.push(Component.PLACEHOLDER(i));
47
62
  }
48
63
  return out;
49
64
  }, []).join('');
@@ -51,12 +66,12 @@ const addPlaceholders = (strings, expressions) =>
51
66
  /**
52
67
  * Generate one dimensional array with strings and expressions.
53
68
  * @param main {string} The main template containing placeholders.
54
- * @param expressions {array} Array of expressions to replace placeholders.
69
+ * @param {Array<any>} expressions Array of expressions to replace placeholders.
55
70
  * @return {array} Array containing strings and expressions.
56
71
  * @private
57
72
  */
58
73
  const splitPlaceholders = (main, expressions) => {
59
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
74
+ const PH = Component.PLACEHOLDER('(\\d+)');
60
75
  const regExp = new RegExp(`${PH}`, 'g');
61
76
  const out = [];
62
77
  let lastIndex = 0;
@@ -75,47 +90,60 @@ const splitPlaceholders = (main, expressions) => {
75
90
 
76
91
  /**
77
92
  * Expand attributes.
78
- * @param attributes {array} Array of attributes as key, value pairs.
79
- * @param getExpressionResult {function} Function to render expressions.
93
+ * @param {Array<Array<any>>} attributes Array of attributes as key, value pairs.
94
+ * @param {Function} getExpressionResult Function to render expressions.
80
95
  * @return {object}
81
96
  * @property {object} all All attributes.
82
97
  * @property {object} events Event listeners.
83
98
  * @property {object} attributes Attributes.
84
99
  * @private
85
100
  */
86
- const expandAttributes = (attributes, getExpressionResult) => {
87
- const out = attributes.reduce((out, pair) => {
88
- const attribute = getExpressionResult(pair[0]);
89
- // Attribute without value.
90
- if (pair.length === 1) {
91
- if (typeof attribute === 'object') {
92
- // Expand objects as attributes.
93
- out.all = Object.assign(out.all, attribute);
94
- } else if (typeof attribute === 'string') {
95
- // Treat as boolean.
96
- out.all[attribute] = true;
97
- }
98
- } else {
99
- // Attribute with value.
100
- const value = getExpressionResult(pair[1]);
101
- out.all[attribute] = value;
101
+ const expandAttributes = (attributes, getExpressionResult) => attributes.reduce((out, pair) => {
102
+ const attribute = getExpressionResult(pair[0]);
103
+ // Attribute without value.
104
+ if (pair.length === 1) {
105
+ if (typeof attribute === 'object') {
106
+ // Expand objects as attributes.
107
+ out = Object.assign(out, attribute);
108
+ } else if (typeof attribute === 'string') {
109
+ // Treat as boolean.
110
+ out[attribute] = true;
102
111
  }
112
+ } else {
113
+ // Attribute with value.
114
+ const value = pair[2] ? getExpressionResult(pair[1]) : pair[1];
115
+ out[attribute] = value;
116
+ }
103
117
 
104
- return out;
105
- }, { all : {}, events : {}, attributes : {} });
118
+ return out;
119
+ }, {});
106
120
 
107
- Object.keys(out.all).forEach(key => {
121
+ /**
122
+ * Expand events.
123
+ * @param {object} attributes Attributes object.
124
+ * @param {EventsManager} eventsManager Events manager.
125
+ * @return {object} Attributes object.
126
+ * @private
127
+ */
128
+ const expandEvents = (attributes, eventsManager) => {
129
+ const out = {};
130
+ Object.keys(attributes).forEach(key => {
108
131
  // Check if key is an event listener.
109
132
  const match = key.match(/on(([A-Z]{1}[a-z]+)+)/);
133
+
110
134
  if (match && match[1]) {
111
- // Add event listener.
112
- out.events[match[1].toLowerCase()] = out.all[key];
135
+ const type = match[1].toLowerCase();
136
+ const listener = attributes[key];
137
+ if (listener) {
138
+ const index = eventsManager.addListener(listener, type);
139
+ // Add event listener index.
140
+ out[Component.ATTRIBUTE_EVENT(type)] = index;
141
+ }
113
142
  } else {
114
143
  // Add attribute.
115
- out.attributes[key] = out.all[key];
144
+ out[key] = attributes[key];
116
145
  }
117
146
  });
118
-
119
147
  return out;
120
148
  };
121
149
 
@@ -126,177 +154,351 @@ const expandAttributes = (attributes, getExpressionResult) => {
126
154
  * Returns the template with component tags replaced by expressions placeholders
127
155
  * modifies the expressions array adding the mount functions.
128
156
  * @param main {string} The main template.
157
+ * @param {Array<any>} expressions Array of expressions.
129
158
  * @return {string} The template with components tags replaced by expressions
130
159
  * placeholders.
131
160
  * @private
132
161
  */
133
162
  const expandComponents = (main, expressions) => {
134
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
163
+ const PH = Component.PLACEHOLDER('(\\d+)');
135
164
  // Match component tags.
136
165
  return main.replace(
137
166
  new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</(${PH})>|<(${PH})([^>]*)/>`,'g'),
138
- function() {
139
- const { tag, attributes, inner, close, raw } = parseMatch(arguments, expressions);
167
+ (match, openTag, openIdx, nonVoidAttrs, inner, closeTag, closeIdx, selfClosingTag, selfClosingIdx, selfClosingAttrs) => {
168
+ let tag, close, attributesStr;
169
+
170
+ if (openTag) {
171
+ tag = typeof openIdx !== 'undefined' ? expressions[openIdx] : openTag;
172
+ close = typeof closeIdx !== 'undefined' ? expressions[closeIdx] : closeTag;
173
+ attributesStr = nonVoidAttrs;
174
+ } else {
175
+ tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
176
+ attributesStr = selfClosingAttrs;
177
+ }
140
178
  // No component found.
141
- if (!(tag.prototype instanceof Component)) return raw;
179
+ if (!(tag.prototype instanceof Component)) return match;
142
180
 
143
- let renderChildren;
181
+ let innerList;
144
182
  // Non void component.
145
183
  if (close) {
146
184
  // Close component tag must match open component tag.
147
- if (tag !== close) return raw;
185
+ if (tag !== close) return match;
186
+ // Process inner content same way as partial().
148
187
  // Recursively expand inner components.
149
- const list = splitPlaceholders(expandComponents(inner, expressions), expressions);
150
- // Create renderChildren function.
151
- renderChildren = function() {
152
- return deepFlat(list.map(item => getExpressionResult(item, this)));
153
- };
188
+ const innerTemplate = expandComponents(inner, expressions);
189
+ // Parse partial elements to handle dynamic attributes and events.
190
+ const parsedInner = parsePartialElements(innerTemplate, expressions);
191
+ // Split into items.
192
+ innerList = splitPlaceholders(parsedInner, expressions);
154
193
  }
194
+ // Parse attributes.
195
+ const attributes = parseAttributes(attributesStr, expressions);
155
196
  // Create mount function.
156
197
  const mount = function() {
157
- const options = expandAttributes(attributes, value => getExpressionResult(value, this)).all;
158
- // Add renderChildren function to options.
159
- if (renderChildren) options.renderChildren = renderChildren.bind(this);
198
+ const options = expandAttributes(attributes, value => getExpressionResult(value, this));
199
+ // Add `renderChildren` function to options.
200
+ if (innerList) {
201
+ // Evaluate items in parent context and create Partial.
202
+ options.renderChildren = () => new Partial(innerList.map(item => getExpressionResult(item, this)));
203
+ }
160
204
  // Mount component.
161
205
  return tag.mount(options);
162
206
  };
163
207
  // Add mount function to expression.
164
208
  expressions.push(mount);
165
209
  // Replace whole string with expression placeholder.
166
- return Component.PLACEHOLDER_EXPRESSION(expressions.length - 1);
210
+ return Component.PLACEHOLDER(expressions.length - 1);
167
211
  }
168
212
  );
169
213
  };
170
214
 
171
215
  /**
172
- * Parse match data to get tag, attributes, inner html and close tag.
173
- * @param match {array}
174
- * @return {object}
175
- * @property {string} tag The tag.
176
- * @property {string} inner The inner html.
177
- * @property {string} close The closing tag.
178
- * @property {array} attributes Array of attributes as key, value pairs.
179
- * @property {string} raw The whole match.
216
+ * Replace elements in template.
217
+ * @param {string} template Template string.
218
+ * @param {Function} replacer Replacer function.
219
+ * @return {string} Template string with replaced elements.
180
220
  * @private
181
221
  */
182
- const parseMatch = (match, expressions) => {
183
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
222
+ const replaceElements = (template, replacer) => {
223
+ const PH = Component.PLACEHOLDER('(?:\\d+)');
224
+ return template.replace(
225
+ new RegExp(`<(${PH}|[a-z]+[1-6]?)(?:\\s*)((?:"[^"]*"|'[^']*'|[^>])*)(/?>)`, 'gi'),
226
+ replacer
227
+ );
228
+ };
184
229
 
185
- const [all, openTag, openIdx, nonVoidAttrs, inner, closeTag, closeIdx,
186
- selfClosingTag, selfClosingIdx, selfClosingAttrs] = match;
230
+ /**
231
+ * Parse all HTML elements in template and extract their attributes.
232
+ * @param {string} template Template string with placeholders.
233
+ * @param {Array} expressions Array of expressions.
234
+ * @param {Array} elements Array to store element references.
235
+ * @return {string} Template with parsed attributes.
236
+ * @throws {SyntaxError} If the template does not have a single root element or is a container component.
237
+ * @private
238
+ */
239
+ const parseElements = (template, expressions, elements) => {
240
+ const PH = Component.PLACEHOLDER('(?:\\d+)');
241
+ // Check if template is a container (single placeholder, no tag).
242
+ const containerMatch = template.match(new RegExp(`^\\s*${PH}\\s*$`));
243
+ if (containerMatch) return template;
244
+ // Validate that template has a root element.
245
+ const rootElementMatch = template.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`));
246
+ if (!rootElementMatch) throw new SyntaxError(`Template must have a single root element or be a container component: "${template.trim()}"`);
247
+
248
+ let elementUid = 0;
249
+ // Match all HTML elements including placeholders and self-closed elements.
250
+ return replaceElements(template, (match, tag, attributesStr, ending) => {
251
+ const isRoot = elementUid === 0;
252
+ const currentElementUid = ++elementUid;
253
+ // If there are no dynamic attributes, return original match.
254
+ if (!isRoot && !attributesStr.match(new RegExp(PH))) {
255
+ return match;
256
+ }
257
+ // Parse attributes.
258
+ const parsedAttributes = parseAttributes(attributesStr, expressions);
259
+ // Create element reference.
260
+ const generateElementUid = componentUid => `${componentUid}-${currentElementUid}`;
261
+ // Create function that returns attributes object.
262
+ const getAttributes = function() {
263
+ // Expand attributes and events.
264
+ const attributes = expandEvents(
265
+ expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
266
+ this.eventsManager
267
+ );
268
+ // Extend template attributes with `options.attributes`.
269
+ if (isRoot && this.attributes) {
270
+ Object.assign(attributes, getResult(this.attributes, this));
271
+ }
272
+ // Add data attribute for element identification.
273
+ // First element gets the component uid, others get element uid.
274
+ attributes[Component.ATTRIBUTE_ELEMENT] = generateElementUid(this.uid);
275
+
276
+ return attributes;
277
+ };
278
+
279
+ const getSelector = function() {
280
+ return `[${Component.ATTRIBUTE_ELEMENT}="${generateElementUid(this.uid)}"]`;
281
+ };
282
+ // Add element reference to elements array.
283
+ elements.push({
284
+ getSelector,
285
+ getAttributes,
286
+ });
287
+ // Add new expression to expressions array.
288
+ expressions.push(function() {
289
+ const attributes = getAttributes.call(this);
290
+ return Component.markAsSafeHTML(getAttributesHTML(attributes));
291
+ });
292
+ // Replace attributes with placeholder.
293
+ const placeholder = Component.PLACEHOLDER(expressions.length - 1);
294
+ // Preserve original tag ending (> or />)
295
+ return `<${tag} ${placeholder}${ending}`;
296
+ });
297
+ };
187
298
 
188
- const data = { raw : all, attributes : [] };
299
+ /**
300
+ * Parse elements in partial template.
301
+ * @param {string} template Template string with placeholders.
302
+ * @param {Array} expressions Array of expressions.
303
+ * @return {string} Template with parsed attributes.
304
+ * @private
305
+ */
306
+ const parsePartialElements = (template, expressions) => {
307
+ const PH = Component.PLACEHOLDER('(?:\\d+)');
308
+ // Match all HTML elements including placeholders and self-closed elements.
309
+ return replaceElements(template, (match, tag, attributesStr, ending) => {
310
+ // If there are no dynamic attributes, return original match.
311
+ if (!attributesStr.match(new RegExp(PH))) {
312
+ return match;
313
+ }
314
+ // Parse attributes.
315
+ const parsedAttributes = parseAttributes(attributesStr, expressions);
316
+ // Create function that returns attributes object.
317
+ const getAttributes = function() {
318
+ const attributes = expandEvents(
319
+ expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
320
+ this.eventsManager
321
+ );
322
+
323
+ return attributes;
324
+ };
325
+ // Add new expression to expressions array.
326
+ expressions.push(function() {
327
+ const attributes = getAttributes.call(this);
328
+ return Component.markAsSafeHTML(getAttributesHTML(attributes));
329
+ });
330
+ // Replace attributes with placeholder.
331
+ const placeholder = Component.PLACEHOLDER(expressions.length - 1);
332
+ // Preserve original tag ending (> or />)
333
+ return `<${tag} ${placeholder}${ending}`;
334
+ });
335
+ };
189
336
 
190
- let attributesStr;
191
- if (openTag) {
192
- // Non void element.
193
- data.tag = typeof openIdx !== 'undefined' ? expressions[openIdx] : openTag;
194
- data.inner = inner;
195
- data.close = typeof closeIdx !== 'undefined' ? expressions[closeIdx] : closeTag;
196
- attributesStr = nonVoidAttrs;
197
- } else {
198
- // Self closing element.
199
- data.tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
200
- attributesStr = selfClosingAttrs;
201
- }
202
- // Parse attributes.
337
+ /**
338
+ * Parse all interpolations in template text content.
339
+ * @param {string} template Template string with placeholders.
340
+ * @param {Array} expressions Array of expressions.
341
+ * @param {Array} interpolations Array to store interpolation references.
342
+ * @return {string} Template with interpolation markers.
343
+ * @private
344
+ */
345
+ const parseInterpolations = (template, expressions, interpolations) => {
346
+ const PH = Component.PLACEHOLDER('(\\d+)');
347
+ let interpolationUid = 0;
348
+ // Match all expression placeholders.
349
+ return template.replace(
350
+ new RegExp(PH, 'g'),
351
+ function(match, expressionIndex, offset) {
352
+ // Check if this placeholder is inside an element tag (attribute).
353
+ // `offset` is the index of the match in the original string.
354
+ const beforeMatch = template.substring(0, offset);
355
+ const lastOpenTag = beforeMatch.lastIndexOf('<');
356
+ const lastCloseTag = beforeMatch.lastIndexOf('>');
357
+ // If we're inside an element tag, don't process as interpolation.
358
+ if (lastOpenTag > lastCloseTag) {
359
+ return match;
360
+ }
361
+
362
+ const currentInterpolationUid = ++interpolationUid;
363
+
364
+ function getStart() {
365
+ return Component.MARKER_START(`${this.uid}-${currentInterpolationUid}`);
366
+ }
367
+ function getEnd() {
368
+ return Component.MARKER_END(`${this.uid}-${currentInterpolationUid}`);
369
+ }
370
+ // Add interpolation reference to interpolations array.
371
+ interpolations.push({
372
+ getStart,
373
+ getEnd,
374
+ expression : expressions[expressionIndex]
375
+ });
376
+ // Add new expression to expressions array.
377
+ expressions.push(function() {
378
+ const result = getExpressionResult(expressions[expressionIndex], this);
379
+ const uid = `${this.uid}-${currentInterpolationUid}`;
380
+ return new InterpolationWrapper(uid, result);
381
+ });
382
+ // Replace with new placeholder.
383
+ return Component.PLACEHOLDER(expressions.length - 1);
384
+ }
385
+ );
386
+ };
387
+
388
+ /**
389
+ * Parse attributes string to extract dynamic attributes.
390
+ * @param {string} attributesStr Attributes string from HTML element.
391
+ * @param {Array} expressions Array of expressions.
392
+ * @return {Array} Array of attribute pairs [key, value] or [key, value, hasQuotes].
393
+ * @private
394
+ */
395
+ const parseAttributes = (attributesStr, expressions) => {
396
+ const PH = Component.PLACEHOLDER('(\\d+)');
397
+ const attributes = [];
398
+ // Parse attributes string with support for placeholders in both names and values.
203
399
  const regExp = new RegExp(`(${PH}|[\\w-]+)(?:=(["']?)(?:${PH}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`, 'g');
204
400
 
205
401
  let attributeMatch;
206
402
  while ((attributeMatch = regExp.exec(attributesStr)) !== null) {
207
- const [, attribute, attributeIdx,, valueIdx, value] = attributeMatch;
403
+ const [, attribute, attributeIdx, quotes, valueIdx, value] = attributeMatch;
208
404
 
209
- const attr = typeof attributeIdx !== 'undefined' ? expressions[attributeIdx] : attribute;
210
- const val = typeof valueIdx !== 'undefined' ? expressions[valueIdx] : value;
405
+ const attr = typeof attributeIdx !== 'undefined' ? expressions[parseInt(attributeIdx, 10)] : attribute;
406
+ const val = typeof valueIdx !== 'undefined' ? expressions[parseInt(valueIdx, 10)] : value;
211
407
 
212
408
  if (typeof val !== 'undefined') {
213
- data.attributes.push([attr, val]);
409
+ attributes.push([attr, val, !!quotes]);
214
410
  } else {
215
- data.attributes.push([attr]);
411
+ attributes.push([attr]);
216
412
  }
217
413
  }
218
414
 
219
- return data;
220
- };
221
-
222
- /*
223
- * HTML tags that are self closing.
224
- */
225
- const selfClosingTags = {
226
- area : true, base : true, br : true, col : true, embed : true, hr : true,
227
- img : true, input : true, link : true, meta : true, source : true, track : true, wbr : true
415
+ return attributes;
228
416
  };
229
417
 
230
418
  /*
231
419
  * These option keys will be extended on the component instance.
232
420
  */
233
- const componentOptions = ['key', 'state', 'onCreate', 'onChange', 'onRender'];
421
+ const componentOptions = ['key', 'state', 'onCreate', 'onChange', 'onHydrate', 'onRecycle', 'onUpdate'];
234
422
 
235
423
  /**
236
- * Components are a special kind of `View` that is designed to be easily composable,
237
- * making it simple to add child views and build complex user interfaces.
238
- * Unlike views, which are render-agnostic, components have a specific set of rendering
239
- * guidelines that allow for a more declarative development style.
240
- * 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.
241
- * @module
242
- * @extends Rasti.View
243
- * @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onRender, onCreate, onChange.
244
- * @property {string} key A unique key to identify the component. Used to recycle child components.
245
- * @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.
246
- * @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.
247
- * @see {@link #module_component_create Component.create}
248
- * @example
249
- * import { Component, Model } from 'rasti';
250
- * // Create Timer component.
251
- * const Timer = Component.create`
252
- * <div>
253
- * Seconds: <span>${({ model }) => model.seconds}</span>
254
- * </div>
255
- * `;
256
- * // Create model to store seconds.
257
- * const model = new Model({ seconds: 0 });
258
- * // Mount timer on body.
259
- * Timer.mount({ model }, document.body);
260
- * // Increment `model.seconds` every second.
261
- * setInterval(() => model.seconds++, 1000);
424
+ * @lends module:Component
262
425
  */
263
426
  class Component extends View {
264
427
  constructor(options = {}) {
265
428
  super(...arguments);
429
+ this.componentOptions = [];
266
430
  // Extend "this" with options.
267
431
  componentOptions.forEach(key => {
268
- if (key in options) this[key] = options[key];
432
+ if (key in options) {
433
+ this[key] = options[key];
434
+ this.componentOptions.push(key);
435
+ }
436
+ });
437
+ // Extract props from options that aren't component or view options.
438
+ const props = {};
439
+ Object.keys(options).forEach(key => {
440
+ if (!this.viewOptions.includes(key) && !this.componentOptions.includes(key)) {
441
+ props[key] = options[key];
442
+ }
269
443
  });
444
+ // Store props as Model for reactive updates.
445
+ this.props = new Model(props);
270
446
  // Store options by default.
271
447
  this.options = options;
272
448
  // Bind `partial` method to `this`.
273
449
  this.partial = this.partial.bind(this);
450
+ // Bind `onChange` method to `this`.
451
+ this.onChange = this.onChange.bind(this);
274
452
  // Call lifecycle method.
275
453
  this.onCreate.apply(this, arguments);
276
454
  }
277
455
 
278
456
  /**
279
- * Listen to `change` event on a model or emitter object and call `onChange` lifecycle method.
280
- * The listener will be removed when the component is destroyed.
281
- * By default the component will be subscribed to `this.model` and `this.state`.
282
- * @param {Rasti.Model} model A model or emitter object to listen to changes.
283
- * @return {Rasti.Component} The component instance.
457
+ * Get events object for automatic event delegation, based on data attributes.
458
+ * @return {object} The events object.
459
+ * @private
284
460
  */
285
- subscribe(model) {
286
- // Check if model has `on` method.
287
- if (!model.on) return;
288
- // Store bound onChange method.
289
- const onChange = this.onChange.bind(this);
290
- // Listen to model changes and store unbind function.
291
- const off = model.on('change', onChange);
292
- // Add unbind function to destroy queue.
293
- // So the component stops listening to model changes when destroyed.
294
- this.destroyQueue.push(
295
- // Rasti `on` method returns an unbind function.
296
- // But other libraries may return the object itself.
297
- typeof off === 'function' ? off : () => model.off('change', onChange)
298
- );
461
+ events() {
462
+ const events = {};
463
+ // Create events object.
464
+ this.eventsManager.types.forEach(type => {
465
+ const dataAttribute = Component.ATTRIBUTE_EVENT(type);
466
+ // Create a listener function that gets the listener index from the data attribute and calls the listener.
467
+ const listener = function(event, component, matched) {
468
+ // Get the listener index from the data attribute.
469
+ const index = matched.getAttribute(dataAttribute);
470
+ // Root element listener may not have a data attribute.
471
+ if (index) {
472
+ let currentListener = this.eventsManager.listeners[parseInt(index, 10)];
473
+ if (typeof currentListener === 'string') currentListener = this[currentListener];
474
+ validateListener(currentListener);
475
+ // Call the listener.
476
+ currentListener.call(this, event, component, matched);
477
+ }
478
+ };
479
+ // Add an event listener to the events object for each event type, using the data attribute
480
+ // as both a CSS selector and to store the listener's index.
481
+ events[`${type} [${dataAttribute}]`] = listener;
482
+ // Add an event listener to the events object for each event type that matches the root element.
483
+ events[type] = listener;
484
+ });
299
485
 
486
+ return events;
487
+ }
488
+
489
+ /**
490
+ * Subscribes to a `change` event on a model or emitter object and invokes the `onChange` lifecycle method.
491
+ * The subscription is automatically cleaned up when the component is destroyed.
492
+ * By default, the component subscribes to changes on `this.model`, `this.state`, and `this.props`.
493
+ *
494
+ * @param {Object} model - The model or emitter object to listen to.
495
+ * @param {string} [type='change'] - The event type to listen for.
496
+ * @param {Function} [listener=this.onChange] - The callback to invoke when the event is emitted.
497
+ * @returns {Component} The current component instance for chaining.
498
+ */
499
+ subscribe(model, type = 'change', listener = this.onChange) {
500
+ // Check if model has `on` method.
501
+ if (model.on) this.listenTo(model, type, listener);
300
502
  return this;
301
503
  }
302
504
 
@@ -309,7 +511,7 @@ class Component extends View {
309
511
  * @private
310
512
  */
311
513
  isContainer() {
312
- return !!(!this.tag && this.template);
514
+ return this.template.elements.length === 0 && this.template.interpolations.length === 1;
313
515
  }
314
516
 
315
517
  /**
@@ -318,134 +520,107 @@ class Component extends View {
318
520
  * @private
319
521
  */
320
522
  ensureElement() {
523
+ // Store data event listeners.
524
+ this.eventsManager = new EventsManager();
525
+ // Store position tracking for recycling.
526
+ this.pathManager = new PathManager();
527
+ // Call template function.
528
+ this.template = getResult(this.template, this);
321
529
  // If el is provided, delegate events.
322
530
  if (this.el) {
323
531
  // If "this.el" is a function, call it to get the element.
324
532
  this.el = getResult(this.el, this);
325
- this.delegateEvents();
533
+ // Render the component as a string to generate children components.
534
+ this.toString();
535
+ // Hydrate the component.
536
+ this.hydrate(this.el.parentNode);
326
537
  }
327
538
  }
328
539
 
329
- /**
330
- * Locate the root element of the `Component` within a specified parent node.
331
- * This is achieved by searching for the element using the unique data attribute assigned to the `Component`.
332
- * @param {Node} parent - The parent node to search within.
333
- * @return {Node} The root element of the component, or `null` if not found.
334
- * @private
335
- */
336
- findElement(parent) {
337
- return (parent || document).querySelector(`[${Component.DATA_ATTRIBUTE_UID}="${this.uid}"]`);
338
- }
339
-
340
- /**
341
- * Retrieve the attributes to be applied to the element.
342
- * This includes attributes to be added, removed, and their HTML representation.
343
- * @return {object} An object containing the following properties:
344
- * @property {object} add - Attributes to be added to the element, with their values.
345
- * @property {object} remove - Attributes to be removed from the element.
346
- * @property {string} html - A string representation of the attributes for use in HTML.
347
- * @private
348
- */
349
- getAttributes() {
350
- const add = {};
351
- const remove = {};
352
- const html = [];
353
-
354
- const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
355
-
356
- if (this.attributes) Object.assign(attributes, getResult(this.attributes, this));
357
- // Store previous attributes.
358
- const previousAttributes = this.previousAttributes || {};
359
- this.previousAttributes = attributes;
360
-
361
- Object.keys(attributes).forEach(key => {
362
- let value = attributes[key];
363
- // Transform bool attribute values
364
- if (value === false) {
365
- remove[key] = true;
366
- } else if (value === true) {
367
- add[key] = '';
368
- html.push(key);
369
- } else {
370
- if (value === null || typeof value === 'undefined') value = '';
371
-
372
- add[key] = value;
373
- html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
374
- }
375
- });
376
- // Remove attributes that were in previousAttributes but not in current attributes.
377
- Object.keys(previousAttributes).forEach(key => {
378
- if (!(key in attributes)) {
379
- remove[key] = true;
380
- }
381
- });
382
-
383
- return { add, remove, html : html.join(' ') };
384
- }
385
-
386
540
  /**
387
541
  * Used internally on the render process.
388
542
  * Attach the `Component` to the dom element providing `this.el`, delegate events,
389
- * subscribe to model changes and call `onRender` lifecycle method with `Component.RENDER_TYPE_HYDRATE` as argument.
543
+ * subscribe to model changes and call `onHydrate` lifecycle method.
390
544
  * @param parent {node} The parent node.
391
- * @return {Rasti.Component} The component instance.
545
+ * @return {Component} The component instance.
392
546
  * @private
393
547
  */
394
548
  hydrate(parent) {
395
- // Listen to model changes and call onChange.
396
- if (this.model) this.subscribe(this.model);
397
- // Listen to state changes and call onChange.
398
- if (this.state) this.subscribe(this.state);
549
+ ['model', 'state', 'props'].forEach(key => {
550
+ if (this[key]) this.subscribe(this[key]);
551
+ });
399
552
 
400
553
  if (this.isContainer()) {
554
+ // Get references for interpolation marker comments
555
+ this.template.interpolations[0].hydrate(parent);
556
+ // Call hydrate on children.
401
557
  this.children[0].hydrate(parent);
558
+ // Set the first element as the component's element.
402
559
  this.el = this.children[0].el;
403
560
  } else {
404
- this.el = this.findElement(parent);
561
+ // Search for every element in template using getSelector
562
+ this.template.elements.forEach((element, index) => {
563
+ if (index === 0) {
564
+ element.hydrate(parent);
565
+ if (this.el) element.ref = this.el;
566
+ else this.el = element.ref;
567
+ }
568
+ else {
569
+ element.hydrate(this.el);
570
+ }
571
+ });
572
+ // Delegate events.
405
573
  this.delegateEvents();
574
+ // Get references for interpolation marker comments
575
+ this.template.interpolations.forEach(interpolation => interpolation.hydrate(this.el));
406
576
  this.children.forEach(child => child.hydrate(this.el));
407
577
  }
408
- // Call `onRender` lifecycle method.
409
- this.onRender.call(this, Component.RENDER_TYPE_HYDRATE);
578
+ // Call `onHydrate` lifecycle method.
579
+ this.onHydrate.call(this);
410
580
  // Return `this` for chaining.
411
581
  return this;
412
582
  }
413
583
 
414
584
  /**
415
- * Used internally on the render process.
416
- * Reuse a `Component` that has `key` when its parent is rendered.
417
- * Call `onRender` lifecycle method with `Component.RENDER_TYPE_RECYCLE` as argument.
418
- * @param parent {node} The parent node.
419
- * @return {Rasti.Component} The component instance.
585
+ * Get a `comment` marker with same data attribute as this component.
586
+ * Used to replace the component when it is recycled.
587
+ * @return {string} The recycle placeholder.
420
588
  * @private
421
589
  */
422
- recycle(parent) {
423
- // If component is a container, call recycle on its child.
424
- if (this.isContainer()) {
425
- this.children[0].recycle(parent);
426
- } else {
427
- // Find placeholder element to be replaced. It has same data attribute as this component.
428
- const toBeReplaced = this.findElement(parent);
429
- // Replace it with this.el.
430
- toBeReplaced.replaceWith(this.el);
431
- }
432
- // Call `onRender` lifecycle method.
433
- this.onRender.call(this, Component.RENDER_TYPE_RECYCLE);
434
- // Return `this` for chaining.
435
- return this;
590
+ getRecycledMarker() {
591
+ return `<!--${Component.MARKER_RECYCLED(this.uid)}-->`;
436
592
  }
437
593
 
438
594
  /**
439
- * Destroy the `Component`.
440
- * Destroy children components if any, undelegate events, stop listening to events, call `onDestroy` lifecycle method.
441
- * @param {object} options Options object or any arguments passed to `destroy` method will be passed to `onDestroy` method.
442
- * @return {Rasti.View} Return `this` for chaining.
595
+ * Get the component nodes to be inserted into the DOM.
596
+ * Used internally during the render process, you usually don't need to call it if you use
597
+ * `mount()`.
598
+ * For components that render HTML elements you can safely rely on `this.el`.
599
+ * For container components that render another component you need the wrapper nodes to insert
600
+ * them into the DOM; use `getNodes()` for that.
601
+ * @return {Node[]} The component nodes.
443
602
  */
444
- destroy() {
445
- // Call super destroy method.
446
- super.destroy.apply(this, arguments);
447
- // Set destroyed flag to prevent a last render after destroyed.
448
- this.destroyed = true;
603
+ getNodes() {
604
+ return this.isContainer() ?
605
+ [this.template.interpolations[0].ref[0], ...this.children[0].getNodes(), this.template.interpolations[0].ref[1]] :
606
+ [this.el];
607
+ }
608
+
609
+ /**
610
+ * Used internally on the render process.
611
+ * Reuse a `Component` by replacing the placeholder comment with the real nodes.
612
+ * Call `onRecycle` lifecycle method.
613
+ * @param parent {node} The parent node.
614
+ * @return {Component} The component instance.
615
+ * @private
616
+ */
617
+ recycle(parent) {
618
+ // Locate the placeholder comment and replace it with the real nodes
619
+ const toBeReplaced = findComment(parent, Component.MARKER_RECYCLED(this.uid), isComponent);
620
+ // Replace it with this.el.
621
+ toBeReplaced.replaceWith(...this.getNodes());
622
+ // Call `onRecycle` lifecycle method.
623
+ this.onRecycle.call(this);
449
624
  // Return `this` for chaining.
450
625
  return this;
451
626
  }
@@ -471,16 +646,22 @@ class Component extends View {
471
646
  }
472
647
 
473
648
  /**
474
- * Lifecycle method. Called after the component is rendered.
475
- * - When the component is rendered for the first time, this method is called with `Component.RENDER_TYPE_HYDRATE` as the argument.
476
- * - When the component is updated or re-rendered, this method is called with `Component.RENDER_TYPE_RENDER` as the argument.
477
- * - When the component is recycled (reused with the same key), this method is called with `Component.RENDER_TYPE_RECYCLE` as the argument.
478
- * @param {string} type - The render type. Possible values are: `Component.RENDER_TYPE_HYDRATE`, `Component.RENDER_TYPE_RENDER` and `Component.RENDER_TYPE_RECYCLE`.
649
+ * Lifecycle method. Called when the component is rendered for the first time and hydrated.
650
+ */
651
+ onHydrate() {}
652
+
653
+ /**
654
+ * Lifecycle method. Called when the component is recycled (reused with the same key) and added to the DOM again.
655
+ */
656
+ onRecycle() {}
657
+
658
+ /**
659
+ * Lifecycle method. Called when the component is updated or re-rendered.
479
660
  */
480
- onRender() {}
661
+ onUpdate() {}
481
662
 
482
663
  /**
483
- * Lifecycle method. Called when the view is destroyed.
664
+ * Lifecycle method. Called when the component is destroyed.
484
665
  * @param {object} options Options object or any arguments passed to `destroy` method.
485
666
  */
486
667
  onDestroy() {}
@@ -488,18 +669,18 @@ class Component extends View {
488
669
  /**
489
670
  * Tagged template helper method.
490
671
  * Used to create a partial template.
491
- * It will return a one-dimensional array with strings and expressions.
672
+ * It will return a Partial object that preserves structure for position-based recycling.
492
673
  * Components will be added as children by the parent component. Template strings literals
493
674
  * will be marked as safe HTML to be rendered.
494
675
  * This method is bound to the component instance by default.
495
676
  * @param {TemplateStringsArray} strings - Template strings.
496
677
  * @param {...any} expressions - Template expressions.
497
- * @return {Array} Array containing strings and expressions.
678
+ * @return {Partial} Partial object containing strings and expressions.
498
679
  * @example
499
680
  * import { Component } from 'rasti';
500
681
  * // Create a Title component.
501
682
  * const Title = Component.create`
502
- * <h1>${self => self.renderChildren()}</h1>
683
+ * <h1>${({ props }) => props.children}</h1>
503
684
  * `;
504
685
  * // Create Main component.
505
686
  * const Main = Component.create`
@@ -519,147 +700,191 @@ class Component extends View {
519
700
  * });
520
701
  */
521
702
  partial(strings, ...expressions) {
522
- return deepFlat(
523
- splitPlaceholders(
524
- expandComponents(addPlaceholders(strings, expressions), expressions), expressions
525
- ).map(item => getExpressionResult(item, this))
526
- );
703
+ const items = splitPlaceholders(
704
+ parsePartialElements(
705
+ expandComponents(
706
+ addPlaceholders(strings, expressions),
707
+ expressions
708
+ ),
709
+ expressions
710
+ ),
711
+ expressions
712
+ ).map(item => getExpressionResult(item, this));
713
+
714
+ return new Partial(items);
527
715
  }
528
716
 
529
- getRecyclePlaceholder() {
530
- if (this.isContainer()) return this.children[0].getRecyclePlaceholder();
531
-
532
- const tag = getResult(this.tag, this) || 'div';
533
- const attributes = `${Component.DATA_ATTRIBUTE_UID}="${this.uid}"`;
717
+ /**
718
+ * Render a template part.
719
+ * @param {any} part - The template part.
720
+ * @param {function} addChild - The addChild function.
721
+ * @return {string} The rendered template part.
722
+ * @private
723
+ */
724
+ renderTemplatePart(part, addChild) {
725
+ const result = getExpressionResult(part, this);
726
+
727
+ const parse = item => {
728
+ if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
729
+ if (item instanceof SafeHTML) return item;
730
+ if (item instanceof Component) return addChild(item);
731
+
732
+ if (item instanceof Partial) {
733
+ this.pathManager.push();
734
+ const out = item.items.map(subItem => { this.pathManager.increment(); return parse(subItem); }).join('');
735
+ this.pathManager.pop();
736
+ return out;
737
+ }
738
+ // Handle arrays (user loops) - disable tracking.
739
+ if (Array.isArray(item)) {
740
+ this.pathManager.pause();
741
+ const out = deepFlat(item).map(parse).join('');
742
+ this.pathManager.resume();
743
+ return out;
744
+ }
745
+ // InterpolationWrapper: add markers and process maintaining tracking.
746
+ if (item instanceof InterpolationWrapper) {
747
+ this.pathManager.increment();
748
+ const startMarker = `<!--${Component.MARKER_START(item.interpolationUid)}-->`;
749
+ const endMarker = `<!--${Component.MARKER_END(item.interpolationUid)}-->`;
750
+ return `${startMarker}${parse(item.result)}${endMarker}`;
751
+ }
752
+
753
+ return Component.sanitize(item);
754
+ }
755
+ // Return empty string if item is undefined, null, false, or true.
756
+ return '';
757
+ };
534
758
 
535
- return this.template || !selfClosingTags[tag] ?
536
- `<${tag} ${attributes}></${tag}>` :
537
- `<${tag} ${attributes} />`;
759
+ return `${parse(result)}`;
538
760
  }
539
761
 
540
- /*
541
- * Treat the whole view as a HTML string.
762
+ /**
763
+ * Render the component as a string.
764
+ * Used internally on the render process.
765
+ * Use it for server-side rendering or static site generation.
766
+ * @return {string} The rendered component.
542
767
  */
543
768
  toString() {
544
769
  // Normally there won't be any children, but if there are, destroy them.
545
770
  this.destroyChildren();
546
- // Container.
547
- if (this.isContainer()) return this.template.call(this, this.addChild.bind(this));
548
- // Get tag name.
549
- const tag = getResult(this.tag, this) || 'div';
550
- // Get attributes.
551
- const attributes = this.getAttributes().html;
552
- // Replace expressions of inner template.
553
- const inner = this.template ? this.template.call(this, this.addChild.bind(this)) : '';
554
- // Generate outer template.
555
- return this.template || !selfClosingTags[tag] ?
556
- `<${tag} ${attributes}>${inner}</${tag}>` :
557
- `<${tag} ${attributes} />`;
771
+ // Normally there won't be any data event listeners, but if there are, clear them.
772
+ this.eventsManager.reset();
773
+ // Reset position tracking.
774
+ this.pathManager.reset();
775
+ // Bind addChild method.
776
+ const addChild = component => {
777
+ this.pathManager.track(component);
778
+ return this.addChild(component);
779
+ };
780
+ // Render the template parts.
781
+ return this.template.parts
782
+ .map(part => this.renderTemplatePart(part, addChild))
783
+ .join('');
558
784
  }
559
785
 
560
786
  /**
561
787
  * Render the `Component`.
562
- * - If `this.el` is not present, the `Component` will be rendered as a string inside a `DocumentFragment` and hydrated, making `this.el` available. The `onRender` lifecycle method will be called with `Component.RENDER_TYPE_HYDRATE` as an argument.
563
- * - 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 `onRender` lifecycle method will be called with `Component.RENDER_TYPE_RENDER` as an argument.
564
- * - When rendering child components, if the new children have the same key as the previous ones, they will be recycled. A recycled `Component` will call the `onRender` lifecycle method with `Component.RENDER_TYPE_RECYCLE` as an argument.
788
+ * - 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.
789
+ * - 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.
790
+ * - When rendering child components, recycling happens in two ways:
791
+ * - Components with a `key` are recycled if a previous child with the same key exists.
792
+ * - Unkeyed components are recycled if they have the same type and position in the template or partial.
793
+ * A recycled `Component` will call the `onRecycle` lifecycle method.
565
794
  * - If the active element is inside the component, it will retain focus after the render.
566
- * @return {Rasti.Component} The component instance.
795
+ * @return {Component} The component instance.
567
796
  */
568
797
  render() {
569
798
  // Prevent a last re render if view is already destroyed.
570
799
  if (this.destroyed) return this;
571
800
  // If `this.el` is not present, render the view as a string and hydrate it.
572
801
  if (!this.el) {
573
- const fragment = this.createElement('template');
574
- fragment.innerHTML = this;
575
- this.hydrate(fragment.content);
802
+ const fragment = parseHTML(this);
803
+ this.hydrate(fragment);
576
804
  return this;
577
805
  }
578
- // Update attributes.
579
- if (!this.isContainer()) {
580
- const attributes = this.getAttributes();
581
- // Remove attributes.
582
- Object.keys(attributes.remove).forEach(key => {
583
- this.el.removeAttribute(key);
584
- });
585
- // Add attributes.
586
- Object.keys(attributes.add).forEach(key => {
587
- this.el.setAttribute(key, attributes.add[key]);
588
- });
589
- }
590
- // Check for `template` to see if view has innerHTML or a child component.
591
- if (this.template) {
592
- // Store active element.
593
- const activeElement = document.activeElement;
594
-
806
+ // Clear event listeners.
807
+ this.eventsManager.reset();
808
+ // Reset position tracking.
809
+ this.pathManager.reset();
810
+ // Store active element.
811
+ const activeElement = this.isContainer() ? null : document.activeElement;
812
+ // Update elements.
813
+ this.template.elements.forEach(element => element.update());
814
+ // Store previous children.
815
+ const previousChildren = this.children;
816
+ // Clear current children.
817
+ this.children = [];
818
+ // Update interpolations.
819
+ this.template.interpolations.forEach(interpolation => {
595
820
  const nextChildren = [];
596
821
  const recycledChildren = [];
597
822
 
598
- const previousChildren = this.children;
599
- this.children = [];
600
- // Replace expressions. Set html inside of `this.el`.
601
- const inner = this.template.call(this, component => {
823
+ this.pathManager.increment();
824
+
825
+ const addChild = component => {
602
826
  let out = component;
603
- // Check if child already exists.
604
- const found = component.key && previousChildren.find(
605
- previousChild => previousChild.key === component.key
606
- );
827
+ let found = null;
828
+ // Check if child already exists by key.
829
+ if (component.key) {
830
+ found = previousChildren.find(prev => prev.key === component.key);
831
+ } else {
832
+ // Find by position and type.
833
+ found = this.pathManager.findRecyclable(component.constructor);
834
+ }
607
835
 
608
836
  if (found) {
609
837
  // If child already exists, replace it html by its root element.
610
- out = found.getRecyclePlaceholder();
838
+ out = found.getRecycledMarker();
611
839
  // Add child to recycled children.
612
- recycledChildren.push(found);
613
- // Destroy new child component. Use recycled one instead.
614
- component.destroy();
840
+ recycledChildren.push([found, component]);
841
+ // Track the component.
842
+ if (!found.key) this.pathManager.track(found);
615
843
  } else {
616
- // Not found. Add new child component.
844
+ // Add new component.
617
845
  nextChildren.push(component);
846
+ // Track the component.
847
+ this.pathManager.track(component);
618
848
  }
619
- // Component html.
849
+ // Return the component or placeholder.
620
850
  return out;
621
- });
851
+ };
622
852
 
623
- if (this.isContainer()) {
624
- if (nextChildren[0]) {
625
- const fragment = this.createElement('template');
626
- fragment.innerHTML = inner;
627
- // Add new child to dom fragment and hydrate it.
628
- this.addChild(nextChildren[0]).hydrate(fragment.content);
629
- // Get next child element.
630
- const nextEl = fragment.content.children[0];
631
- // Replace `this.el` with nextEl.
632
- this.el.replaceWith(nextEl);
633
- // Set `this.el` to nextEl.
634
- this.el = nextEl;
635
- } else if (recycledChildren[0]) {
636
- this.addChild(recycledChildren[0]);
637
- } else {
638
- throw new Error('Container component must have a child component');
639
- }
640
- } else {
641
- this.el.innerHTML = inner;
642
- // Add new children. Hydrate them.
643
- nextChildren.forEach(nextChild => {
644
- this.addChild(nextChild).hydrate(this.el);
645
- });
646
- // Replace children root elements with recycled components.
647
- recycledChildren.forEach(recycledChild => {
648
- this.addChild(recycledChild).recycle(this.el);
649
- });
650
- }
651
- // Destroy unused children.
652
- previousChildren.forEach(previousChild => {
653
- const found = recycledChildren.indexOf(previousChild) > -1;
654
- if (!found) previousChild.destroy();
853
+ const fragment = parseHTML(this.renderTemplatePart(interpolation.expression, addChild));
854
+ // Replace children root elements with recycled components.
855
+ recycledChildren.forEach(([recycled, discarded]) => {
856
+ this.addChild(recycled).recycle(fragment);
857
+ // Update props.
858
+ recycled.props.set(discarded.props.toJSON());
859
+ // Destroy discarded component.
860
+ discarded.destroy();
655
861
  });
656
- // Restore focus.
657
- if (this.el.contains(activeElement)) {
658
- activeElement.focus();
862
+ // Add new children. Hydrate them.
863
+ nextChildren.forEach(child => {
864
+ this.addChild(child).hydrate(fragment);
865
+ });
866
+
867
+ interpolation.update(fragment);
868
+ });
869
+ // Destroy unused children.
870
+ previousChildren.forEach(prev => {
871
+ if (this.children.indexOf(prev) < 0) prev.destroy();
872
+ });
873
+ // If container, set el to the child element.
874
+ if (this.isContainer()) {
875
+ this.el = this.children[0].el;
876
+ } else {
877
+ // If there are pending event types, delegate events again.
878
+ if (this.eventsManager.hasPendingTypes()) {
879
+ this.delegateEvents();
659
880
  }
660
881
  }
661
- // Call onRender lifecycle method.
662
- this.onRender.call(this, Component.RENDER_TYPE_RENDER);
882
+ // Restore focus.
883
+ if (activeElement && this.el.contains(activeElement)) {
884
+ activeElement.focus();
885
+ }
886
+ // Call onUpdate lifecycle method.
887
+ this.onUpdate.call(this);
663
888
  // Return this for chaining.
664
889
  return this;
665
890
  }
@@ -672,7 +897,7 @@ class Component extends View {
672
897
  * Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
673
898
  * @static
674
899
  * @param {string} value
675
- * @return {Rasti.SafeHTML} A safe HTML object.
900
+ * @return {SafeHTML} A safe HTML object.
676
901
  */
677
902
  static markAsSafeHTML(value) {
678
903
  return new SafeHTML(value);
@@ -681,7 +906,7 @@ class Component extends View {
681
906
  /**
682
907
  * Helper method used to extend a `Component`, creating a subclass.
683
908
  * @static
684
- * @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.
909
+ * @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.
685
910
  */
686
911
  static extend(object) {
687
912
  const Current = this;
@@ -702,27 +927,28 @@ class Component extends View {
702
927
  * appends its element into the DOM (if `el` is provided).
703
928
  * And returns the view instance.
704
929
  * @static
705
- * @param {object} options The view options.
706
- * @param {node} el Dom element to append the view element.
707
- * @param {boolean} hydrate If true, the view will hydrate existing DOM.
708
- * @return {Rasti.Component}
930
+ * @param {object} [options] The view options.
931
+ * @param {node} [el] Dom element to append the view element.
932
+ * @param {boolean} [hydrate] If true, the view will hydrate existing DOM.
933
+ * @return {Component} The component instance.
709
934
  */
710
935
  static mount(options, el, hydrate) {
711
- // Instantiate view.
712
- const view = new this(options);
936
+ // Instantiate component.
937
+ const component = new this(options);
713
938
  // If `el` is passed, mount component.
714
939
  if (el) {
715
940
  if (hydrate) {
941
+ // Generate subcomponents.
942
+ component.toString();
716
943
  // Hydrate existing DOM.
717
- view.toString();
718
- view.hydrate(el);
944
+ component.hydrate(el);
719
945
  } else {
720
946
  // Append element to the DOM.
721
- el.appendChild(view.render().el);
947
+ el.append(...component.render().getNodes());
722
948
  }
723
949
  }
724
- // Return view instance.
725
- return view;
950
+ // Return component instance.
951
+ return component;
726
952
  }
727
953
 
728
954
  /**
@@ -735,16 +961,45 @@ class Component extends View {
735
961
  * - 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.
736
962
  * ```javascript
737
963
  * const Button = Component.create`
738
- * <button class="${({ options }) => options.className}">
739
- * ${({ options }) => options.renderChildren()}
964
+ * <button class="${({ props }) => props.className}">
965
+ * ${({ props }) => props.children}
966
+ * </button>
967
+ * `;
968
+ * ```
969
+ * - Attach DOM event handlers per element using camel-cased attributes.
970
+ * Event handlers are automatically bound to the component instance (`this`).
971
+ * Internally, Rasti uses event delegation to the component's root element for performance.
972
+ *
973
+ * **Attribute Quoting:**
974
+ * - **Quoted attributes** (`onClick="${handler}"`) evaluate the expression first, useful for dynamic values
975
+ * - **Unquoted attributes** (`onClick=${handler}`) pass the function reference directly
976
+ *
977
+ * **Listener Signature:** `(event, component, matched)`
978
+ * - `event`: The native DOM event object
979
+ * - `component`: The component instance (same as `this`)
980
+ * - `matched`: The element that matched the event (useful for delegation)
981
+ *
982
+ * ```javascript
983
+ * const Button = Component.create`
984
+ * <button
985
+ * onClick=${function(event, component, matched) {
986
+ * // this === component
987
+ * console.log('Button clicked:', matched);
988
+ * }}
989
+ * onMouseOver="${({ model }) => () => model.isHovered = true}"
990
+ * onMouseOut="${({ model }) => () => model.isHovered = false}"
991
+ * >
992
+ * Click me
740
993
  * </button>
741
994
  * `;
742
995
  * ```
743
- * - 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}.
996
+ *
997
+ * If you need custom delegation (e.g., `{'click .selector': 'handler'}`),
998
+ * you may override the `events` property as described in {@link #module_view__delegateevents View.delegateEvents}.
744
999
  * - Boolean attributes should be passed in the format `attribute="${() => true}"`. `false` attributes won't be rendered. `true` attributes will be rendered without a value.
745
1000
  * ```javascript
746
1001
  * const Input = Component.create`
747
- * <input type="text" disabled=${({ options }) => options.disabled} />
1002
+ * <input type="text" disabled=${({ props }) => props.disabled} />
748
1003
  * `;
749
1004
  * ```
750
1005
  * - If the interpolated function returns a component instance, it will be added as a child component.
@@ -753,21 +1008,21 @@ class Component extends View {
753
1008
  * // Create a button component.
754
1009
  * const Button = Component.create`
755
1010
  * <button class="button">
756
- * ${({ options }) => options.renderChildren()}
1011
+ * ${({ props }) => props.children}
757
1012
  * </button>
758
1013
  * `;
759
1014
  * // Create a navigation component. Add buttons as children. Iterate over items.
760
1015
  * const Navigation = Component.create`
761
1016
  * <nav>
762
- * ${({ options }) => options.items.map(
763
- * item => Button.mount({ renderChildren: () => item.label })
1017
+ * ${({ props }) => props.items.map(
1018
+ * item => Button.mount({ children : item.label })
764
1019
  * )}
765
1020
  * </nav>
766
1021
  * `;
767
1022
  * // Create a header component. Add navigation as a child.
768
1023
  * const Header = Component.create`
769
1024
  * <header>
770
- * ${({ options }) => Navigation.mount({ items : options.items})}
1025
+ * ${({ props }) => Navigation.mount({ items : props.items})}
771
1026
  * </header>
772
1027
  * `;
773
1028
  * ```
@@ -776,13 +1031,13 @@ class Component extends View {
776
1031
  * // Create a button component.
777
1032
  * const Button = Component.create`
778
1033
  * <button class="button">
779
- * ${({ options }) => options.renderChildren()}
1034
+ * ${({ props }) => props.children}
780
1035
  * </button>
781
1036
  * `;
782
1037
  * // Create a navigation component. Add buttons as children. Iterate over items.
783
1038
  * const Navigation = Component.create`
784
1039
  * <nav>
785
- * ${({ options, partial }) => options.items.map(
1040
+ * ${({ props, partial }) => props.items.map(
786
1041
  * item => partial`<${Button}>${item.label}</${Button}>`
787
1042
  * )}
788
1043
  * </nav>
@@ -790,7 +1045,7 @@ class Component extends View {
790
1045
  * // Create a header component. Add navigation as a child.
791
1046
  * const Header = Component.create`
792
1047
  * <header>
793
- * <${Navigation} items="${({ options }) => options.items}" />
1048
+ * <${Navigation} items="${({ props }) => props.items}" />
794
1049
  * </header>
795
1050
  * `;
796
1051
  * ```
@@ -798,8 +1053,8 @@ class Component extends View {
798
1053
  * ```javascript
799
1054
  * // Create a button component.
800
1055
  * const Button = Component.create`
801
- * <button class="${({ options }) => options.className}">
802
- * ${self => self.renderChildren()}
1056
+ * <button class="${({ props }) => props.className}">
1057
+ * ${({ props }) => props.children}
803
1058
  * </button>
804
1059
  * `;
805
1060
  * // Create a container that renders a Button component.
@@ -808,104 +1063,110 @@ class Component extends View {
808
1063
  * `;
809
1064
  * // Create a container that renders a Button component, using a function.
810
1065
  * const ButtonCancel = Component.create(() => Button.mount({
811
- * className: 'cancel',
812
- * renderChildren: () => 'Cancel'
1066
+ * className : 'cancel',
1067
+ * children : 'Cancel'
813
1068
  * }));
814
1069
  * ```
815
1070
  * @static
816
- * @param {string|function} strings - HTML template for the component or a function that mounts a sub component.
1071
+ * @param {string|Function} strings - HTML template for the component or a function that mounts a sub component.
817
1072
  * @param {...*} expressions - The expressions to be interpolated within the template.
818
- * @return {Rasti.Component} The newly created component class.
1073
+ * @return {Component} The newly created component class.
819
1074
  */
820
1075
  static create(strings, ...expressions) {
821
- const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
822
1076
  // Containers can be created using create as a functions instead of a tagged template.
823
1077
  if (typeof strings === 'function') {
824
1078
  expressions = [strings];
825
1079
  strings = ['', ''];
826
1080
  }
827
-
828
- let tag, attributes, events, template;
829
- // Create output string for main template. Add placeholders for new lines.
830
- const main = expandComponents(addPlaceholders(strings, expressions), expressions);
831
-
832
- let match = main.match(
833
- new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`)
1081
+ // Create elements, interpolations and parts arrays.
1082
+ const elements = [], interpolations = [];
1083
+ const parts = splitPlaceholders(
1084
+ parseInterpolations(
1085
+ parseElements(
1086
+ expandComponents(
1087
+ addPlaceholders(
1088
+ strings,
1089
+ expressions
1090
+ ).trim(),
1091
+ expressions
1092
+ ),
1093
+ expressions,
1094
+ elements
1095
+ ),
1096
+ expressions,
1097
+ interpolations
1098
+ ),
1099
+ expressions
834
1100
  );
835
-
836
- if (match) {
837
- // It's a component with tag.
838
- const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
839
- // Get tag, attributes.
840
- tag = function() {
841
- return Component.sanitize(getExpressionResult(tagExpression, this));
842
- };
843
- // Get attributes.
844
- attributes = function() {
845
- return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
846
- };
847
- // Get events.
848
- events = function() {
849
- const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
850
-
851
- return Object.keys(onlyEvents).reduce((out, key) => {
852
- const typeListeners = getExpressionResult(onlyEvents[key], this);
853
-
854
- Object.keys(typeListeners).forEach(selector => {
855
- out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
856
- });
857
-
858
- return out;
859
- }, {});
860
- };
861
- // If there is a closing tag, get template.
862
- if (close) {
863
- const list = inner ? splitPlaceholders(inner, expressions) : [];
864
- template = function(addChild) {
865
- return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
866
- if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
867
- if (item instanceof SafeHTML) return item;
868
- if (item instanceof Component) return addChild(item);
869
- return Component.sanitize(item);
870
- }
871
- return '';
872
- }).join('');
873
- };
874
- }
875
- } else {
876
- // It's a container.
877
- match = main.match(new RegExp(`^\\s*${PH}\\s*$`));
878
-
879
- if (match) {
880
- // If there is only one expression and no tag, is a container.
881
- template = function(addChild) {
882
- // Replace expressions.
883
- return addChild(getExpressionResult(expressions[match[1]], this)).toString();
1101
+ // Create subclass for this component.
1102
+ return this.extend({
1103
+ template() {
1104
+ return {
1105
+ elements : elements.map(element => new Element({
1106
+ getSelector : element.getSelector.bind(this),
1107
+ getAttributes : element.getAttributes.bind(this)
1108
+ })),
1109
+ interpolations : interpolations.map(interpolation => new Interpolation({
1110
+ getStart : interpolation.getStart.bind(this),
1111
+ getEnd : interpolation.getEnd.bind(this),
1112
+ expression : interpolation.expression,
1113
+ isComponent,
1114
+ isElement
1115
+ })),
1116
+ parts,
884
1117
  };
885
- } else {
886
- throw new SyntaxError('Invalid component');
887
1118
  }
888
- }
889
-
890
- const Current = this;
891
- // Create subclass for this component.
892
- return Current.extend({
893
- // Set root element tag.
894
- tag,
895
- // Set attributes.
896
- attributes,
897
- // Set events.
898
- events,
899
- // Set template.
900
- template
901
1119
  });
902
1120
  }
903
1121
  }
904
1122
 
905
- Component.PLACEHOLDER_EXPRESSION = (idx) => `__RASTI_{${idx}}__`;
906
- Component.DATA_ATTRIBUTE_UID = 'data-rasti-uid';
907
- Component.RENDER_TYPE_HYDRATE = 'hydrate';
908
- Component.RENDER_TYPE_RECYCLE = 'recycle';
909
- Component.RENDER_TYPE_RENDER = 'render';
1123
+ /*
1124
+ * Attributes used to identify elements and events.
1125
+ */
1126
+ Component.ATTRIBUTE_ELEMENT = 'data-rasti-el';
1127
+ Component.ATTRIBUTE_EVENT = (type) => `data-rasti-on-${type}`;
1128
+
1129
+ /*
1130
+ * Placeholders used to temporarily replace expressions in the template.
1131
+ */
1132
+ Component.PLACEHOLDER = (idx) => `__RASTI-${idx}__`;
1133
+
1134
+ /*
1135
+ * Markers used to identify interpolation and recycled components.
1136
+ */
1137
+ Component.MARKER_RECYCLED = (uid) => `rasti-recycled-${uid}`;
1138
+ Component.MARKER_START = (uid) => `rasti-start-${uid}`;
1139
+ Component.MARKER_END = (uid) => `rasti-end-${uid}`;
1140
+
1141
+ /**
1142
+ * Components are a special kind of `View` that is designed to be easily composable,
1143
+ * making it simple to add child views and build complex user interfaces.
1144
+ * Unlike views, which are render-agnostic, components have a specific set of rendering
1145
+ * guidelines that allow for a more declarative development style.
1146
+ * 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.
1147
+ * @module
1148
+ * @extends View
1149
+ * @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`.
1150
+ * @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.
1151
+ * @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.
1152
+ * @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.
1153
+ * @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.
1154
+ * @see {@link #module_component_create Component.create}
1155
+ * @example
1156
+ * import { Component, Model } from 'rasti';
1157
+ * // Create Timer component.
1158
+ * const Timer = Component.create`
1159
+ * <div>
1160
+ * Seconds: <span>${({ model }) => model.seconds}</span>
1161
+ * </div>
1162
+ * `;
1163
+ * // Create model to store seconds.
1164
+ * const model = new Model({ seconds: 0 });
1165
+ * // Mount timer on body.
1166
+ * Timer.mount({ model }, document.body);
1167
+ * // Increment `model.seconds` every second.
1168
+ * setInterval(() => model.seconds++, 1000);
1169
+ */
1170
+ var Component$1 = Component.create`<div></div>`;
910
1171
 
911
- export { Component as default };
1172
+ export { Component$1 as default };