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