rasti 3.0.1 → 4.0.0-alpha.1

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