rasti 3.0.0-alpha.1 → 3.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,8 +104,8 @@ const Link = Component.create`
104
104
  // Create a Navigation component that renders Link components for each route.
105
105
  const Navigation = Component.create`
106
106
  <nav>
107
- ${({ options }) => options.routes.map(
108
- ({ label, href }) => Link.mount({ label, href })
107
+ ${self => self.options.routes.map(
108
+ ({ label, href }) => self.partial`<${Link} ${{ label, href }} />`
109
109
  )}
110
110
  </nav>
111
111
  `;
package/dist/rasti.js CHANGED
@@ -502,15 +502,15 @@
502
502
  * Called from the constructor if `this.el` is undefined, to ensure
503
503
  * the view has a root element.
504
504
  * @param {string} tag Tag for the element. Default to `div`
505
- * @param {object} attrs Attributes for the element.
505
+ * @param {object} attributes Attributes for the element.
506
506
  * @return {node} The created element.
507
507
  */
508
- createElement(tag = 'div', attrs = {}) {
508
+ createElement(tag = 'div', attributes = {}) {
509
509
  // Create DOM element.
510
510
  let el = document.createElement(tag);
511
511
  // Add element attributes.
512
- Object.keys(attrs)
513
- .forEach(key => el.setAttribute(key, attrs[key]));
512
+ Object.keys(attributes)
513
+ .forEach(key => el.setAttribute(key, attributes[key]));
514
514
 
515
515
  return el;
516
516
  }
@@ -631,8 +631,10 @@
631
631
  * of calling `this.template`, passing `this.model` as an argument.
632
632
  * <br><br> &#9888; **Security Notice:** The default implementation utilizes `innerHTML` on the root element
633
633
  * for rendering, which may introduce Cross-Site Scripting (XSS) risks. Ensure that any user-generated
634
- * content is properly sanitized before inserting it into the DOM. For best practices on secure data handling,
635
- * refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
634
+ * content is properly sanitized before inserting it into the DOM. You can use the @link{#module_view_sanitize View.sanitize}
635
+ * static method to escape HTML entities in a string.
636
+ * For best practices on secure data handling, refer to the
637
+ * [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
636
638
  * @return {Rasti.View} Returns `this` for chaining.
637
639
  */
638
640
  render() {
@@ -640,6 +642,25 @@
640
642
  // Return `this` for chaining.
641
643
  return this;
642
644
  }
645
+
646
+ /**
647
+ * Escape HTML entities in a string.
648
+ * Use method to sanitize user-generated content before inserting it into the DOM.
649
+ * Override this method to provide a custom escape function.
650
+ * This method is used by `Component` to escape template interpolations.
651
+ * @static
652
+ * @param {string} str String to escape.
653
+ * @return {string} Escaped string.
654
+ */
655
+ static sanitize(value) {
656
+ return `${value}`.replace(/[&<>"']/g, match => ({
657
+ '&' : '&amp;',
658
+ '<' : '&lt;',
659
+ '>' : '&gt;',
660
+ '"' : '&quot;',
661
+ '\'' : '&#039;'
662
+ }[match]));
663
+ }
643
664
  }
644
665
 
645
666
  /*
@@ -658,6 +679,19 @@
658
679
  return acc;
659
680
  }, []);
660
681
 
682
+ /*
683
+ * Wrapper class for HTML strings marked as safe.
684
+ */
685
+ class SafeHTML {
686
+ constructor(value) {
687
+ this.value = value;
688
+ }
689
+
690
+ toString() {
691
+ return this.value;
692
+ }
693
+ }
694
+
661
695
  /*
662
696
  * Same as getResult, but pass context as argument to the expression.
663
697
  * Used to evaluate expressions in the context of a component.
@@ -665,7 +699,7 @@
665
699
  * @param {any} context The context to call the expression with.
666
700
  * @return {any} The result of the evaluated expression.
667
701
  */
668
- const renderExpression = (expression, context) => getResult(expression, context, context);
702
+ const getExpressionResult = (expression, context) => getResult(expression, context, context);
669
703
 
670
704
  /*
671
705
  * Generate string with placeholders for interpolated expressions.
@@ -700,10 +734,10 @@
700
734
  // so all the components are added as children by the parent component.
701
735
  while ((match = regExp.exec(main)) !== null) {
702
736
  const before = main.slice(lastIndex, match.index);
703
- out.push(before, expressions[match[1]]);
737
+ out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
704
738
  lastIndex = match.index + match[0].length;
705
739
  }
706
- out.push(main.slice(lastIndex));
740
+ out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
707
741
 
708
742
  return out;
709
743
  };
@@ -711,15 +745,15 @@
711
745
  /*
712
746
  * Expand attributes.
713
747
  * @param attributes {array} Array of attributes as key, value pairs.
714
- * @param renderExpression {function} Function to render expressions.
748
+ * @param getExpressionResult {function} Function to render expressions.
715
749
  * @return {object}
716
750
  * @property {object} all All attributes.
717
751
  * @property {object} events Event listeners.
718
752
  * @property {object} attributes Attributes.
719
753
  */
720
- const expandAttributes = (attributes, renderExpression) => {
754
+ const expandAttributes = (attributes, getExpressionResult) => {
721
755
  const out = attributes.reduce((out, pair) => {
722
- const attribute = renderExpression(pair[0]);
756
+ const attribute = getExpressionResult(pair[0]);
723
757
  // Attribute without value.
724
758
  if (pair.length === 1) {
725
759
  if (typeof attribute === 'object') {
@@ -731,7 +765,7 @@
731
765
  }
732
766
  } else {
733
767
  // Attribute with value.
734
- const value = renderExpression(pair[1]);
768
+ const value = getExpressionResult(pair[1]);
735
769
  out.all[attribute] = value;
736
770
  }
737
771
 
@@ -782,12 +816,12 @@
782
816
  const list = splitPlaceholders(expandComponents(inner, expressions), expressions);
783
817
  // Create renderChildren function.
784
818
  renderChildren = function() {
785
- return deepFlat(list.map(item => renderExpression(item, this)));
819
+ return deepFlat(list.map(item => getExpressionResult(item, this)));
786
820
  };
787
821
  }
788
822
  // Create mount function.
789
823
  const mount = function() {
790
- const options = expandAttributes(attributes, value => renderExpression(value, this)).all;
824
+ const options = expandAttributes(attributes, value => getExpressionResult(value, this)).all;
791
825
  // Add renderChildren function to options.
792
826
  if (renderChildren) options.renderChildren = renderChildren.bind(this);
793
827
  // Mount component.
@@ -981,9 +1015,11 @@
981
1015
  const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
982
1016
 
983
1017
  if (this.attributes) Object.assign(attributes, getResult(this.attributes, this));
1018
+ // Store previous attributes.
1019
+ const previousAttributes = this.previousAttributes || {};
1020
+ this.previousAttributes = attributes;
984
1021
 
985
1022
  Object.keys(attributes).forEach(key => {
986
- // Evaluate attribute value.
987
1023
  let value = attributes[key];
988
1024
  // Transform bool attribute values
989
1025
  if (value === false) {
@@ -995,7 +1031,13 @@
995
1031
  if (value === null || typeof value === 'undefined') value = '';
996
1032
 
997
1033
  add[key] = value;
998
- html.push(`${key}="${value}"`);
1034
+ html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
1035
+ }
1036
+ });
1037
+ // Remove attributes that were in previousAttributes but not in current attributes.
1038
+ Object.keys(previousAttributes).forEach(key => {
1039
+ if (!(key in attributes)) {
1040
+ remove[key] = true;
999
1041
  }
1000
1042
  });
1001
1043
 
@@ -1093,8 +1135,9 @@
1093
1135
 
1094
1136
  /**
1095
1137
  * Tagged template helper method.
1096
- * Used to create a partial template.
1097
- * It will return a one-dimensional array with strings and expressions.
1138
+ * Used to create a partial template.
1139
+ * It will return a one-dimensional array with strings and expressions.
1140
+ * Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
1098
1141
  * @param {TemplateStringsArray} strings - Template strings.
1099
1142
  * @param {...any} expressions - Template expressions.
1100
1143
  * @return {Array} Array containing strings and expressions.
@@ -1122,13 +1165,11 @@
1122
1165
  * });
1123
1166
  */
1124
1167
  partial(strings, ...expressions) {
1125
- return deepFlat(splitPlaceholders(
1126
- expandComponents(
1127
- addPlaceholders(strings, expressions), expressions
1128
- ), expressions
1129
- ).map(item => {
1130
- return renderExpression(item, this);
1131
- }));
1168
+ return deepFlat(
1169
+ splitPlaceholders(
1170
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
1171
+ ).map(item => getExpressionResult(item, this))
1172
+ );
1132
1173
  }
1133
1174
 
1134
1175
  getRecyclePlaceholder() {
@@ -1265,6 +1306,20 @@
1265
1306
  return this;
1266
1307
  }
1267
1308
 
1309
+ /**
1310
+ * Mark a string as safe HTML to be rendered.
1311
+ * Normally you don't need to use this method, as Rasti will automatically mark strings
1312
+ * as safe HTML when the component is @link{#module_component_create created} and when
1313
+ * using the @link{#module_component__partial Component.partial} method.
1314
+ * Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
1315
+ * @static
1316
+ * @param {string} value
1317
+ * @return {Rasti.SafeHTML} A safe HTML object.
1318
+ */
1319
+ static markAsSafeHTML(value) {
1320
+ return new SafeHTML(value);
1321
+ }
1322
+
1268
1323
  /**
1269
1324
  * Helper method used to extend a `Component`, creating a subclass.
1270
1325
  * @static
@@ -1288,7 +1343,6 @@
1288
1343
  * It instantiate the Component view using options,
1289
1344
  * appends its element into the DOM (if `el` is provided).
1290
1345
  * And returns the view instance.
1291
- * <br><br> &#9888; **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
1292
1346
  * @static
1293
1347
  * @param {object} options The view options.
1294
1348
  * @param {node} el Dom element to append the view element.
@@ -1370,7 +1424,7 @@
1370
1424
  * // Create a navigation component. Add buttons as children. Iterate over items.
1371
1425
  * const Navigation = Component.create`
1372
1426
  * <nav>
1373
- * ${(self) => self.options.items.map(
1427
+ * ${self => self.options.items.map(
1374
1428
  * item => self.partial`<${Button}>${item.label}</${Button}>`
1375
1429
  * )}
1376
1430
  * </nav>
@@ -1426,18 +1480,18 @@
1426
1480
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
1427
1481
  // Get tag, attributes.
1428
1482
  tag = function() {
1429
- return renderExpression(tagExpression, this);
1483
+ return Component.sanitize(getExpressionResult(tagExpression, this));
1430
1484
  };
1431
1485
  // Get attributes.
1432
1486
  attributes = function() {
1433
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
1487
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
1434
1488
  };
1435
1489
  // Get events.
1436
1490
  events = function() {
1437
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
1491
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
1438
1492
 
1439
1493
  return Object.keys(onlyEvents).reduce((out, key) => {
1440
- const typeListeners = renderExpression(onlyEvents[key], this);
1494
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
1441
1495
 
1442
1496
  Object.keys(typeListeners).forEach(selector => {
1443
1497
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -1450,9 +1504,11 @@
1450
1504
  if (close) {
1451
1505
  const list = inner ? splitPlaceholders(inner, expressions) : [];
1452
1506
  template = function(addChild) {
1453
- return deepFlat(list.map(item => renderExpression(item, this))).map(item => {
1507
+ return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
1454
1508
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
1455
- return item instanceof Component ? addChild(item) : item;
1509
+ if (item instanceof SafeHTML) return item;
1510
+ if (item instanceof Component) return addChild(item);
1511
+ return Component.sanitize(`${item}`);
1456
1512
  }
1457
1513
  return '';
1458
1514
  }).join('');
@@ -1466,7 +1522,7 @@
1466
1522
  // If there is only one expression and no tag, is a container.
1467
1523
  template = function(addChild) {
1468
1524
  // Replace expressions.
1469
- return addChild(renderExpression(expressions[match[1]], this)).toString();
1525
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
1470
1526
  };
1471
1527
  } else {
1472
1528
  throw new SyntaxError('Invalid component');
package/dist/rasti.min.js CHANGED
@@ -1 +1 @@
1
- !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).Rasti={})}(this,(function(t){"use strict";class e{on(t,e){if("function"!=typeof e)throw new TypeError("Listener must be a function");return this.listeners||(this.listeners={}),this.listeners[t]||(this.listeners[t]=[]),this.listeners[t].push(e),()=>this.off(t,e)}once(t,e){if("function"==typeof e){const s=this,i=e;e=function(...n){i(...n),s.off(t,e)}}return this.on(t,e)}off(t,e){this.listeners&&(t?this.listeners[t]&&(e?(this.listeners[t]=this.listeners[t].filter((t=>t!==e)),this.listeners[t].length||delete this.listeners[t]):delete this.listeners[t],Object.keys(this.listeners).length||delete this.listeners):delete this.listeners)}emit(t,...e){this.listeners&&this.listeners[t]&&this.listeners[t].slice().forEach((function(t){t(...e)}))}}var s=(t,e,...s)=>"function"!=typeof t?t:t.apply(e,s);const i={el:!0,tag:!0,attributes:!0,events:!0,model:!0,template:!0,onDestroy:!0};class n extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments),this.uid="uid"+ ++n.uid,this.delegatedEventListeners=[],this.children=[],this.destroyQueue=[],Object.keys(t).forEach((e=>{i[e]&&(this[e]=t[e])})),this.ensureElement()}preinitialize(){}$(t){return this.el.querySelector(t)}$$(t){return this.el.querySelectorAll(t)}destroy(){return this.destroyChildren(),this.undelegateEvents(),this.off(),this.destroyQueue.forEach((t=>t())),this.destroyQueue=[],this.onDestroy.apply(this,arguments),this}onDestroy(){}addChild(t){return this.children.push(t),t}destroyChildren(){this.children.forEach((t=>t.destroy())),this.children=[]}ensureElement(){if(this.el)this.el=s(this.el,this);else{const t=s(this.tag,this),e=s(this.attributes,this);this.el=this.createElement(t,e)}this.delegateEvents()}createElement(t="div",e={}){let s=document.createElement(t);return Object.keys(e).forEach((t=>s.setAttribute(t,e[t]))),s}removeElement(){return this.el.parentNode.removeChild(this.el),this}delegateEvents(t){if(t||(t=s(this.events,this)),!t)return this;this.delegatedEventListeners.length&&this.undelegateEvents();let e={};return Object.keys(t).forEach((s=>{const i=s.split(" "),n=i.shift(),r=i.join(" ");let h=t[s];h=("string"==typeof h?this[h]:h).bind(this),e[n]||(e[n]=[]),e[n].push({selector:r,listener:h})})),Object.keys(e).forEach((t=>{const s=s=>{e[t].forEach((({selector:t,listener:e})=>{t&&!s.target.closest(t)||e(s,this)}))};this.delegatedEventListeners.push({type:t,listener:s}),this.el.addEventListener(t,s)})),this}undelegateEvents(){return this.delegatedEventListeners.forEach((({type:t,listener:e})=>{this.el.removeEventListener(t,e)})),this.delegatedEventListeners=[],this}render(){return this.template&&(this.el.innerHTML=this.template(this.model)),this}}n.uid=0;const r=t=>t.reduce(((t,e)=>(Array.isArray(e)?t.push(...r(e)):t.push(e),t)),[]),h=(t,e)=>s(t,e,e),l=(t,e)=>t.reduce(((t,s,i)=>(t.push(s),void 0!==e[i]&&t.push(f.PLACEHOLDER_EXPRESSION(i)),t)),[]).join(""),o=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)"),i=new RegExp(`${s}`,"g"),n=[];let r,h=0;for(;null!==(r=i.exec(t));){const s=t.slice(h,r.index);n.push(s,e[r[1]]),h=r.index+r[0].length}return n.push(t.slice(h)),n},c=(t,e)=>{const s=t.reduce(((t,s)=>{const i=e(s[0]);if(1===s.length)"object"==typeof i?t.all=Object.assign(t.all,i):"string"==typeof i&&(t.all[i]=!0);else{const n=e(s[1]);t.all[i]=n}return t}),{all:{},events:{},attributes:{}});return Object.keys(s.all).forEach((t=>{const e=t.match(/on(([A-Z]{1}[a-z]+)+)/);e&&e[1]?s.events[e[1].toLowerCase()]=s.all[t]:s.attributes[t]=s.all[t]})),s},a=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)");return t.replace(new RegExp(`<(${s})([^>]*)>([\\s\\S]*?)</(${s})>|<(${s})([^>]*)/>`,"g"),(function(){const{tag:t,attributes:s,inner:i,close:n,raw:l}=d(arguments,e);if(!(t.prototype instanceof f))return l;let u;if(n){if(t!==n)return l;const s=o(a(i,e),e);u=function(){return r(s.map((t=>h(t,this))))}}return e.push((function(){const e=c(s,(t=>h(t,this))).all;return u&&(e.renderChildren=u.bind(this)),t.mount(e)})),f.PLACEHOLDER_EXPRESSION(e.length-1)}))},d=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)"),[i,n,r,h,l,o,c,a,d,u]=t,E={raw:i,attributes:[]};let p;n?(E.tag=void 0!==r?e[r]:n,E.inner=l,E.close=void 0!==c?e[c]:o,p=h):(E.tag=void 0!==d?e[d]:a,p=u);const y=new RegExp(`(${s}|[\\w-]+)(?:=(["']?)(?:${s}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`,"g");let g;for(;null!==(g=y.exec(p));){const[,t,s,,i,n]=g,r=void 0!==s?e[s]:t,h=void 0!==i?e[i]:n;void 0!==h?E.attributes.push([r,h]):E.attributes.push([r])}return E},u={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,source:!0,track:!0,wbr:!0},E={key:!0,state:!0,onCreate:!0,onChange:!0,onRender:!0};class f extends n{constructor(t={}){super(...arguments),Object.keys(t).forEach((e=>{E[e]&&(this[e]=t[e])})),this.options=t,this.onCreate.apply(this,arguments)}subscribe(t){if(!t.on)return;const e=this.onChange.bind(this),s=t.on("change",e);return this.destroyQueue.push("function"==typeof s?s:()=>t.off("change",e)),this}isContainer(){return!(this.tag||!this.template)}ensureElement(){this.el&&(this.el=s(this.el,this),this.delegateEvents())}findElement(t){return(t||document).querySelector(`[${f.DATA_ATTRIBUTE_UID}="${this.uid}"]`)}getAttributes(){const t={},e={},i=[],n={[f.DATA_ATTRIBUTE_UID]:this.uid};return this.attributes&&Object.assign(n,s(this.attributes,this)),Object.keys(n).forEach((s=>{let r=n[s];!1===r?e[s]=!0:!0===r?(t[s]="",i.push(s)):(null==r&&(r=""),t[s]=r,i.push(`${s}="${r}"`))})),{add:t,remove:e,html:i.join(" ")}}hydrate(t){return this.model&&this.subscribe(this.model),this.state&&this.subscribe(this.state),this.isContainer()?(this.children[0].hydrate(t),this.el=this.children[0].el):(this.el=this.findElement(t),this.delegateEvents(),this.children.forEach((t=>t.hydrate(this.el)))),this.onRender.call(this,f.RENDER_TYPE_HYDRATE),this}recycle(t){if(this.isContainer())return this.children[0].recycle(t);return this.findElement(t).replaceWith(this.el),this.onRender.call(this,f.RENDER_TYPE_RECYCLE),this}destroy(){return super.destroy.apply(this,arguments),this.destroyed=!0,this}onCreate(){}onChange(){this.render()}onRender(){}onDestroy(){}partial(t,...e){return r(o(a(l(t,e),e),e).map((t=>h(t,this))))}getRecyclePlaceholder(){if(this.isContainer())return this.children[0].getRecyclePlaceholder();const t=s(this.tag,this)||"div",e=`${f.DATA_ATTRIBUTE_UID}="${this.uid}"`;return this.template||!u[t]?`<${t} ${e}></${t}>`:`<${t} ${e} />`}toString(){if(this.destroyChildren(),this.isContainer())return this.template.call(this,this.addChild.bind(this));const t=s(this.tag,this)||"div",e=this.getAttributes().html,i=this.template?this.template.call(this,this.addChild.bind(this)):"";return this.template||!u[t]?`<${t} ${e}>${i}</${t}>`:`<${t} ${e} />`}render(){if(this.destroyed)return this;if(!this.isContainer()){if(!this.el){const t=this.createElement("template");return t.innerHTML=this,this.hydrate(t.content),this}const t=this.getAttributes();Object.keys(t.remove).forEach((t=>{this.el.removeAttribute(t)})),Object.keys(t.add).forEach((e=>{this.el.setAttribute(e,t.add[e])}))}if(this.template){const t=document.activeElement,e=[],s=[],i=this.children;this.children=[];const n=this.template.call(this,(t=>{let n=t;const r=t.key&&i.find((e=>e.key===t.key));return r?(n=r.getRecyclePlaceholder(),s.push(r),t.destroy()):e.push(t),n}));if(this.isContainer())if(e[0]){const t=this.createElement("template");t.innerHTML=n,this.addChild(e[0]).hydrate(t.content);const s=t.content.children[0];this.el&&this.el.replaceWith(s),this.el=s}else{if(!s[0])throw new Error("Container component must have a child component");this.addChild(s[0])}else this.el.innerHTML=n,e.forEach((t=>{this.addChild(t).hydrate(this.el)})),s.forEach((t=>{this.addChild(t).recycle(this.el)}));i.forEach((t=>{s.indexOf(t)>-1||t.destroy()})),this.el.contains(t)&&t.focus()}return this.onRender.call(this,f.RENDER_TYPE_RENDER),this}static extend(t){const e=this;class s extends e{}return Object.assign(s.prototype,"function"==typeof t?t(e.prototype):t),s}static mount(t,e,s){const i=new this(t);return e&&(s?(i.toString(),i.hydrate(e)):e.appendChild(i.render().el)),i}static create(t,...e){const s=f.PLACEHOLDER_EXPRESSION("(\\d+)");let i,n,u,E;"function"==typeof t&&(e=[t],t=["",""]);const p=a(l(t,e),e);let y=p.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${s})([^>]*)>([\\s\\S]*?)</(\\1|${s})>\\s*$|^\\s*<([a-z]+[1-6]?|${s})([^>]*)/>\\s*$`));if(y){const{tag:t,attributes:s,inner:l,close:a}=d(y,e);if(i=function(){return h(t,this)},n=function(){return c(s,(t=>h(t,this))).attributes},u=function(){const t=c(s,(t=>h(t,this))).events;return Object.keys(t).reduce(((e,s)=>{const i=h(t[s],this);return Object.keys(i).forEach((t=>{e[`${s}${"&"===t?"":` ${t}`}`]=i[t]})),e}),{})},a){const t=l?o(l,e):[];E=function(e){return r(t.map((t=>h(t,this)))).map((t=>null!=t&&!1!==t&&!0!==t?t instanceof f?e(t):t:"")).join("")}}}else{if(y=p.match(new RegExp(`^\\s*${s}\\s*$`)),!y)throw new SyntaxError("Invalid component");E=function(t){return t(h(e[y[1]],this)).toString()}}return this.extend({tag:i,attributes:n,events:u,template:E})}}f.PLACEHOLDER_EXPRESSION=t=>`__RASTI_{${t}}__`,f.DATA_ATTRIBUTE_UID="data-rasti-uid",f.RENDER_TYPE_HYDRATE="hydrate",f.RENDER_TYPE_RECYCLE="recycle",f.RENDER_TYPE_RENDER="render",t.Component=f,t.Emitter=e,t.Model=class extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments);const e=s(this.defaults,this)||{};this.attributes=Object.assign({},e,t),this.previous={},Object.keys(this.attributes).forEach(this.defineAttribute.bind(this))}preinitialize(){}defineAttribute(t){Object.defineProperty(this,t,{get:()=>this.get(t),set:e=>{this.set(t,e)}})}get(t){return this.attributes[t]}set(t,e,...s){let i,n;"object"==typeof t?(i=t,n=[e,...s]):(i={[t]:e},n=s);const r=this._changing;this._changing=!0;const h={};r||(this.previous=Object.assign({},this.attributes)),Object.keys(i).forEach((t=>{i[t]!==this.attributes[t]&&(h[t]=i[t],this.attributes[t]=i[t])}));const l=Object.keys(h);if(l.length&&(this._pending=["change",this,h,...n]),l.forEach((t=>{this.emit(`change:${t}`,this,i[t],...n)})),r)return this;for(;this._pending;){const t=this._pending;this._pending=null,this.emit.apply(this,t)}return this._pending=null,this._changing=!1,this}toJSON(){return Object.assign({},this.attributes)}},t.View=n}));
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).Rasti={})}(this,(function(t){"use strict";class e{on(t,e){if("function"!=typeof e)throw new TypeError("Listener must be a function");return this.listeners||(this.listeners={}),this.listeners[t]||(this.listeners[t]=[]),this.listeners[t].push(e),()=>this.off(t,e)}once(t,e){if("function"==typeof e){const s=this,i=e;e=function(...n){i(...n),s.off(t,e)}}return this.on(t,e)}off(t,e){this.listeners&&(t?this.listeners[t]&&(e?(this.listeners[t]=this.listeners[t].filter((t=>t!==e)),this.listeners[t].length||delete this.listeners[t]):delete this.listeners[t],Object.keys(this.listeners).length||delete this.listeners):delete this.listeners)}emit(t,...e){this.listeners&&this.listeners[t]&&this.listeners[t].slice().forEach((function(t){t(...e)}))}}var s=(t,e,...s)=>"function"!=typeof t?t:t.apply(e,s);const i={el:!0,tag:!0,attributes:!0,events:!0,model:!0,template:!0,onDestroy:!0};class n extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments),this.uid="uid"+ ++n.uid,this.delegatedEventListeners=[],this.children=[],this.destroyQueue=[],Object.keys(t).forEach((e=>{i[e]&&(this[e]=t[e])})),this.ensureElement()}preinitialize(){}$(t){return this.el.querySelector(t)}$$(t){return this.el.querySelectorAll(t)}destroy(){return this.destroyChildren(),this.undelegateEvents(),this.off(),this.destroyQueue.forEach((t=>t())),this.destroyQueue=[],this.onDestroy.apply(this,arguments),this}onDestroy(){}addChild(t){return this.children.push(t),t}destroyChildren(){this.children.forEach((t=>t.destroy())),this.children=[]}ensureElement(){if(this.el)this.el=s(this.el,this);else{const t=s(this.tag,this),e=s(this.attributes,this);this.el=this.createElement(t,e)}this.delegateEvents()}createElement(t="div",e={}){let s=document.createElement(t);return Object.keys(e).forEach((t=>s.setAttribute(t,e[t]))),s}removeElement(){return this.el.parentNode.removeChild(this.el),this}delegateEvents(t){if(t||(t=s(this.events,this)),!t)return this;this.delegatedEventListeners.length&&this.undelegateEvents();let e={};return Object.keys(t).forEach((s=>{const i=s.split(" "),n=i.shift(),r=i.join(" ");let h=t[s];h=("string"==typeof h?this[h]:h).bind(this),e[n]||(e[n]=[]),e[n].push({selector:r,listener:h})})),Object.keys(e).forEach((t=>{const s=s=>{e[t].forEach((({selector:t,listener:e})=>{t&&!s.target.closest(t)||e(s,this)}))};this.delegatedEventListeners.push({type:t,listener:s}),this.el.addEventListener(t,s)})),this}undelegateEvents(){return this.delegatedEventListeners.forEach((({type:t,listener:e})=>{this.el.removeEventListener(t,e)})),this.delegatedEventListeners=[],this}render(){return this.template&&(this.el.innerHTML=this.template(this.model)),this}static sanitize(t){return`${t}`.replace(/[&<>"']/g,(t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[t])))}}n.uid=0;const r=t=>t.reduce(((t,e)=>(Array.isArray(e)?t.push(...r(e)):t.push(e),t)),[]);class h{constructor(t){this.value=t}toString(){return this.value}}const o=(t,e)=>s(t,e,e),l=(t,e)=>t.reduce(((t,s,i)=>(t.push(s),void 0!==e[i]&&t.push(p.PLACEHOLDER_EXPRESSION(i)),t)),[]).join(""),a=(t,e)=>{const s=p.PLACEHOLDER_EXPRESSION("(\\d+)"),i=new RegExp(`${s}`,"g"),n=[];let r,h=0;for(;null!==(r=i.exec(t));){const s=t.slice(h,r.index);n.push(p.markAsSafeHTML(s),e[r[1]]),h=r.index+r[0].length}return n.push(p.markAsSafeHTML(t.slice(h))),n},c=(t,e)=>{const s=t.reduce(((t,s)=>{const i=e(s[0]);if(1===s.length)"object"==typeof i?t.all=Object.assign(t.all,i):"string"==typeof i&&(t.all[i]=!0);else{const n=e(s[1]);t.all[i]=n}return t}),{all:{},events:{},attributes:{}});return Object.keys(s.all).forEach((t=>{const e=t.match(/on(([A-Z]{1}[a-z]+)+)/);e&&e[1]?s.events[e[1].toLowerCase()]=s.all[t]:s.attributes[t]=s.all[t]})),s},u=(t,e)=>{const s=p.PLACEHOLDER_EXPRESSION("(\\d+)");return t.replace(new RegExp(`<(${s})([^>]*)>([\\s\\S]*?)</(${s})>|<(${s})([^>]*)/>`,"g"),(function(){const{tag:t,attributes:s,inner:i,close:n,raw:h}=d(arguments,e);if(!(t.prototype instanceof p))return h;let l;if(n){if(t!==n)return h;const s=a(u(i,e),e);l=function(){return r(s.map((t=>o(t,this))))}}return e.push((function(){const e=c(s,(t=>o(t,this))).all;return l&&(e.renderChildren=l.bind(this)),t.mount(e)})),p.PLACEHOLDER_EXPRESSION(e.length-1)}))},d=(t,e)=>{const s=p.PLACEHOLDER_EXPRESSION("(\\d+)"),[i,n,r,h,o,l,a,c,u,d]=t,E={raw:i,attributes:[]};let f;n?(E.tag=void 0!==r?e[r]:n,E.inner=o,E.close=void 0!==a?e[a]:l,f=h):(E.tag=void 0!==u?e[u]:c,f=d);const y=new RegExp(`(${s}|[\\w-]+)(?:=(["']?)(?:${s}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`,"g");let g;for(;null!==(g=y.exec(f));){const[,t,s,,i,n]=g,r=void 0!==s?e[s]:t,h=void 0!==i?e[i]:n;void 0!==h?E.attributes.push([r,h]):E.attributes.push([r])}return E},E={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,source:!0,track:!0,wbr:!0},f={key:!0,state:!0,onCreate:!0,onChange:!0,onRender:!0};class p extends n{constructor(t={}){super(...arguments),Object.keys(t).forEach((e=>{f[e]&&(this[e]=t[e])})),this.options=t,this.onCreate.apply(this,arguments)}subscribe(t){if(!t.on)return;const e=this.onChange.bind(this),s=t.on("change",e);return this.destroyQueue.push("function"==typeof s?s:()=>t.off("change",e)),this}isContainer(){return!(this.tag||!this.template)}ensureElement(){this.el&&(this.el=s(this.el,this),this.delegateEvents())}findElement(t){return(t||document).querySelector(`[${p.DATA_ATTRIBUTE_UID}="${this.uid}"]`)}getAttributes(){const t={},e={},i=[],n={[p.DATA_ATTRIBUTE_UID]:this.uid};this.attributes&&Object.assign(n,s(this.attributes,this));const r=this.previousAttributes||{};return this.previousAttributes=n,Object.keys(n).forEach((s=>{let r=n[s];!1===r?e[s]=!0:!0===r?(t[s]="",i.push(s)):(null==r&&(r=""),t[s]=r,i.push(`${p.sanitize(s)}="${p.sanitize(r)}"`))})),Object.keys(r).forEach((t=>{t in n||(e[t]=!0)})),{add:t,remove:e,html:i.join(" ")}}hydrate(t){return this.model&&this.subscribe(this.model),this.state&&this.subscribe(this.state),this.isContainer()?(this.children[0].hydrate(t),this.el=this.children[0].el):(this.el=this.findElement(t),this.delegateEvents(),this.children.forEach((t=>t.hydrate(this.el)))),this.onRender.call(this,p.RENDER_TYPE_HYDRATE),this}recycle(t){if(this.isContainer())return this.children[0].recycle(t);return this.findElement(t).replaceWith(this.el),this.onRender.call(this,p.RENDER_TYPE_RECYCLE),this}destroy(){return super.destroy.apply(this,arguments),this.destroyed=!0,this}onCreate(){}onChange(){this.render()}onRender(){}onDestroy(){}partial(t,...e){return r(a(u(l(t,e),e),e).map((t=>o(t,this))))}getRecyclePlaceholder(){if(this.isContainer())return this.children[0].getRecyclePlaceholder();const t=s(this.tag,this)||"div",e=`${p.DATA_ATTRIBUTE_UID}="${this.uid}"`;return this.template||!E[t]?`<${t} ${e}></${t}>`:`<${t} ${e} />`}toString(){if(this.destroyChildren(),this.isContainer())return this.template.call(this,this.addChild.bind(this));const t=s(this.tag,this)||"div",e=this.getAttributes().html,i=this.template?this.template.call(this,this.addChild.bind(this)):"";return this.template||!E[t]?`<${t} ${e}>${i}</${t}>`:`<${t} ${e} />`}render(){if(this.destroyed)return this;if(!this.isContainer()){if(!this.el){const t=this.createElement("template");return t.innerHTML=this,this.hydrate(t.content),this}const t=this.getAttributes();Object.keys(t.remove).forEach((t=>{this.el.removeAttribute(t)})),Object.keys(t.add).forEach((e=>{this.el.setAttribute(e,t.add[e])}))}if(this.template){const t=document.activeElement,e=[],s=[],i=this.children;this.children=[];const n=this.template.call(this,(t=>{let n=t;const r=t.key&&i.find((e=>e.key===t.key));return r?(n=r.getRecyclePlaceholder(),s.push(r),t.destroy()):e.push(t),n}));if(this.isContainer())if(e[0]){const t=this.createElement("template");t.innerHTML=n,this.addChild(e[0]).hydrate(t.content);const s=t.content.children[0];this.el&&this.el.replaceWith(s),this.el=s}else{if(!s[0])throw new Error("Container component must have a child component");this.addChild(s[0])}else this.el.innerHTML=n,e.forEach((t=>{this.addChild(t).hydrate(this.el)})),s.forEach((t=>{this.addChild(t).recycle(this.el)}));i.forEach((t=>{s.indexOf(t)>-1||t.destroy()})),this.el.contains(t)&&t.focus()}return this.onRender.call(this,p.RENDER_TYPE_RENDER),this}static markAsSafeHTML(t){return new h(t)}static extend(t){const e=this;class s extends e{}return Object.assign(s.prototype,"function"==typeof t?t(e.prototype):t),s}static mount(t,e,s){const i=new this(t);return e&&(s?(i.toString(),i.hydrate(e)):e.appendChild(i.render().el)),i}static create(t,...e){const s=p.PLACEHOLDER_EXPRESSION("(\\d+)");let i,n,E,f;"function"==typeof t&&(e=[t],t=["",""]);const y=u(l(t,e),e);let g=y.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${s})([^>]*)>([\\s\\S]*?)</(\\1|${s})>\\s*$|^\\s*<([a-z]+[1-6]?|${s})([^>]*)/>\\s*$`));if(g){const{tag:t,attributes:s,inner:l,close:u}=d(g,e);if(i=function(){return p.sanitize(o(t,this))},n=function(){return c(s,(t=>o(t,this))).attributes},E=function(){const t=c(s,(t=>o(t,this))).events;return Object.keys(t).reduce(((e,s)=>{const i=o(t[s],this);return Object.keys(i).forEach((t=>{e[`${s}${"&"===t?"":` ${t}`}`]=i[t]})),e}),{})},u){const t=l?a(l,e):[];f=function(e){return r(t.map((t=>o(t,this)))).map((t=>null!=t&&!1!==t&&!0!==t?t instanceof h?t:t instanceof p?e(t):p.sanitize(`${t}`):"")).join("")}}}else{if(g=y.match(new RegExp(`^\\s*${s}\\s*$`)),!g)throw new SyntaxError("Invalid component");f=function(t){return t(o(e[g[1]],this)).toString()}}return this.extend({tag:i,attributes:n,events:E,template:f})}}p.PLACEHOLDER_EXPRESSION=t=>`__RASTI_{${t}}__`,p.DATA_ATTRIBUTE_UID="data-rasti-uid",p.RENDER_TYPE_HYDRATE="hydrate",p.RENDER_TYPE_RECYCLE="recycle",p.RENDER_TYPE_RENDER="render",t.Component=p,t.Emitter=e,t.Model=class extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments);const e=s(this.defaults,this)||{};this.attributes=Object.assign({},e,t),this.previous={},Object.keys(this.attributes).forEach(this.defineAttribute.bind(this))}preinitialize(){}defineAttribute(t){Object.defineProperty(this,t,{get:()=>this.get(t),set:e=>{this.set(t,e)}})}get(t){return this.attributes[t]}set(t,e,...s){let i,n;"object"==typeof t?(i=t,n=[e,...s]):(i={[t]:e},n=s);const r=this._changing;this._changing=!0;const h={};r||(this.previous=Object.assign({},this.attributes)),Object.keys(i).forEach((t=>{i[t]!==this.attributes[t]&&(h[t]=i[t],this.attributes[t]=i[t])}));const o=Object.keys(h);if(o.length&&(this._pending=["change",this,h,...n]),o.forEach((t=>{this.emit(`change:${t}`,this,i[t],...n)})),r)return this;for(;this._pending;){const t=this._pending;this._pending=null,this.emit.apply(this,t)}return this._pending=null,this._changing=!1,this}toJSON(){return Object.assign({},this.attributes)}},t.View=n}));
package/es/Component.js CHANGED
@@ -3,6 +3,19 @@ import getResult from './utils/getResult.js';
3
3
  import deepFlat from './utils/deepFlat.js';
4
4
  import './Emitter.js';
5
5
 
6
+ /*
7
+ * Wrapper class for HTML strings marked as safe.
8
+ */
9
+ class SafeHTML {
10
+ constructor(value) {
11
+ this.value = value;
12
+ }
13
+
14
+ toString() {
15
+ return this.value;
16
+ }
17
+ }
18
+
6
19
  /*
7
20
  * Same as getResult, but pass context as argument to the expression.
8
21
  * Used to evaluate expressions in the context of a component.
@@ -10,7 +23,7 @@ import './Emitter.js';
10
23
  * @param {any} context The context to call the expression with.
11
24
  * @return {any} The result of the evaluated expression.
12
25
  */
13
- const renderExpression = (expression, context) => getResult(expression, context, context);
26
+ const getExpressionResult = (expression, context) => getResult(expression, context, context);
14
27
 
15
28
  /*
16
29
  * Generate string with placeholders for interpolated expressions.
@@ -45,10 +58,10 @@ const splitPlaceholders = (main, expressions) => {
45
58
  // so all the components are added as children by the parent component.
46
59
  while ((match = regExp.exec(main)) !== null) {
47
60
  const before = main.slice(lastIndex, match.index);
48
- out.push(before, expressions[match[1]]);
61
+ out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
49
62
  lastIndex = match.index + match[0].length;
50
63
  }
51
- out.push(main.slice(lastIndex));
64
+ out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
52
65
 
53
66
  return out;
54
67
  };
@@ -56,15 +69,15 @@ const splitPlaceholders = (main, expressions) => {
56
69
  /*
57
70
  * Expand attributes.
58
71
  * @param attributes {array} Array of attributes as key, value pairs.
59
- * @param renderExpression {function} Function to render expressions.
72
+ * @param getExpressionResult {function} Function to render expressions.
60
73
  * @return {object}
61
74
  * @property {object} all All attributes.
62
75
  * @property {object} events Event listeners.
63
76
  * @property {object} attributes Attributes.
64
77
  */
65
- const expandAttributes = (attributes, renderExpression) => {
78
+ const expandAttributes = (attributes, getExpressionResult) => {
66
79
  const out = attributes.reduce((out, pair) => {
67
- const attribute = renderExpression(pair[0]);
80
+ const attribute = getExpressionResult(pair[0]);
68
81
  // Attribute without value.
69
82
  if (pair.length === 1) {
70
83
  if (typeof attribute === 'object') {
@@ -76,7 +89,7 @@ const expandAttributes = (attributes, renderExpression) => {
76
89
  }
77
90
  } else {
78
91
  // Attribute with value.
79
- const value = renderExpression(pair[1]);
92
+ const value = getExpressionResult(pair[1]);
80
93
  out.all[attribute] = value;
81
94
  }
82
95
 
@@ -127,12 +140,12 @@ const expandComponents = (main, expressions) => {
127
140
  const list = splitPlaceholders(expandComponents(inner, expressions), expressions);
128
141
  // Create renderChildren function.
129
142
  renderChildren = function() {
130
- return deepFlat(list.map(item => renderExpression(item, this)));
143
+ return deepFlat(list.map(item => getExpressionResult(item, this)));
131
144
  };
132
145
  }
133
146
  // Create mount function.
134
147
  const mount = function() {
135
- const options = expandAttributes(attributes, value => renderExpression(value, this)).all;
148
+ const options = expandAttributes(attributes, value => getExpressionResult(value, this)).all;
136
149
  // Add renderChildren function to options.
137
150
  if (renderChildren) options.renderChildren = renderChildren.bind(this);
138
151
  // Mount component.
@@ -326,9 +339,11 @@ class Component extends View {
326
339
  const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
327
340
 
328
341
  if (this.attributes) Object.assign(attributes, getResult(this.attributes, this));
342
+ // Store previous attributes.
343
+ const previousAttributes = this.previousAttributes || {};
344
+ this.previousAttributes = attributes;
329
345
 
330
346
  Object.keys(attributes).forEach(key => {
331
- // Evaluate attribute value.
332
347
  let value = attributes[key];
333
348
  // Transform bool attribute values
334
349
  if (value === false) {
@@ -340,7 +355,13 @@ class Component extends View {
340
355
  if (value === null || typeof value === 'undefined') value = '';
341
356
 
342
357
  add[key] = value;
343
- html.push(`${key}="${value}"`);
358
+ html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
359
+ }
360
+ });
361
+ // Remove attributes that were in previousAttributes but not in current attributes.
362
+ Object.keys(previousAttributes).forEach(key => {
363
+ if (!(key in attributes)) {
364
+ remove[key] = true;
344
365
  }
345
366
  });
346
367
 
@@ -438,8 +459,9 @@ class Component extends View {
438
459
 
439
460
  /**
440
461
  * Tagged template helper method.
441
- * Used to create a partial template.
442
- * It will return a one-dimensional array with strings and expressions.
462
+ * Used to create a partial template.
463
+ * It will return a one-dimensional array with strings and expressions.
464
+ * Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
443
465
  * @param {TemplateStringsArray} strings - Template strings.
444
466
  * @param {...any} expressions - Template expressions.
445
467
  * @return {Array} Array containing strings and expressions.
@@ -467,13 +489,11 @@ class Component extends View {
467
489
  * });
468
490
  */
469
491
  partial(strings, ...expressions) {
470
- return deepFlat(splitPlaceholders(
471
- expandComponents(
472
- addPlaceholders(strings, expressions), expressions
473
- ), expressions
474
- ).map(item => {
475
- return renderExpression(item, this);
476
- }));
492
+ return deepFlat(
493
+ splitPlaceholders(
494
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
495
+ ).map(item => getExpressionResult(item, this))
496
+ );
477
497
  }
478
498
 
479
499
  getRecyclePlaceholder() {
@@ -610,6 +630,20 @@ class Component extends View {
610
630
  return this;
611
631
  }
612
632
 
633
+ /**
634
+ * Mark a string as safe HTML to be rendered.
635
+ * Normally you don't need to use this method, as Rasti will automatically mark strings
636
+ * as safe HTML when the component is @link{#module_component_create created} and when
637
+ * using the @link{#module_component__partial Component.partial} method.
638
+ * Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
639
+ * @static
640
+ * @param {string} value
641
+ * @return {Rasti.SafeHTML} A safe HTML object.
642
+ */
643
+ static markAsSafeHTML(value) {
644
+ return new SafeHTML(value);
645
+ }
646
+
613
647
  /**
614
648
  * Helper method used to extend a `Component`, creating a subclass.
615
649
  * @static
@@ -633,7 +667,6 @@ class Component extends View {
633
667
  * It instantiate the Component view using options,
634
668
  * appends its element into the DOM (if `el` is provided).
635
669
  * And returns the view instance.
636
- * <br><br> &#9888; **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
637
670
  * @static
638
671
  * @param {object} options The view options.
639
672
  * @param {node} el Dom element to append the view element.
@@ -715,7 +748,7 @@ class Component extends View {
715
748
  * // Create a navigation component. Add buttons as children. Iterate over items.
716
749
  * const Navigation = Component.create`
717
750
  * <nav>
718
- * ${(self) => self.options.items.map(
751
+ * ${self => self.options.items.map(
719
752
  * item => self.partial`<${Button}>${item.label}</${Button}>`
720
753
  * )}
721
754
  * </nav>
@@ -771,18 +804,18 @@ class Component extends View {
771
804
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
772
805
  // Get tag, attributes.
773
806
  tag = function() {
774
- return renderExpression(tagExpression, this);
807
+ return Component.sanitize(getExpressionResult(tagExpression, this));
775
808
  };
776
809
  // Get attributes.
777
810
  attributes = function() {
778
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
811
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
779
812
  };
780
813
  // Get events.
781
814
  events = function() {
782
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
815
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
783
816
 
784
817
  return Object.keys(onlyEvents).reduce((out, key) => {
785
- const typeListeners = renderExpression(onlyEvents[key], this);
818
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
786
819
 
787
820
  Object.keys(typeListeners).forEach(selector => {
788
821
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -795,9 +828,11 @@ class Component extends View {
795
828
  if (close) {
796
829
  const list = inner ? splitPlaceholders(inner, expressions) : [];
797
830
  template = function(addChild) {
798
- return deepFlat(list.map(item => renderExpression(item, this))).map(item => {
831
+ return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
799
832
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
800
- return item instanceof Component ? addChild(item) : item;
833
+ if (item instanceof SafeHTML) return item;
834
+ if (item instanceof Component) return addChild(item);
835
+ return Component.sanitize(`${item}`);
801
836
  }
802
837
  return '';
803
838
  }).join('');
@@ -811,7 +846,7 @@ class Component extends View {
811
846
  // If there is only one expression and no tag, is a container.
812
847
  template = function(addChild) {
813
848
  // Replace expressions.
814
- return addChild(renderExpression(expressions[match[1]], this)).toString();
849
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
815
850
  };
816
851
  } else {
817
852
  throw new SyntaxError('Invalid component');
package/es/View.js CHANGED
@@ -183,15 +183,15 @@ class View extends Emitter {
183
183
  * Called from the constructor if `this.el` is undefined, to ensure
184
184
  * the view has a root element.
185
185
  * @param {string} tag Tag for the element. Default to `div`
186
- * @param {object} attrs Attributes for the element.
186
+ * @param {object} attributes Attributes for the element.
187
187
  * @return {node} The created element.
188
188
  */
189
- createElement(tag = 'div', attrs = {}) {
189
+ createElement(tag = 'div', attributes = {}) {
190
190
  // Create DOM element.
191
191
  let el = document.createElement(tag);
192
192
  // Add element attributes.
193
- Object.keys(attrs)
194
- .forEach(key => el.setAttribute(key, attrs[key]));
193
+ Object.keys(attributes)
194
+ .forEach(key => el.setAttribute(key, attributes[key]));
195
195
 
196
196
  return el;
197
197
  }
@@ -312,8 +312,10 @@ class View extends Emitter {
312
312
  * of calling `this.template`, passing `this.model` as an argument.
313
313
  * <br><br> &#9888; **Security Notice:** The default implementation utilizes `innerHTML` on the root element
314
314
  * for rendering, which may introduce Cross-Site Scripting (XSS) risks. Ensure that any user-generated
315
- * content is properly sanitized before inserting it into the DOM. For best practices on secure data handling,
316
- * refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
315
+ * content is properly sanitized before inserting it into the DOM. You can use the @link{#module_view_sanitize View.sanitize}
316
+ * static method to escape HTML entities in a string.
317
+ * For best practices on secure data handling, refer to the
318
+ * [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
317
319
  * @return {Rasti.View} Returns `this` for chaining.
318
320
  */
319
321
  render() {
@@ -321,6 +323,25 @@ class View extends Emitter {
321
323
  // Return `this` for chaining.
322
324
  return this;
323
325
  }
326
+
327
+ /**
328
+ * Escape HTML entities in a string.
329
+ * Use method to sanitize user-generated content before inserting it into the DOM.
330
+ * Override this method to provide a custom escape function.
331
+ * This method is used by `Component` to escape template interpolations.
332
+ * @static
333
+ * @param {string} str String to escape.
334
+ * @return {string} Escaped string.
335
+ */
336
+ static sanitize(value) {
337
+ return `${value}`.replace(/[&<>"']/g, match => ({
338
+ '&' : '&amp;',
339
+ '<' : '&lt;',
340
+ '>' : '&gt;',
341
+ '"' : '&quot;',
342
+ '\'' : '&#039;'
343
+ }[match]));
344
+ }
324
345
  }
325
346
 
326
347
  /*
package/lib/Component.cjs CHANGED
@@ -5,6 +5,19 @@ var utils_getResult = require('./utils/getResult.cjs');
5
5
  var utils_deepFlat = require('./utils/deepFlat.cjs');
6
6
  require('./Emitter.cjs');
7
7
 
8
+ /*
9
+ * Wrapper class for HTML strings marked as safe.
10
+ */
11
+ class SafeHTML {
12
+ constructor(value) {
13
+ this.value = value;
14
+ }
15
+
16
+ toString() {
17
+ return this.value;
18
+ }
19
+ }
20
+
8
21
  /*
9
22
  * Same as getResult, but pass context as argument to the expression.
10
23
  * Used to evaluate expressions in the context of a component.
@@ -12,7 +25,7 @@ require('./Emitter.cjs');
12
25
  * @param {any} context The context to call the expression with.
13
26
  * @return {any} The result of the evaluated expression.
14
27
  */
15
- const renderExpression = (expression, context) => utils_getResult(expression, context, context);
28
+ const getExpressionResult = (expression, context) => utils_getResult(expression, context, context);
16
29
 
17
30
  /*
18
31
  * Generate string with placeholders for interpolated expressions.
@@ -47,10 +60,10 @@ const splitPlaceholders = (main, expressions) => {
47
60
  // so all the components are added as children by the parent component.
48
61
  while ((match = regExp.exec(main)) !== null) {
49
62
  const before = main.slice(lastIndex, match.index);
50
- out.push(before, expressions[match[1]]);
63
+ out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
51
64
  lastIndex = match.index + match[0].length;
52
65
  }
53
- out.push(main.slice(lastIndex));
66
+ out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
54
67
 
55
68
  return out;
56
69
  };
@@ -58,15 +71,15 @@ const splitPlaceholders = (main, expressions) => {
58
71
  /*
59
72
  * Expand attributes.
60
73
  * @param attributes {array} Array of attributes as key, value pairs.
61
- * @param renderExpression {function} Function to render expressions.
74
+ * @param getExpressionResult {function} Function to render expressions.
62
75
  * @return {object}
63
76
  * @property {object} all All attributes.
64
77
  * @property {object} events Event listeners.
65
78
  * @property {object} attributes Attributes.
66
79
  */
67
- const expandAttributes = (attributes, renderExpression) => {
80
+ const expandAttributes = (attributes, getExpressionResult) => {
68
81
  const out = attributes.reduce((out, pair) => {
69
- const attribute = renderExpression(pair[0]);
82
+ const attribute = getExpressionResult(pair[0]);
70
83
  // Attribute without value.
71
84
  if (pair.length === 1) {
72
85
  if (typeof attribute === 'object') {
@@ -78,7 +91,7 @@ const expandAttributes = (attributes, renderExpression) => {
78
91
  }
79
92
  } else {
80
93
  // Attribute with value.
81
- const value = renderExpression(pair[1]);
94
+ const value = getExpressionResult(pair[1]);
82
95
  out.all[attribute] = value;
83
96
  }
84
97
 
@@ -129,12 +142,12 @@ const expandComponents = (main, expressions) => {
129
142
  const list = splitPlaceholders(expandComponents(inner, expressions), expressions);
130
143
  // Create renderChildren function.
131
144
  renderChildren = function() {
132
- return utils_deepFlat(list.map(item => renderExpression(item, this)));
145
+ return utils_deepFlat(list.map(item => getExpressionResult(item, this)));
133
146
  };
134
147
  }
135
148
  // Create mount function.
136
149
  const mount = function() {
137
- const options = expandAttributes(attributes, value => renderExpression(value, this)).all;
150
+ const options = expandAttributes(attributes, value => getExpressionResult(value, this)).all;
138
151
  // Add renderChildren function to options.
139
152
  if (renderChildren) options.renderChildren = renderChildren.bind(this);
140
153
  // Mount component.
@@ -328,9 +341,11 @@ class Component extends View {
328
341
  const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
329
342
 
330
343
  if (this.attributes) Object.assign(attributes, utils_getResult(this.attributes, this));
344
+ // Store previous attributes.
345
+ const previousAttributes = this.previousAttributes || {};
346
+ this.previousAttributes = attributes;
331
347
 
332
348
  Object.keys(attributes).forEach(key => {
333
- // Evaluate attribute value.
334
349
  let value = attributes[key];
335
350
  // Transform bool attribute values
336
351
  if (value === false) {
@@ -342,7 +357,13 @@ class Component extends View {
342
357
  if (value === null || typeof value === 'undefined') value = '';
343
358
 
344
359
  add[key] = value;
345
- html.push(`${key}="${value}"`);
360
+ html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
361
+ }
362
+ });
363
+ // Remove attributes that were in previousAttributes but not in current attributes.
364
+ Object.keys(previousAttributes).forEach(key => {
365
+ if (!(key in attributes)) {
366
+ remove[key] = true;
346
367
  }
347
368
  });
348
369
 
@@ -440,8 +461,9 @@ class Component extends View {
440
461
 
441
462
  /**
442
463
  * Tagged template helper method.
443
- * Used to create a partial template.
444
- * It will return a one-dimensional array with strings and expressions.
464
+ * Used to create a partial template.
465
+ * It will return a one-dimensional array with strings and expressions.
466
+ * Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
445
467
  * @param {TemplateStringsArray} strings - Template strings.
446
468
  * @param {...any} expressions - Template expressions.
447
469
  * @return {Array} Array containing strings and expressions.
@@ -469,13 +491,11 @@ class Component extends View {
469
491
  * });
470
492
  */
471
493
  partial(strings, ...expressions) {
472
- return utils_deepFlat(splitPlaceholders(
473
- expandComponents(
474
- addPlaceholders(strings, expressions), expressions
475
- ), expressions
476
- ).map(item => {
477
- return renderExpression(item, this);
478
- }));
494
+ return utils_deepFlat(
495
+ splitPlaceholders(
496
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
497
+ ).map(item => getExpressionResult(item, this))
498
+ );
479
499
  }
480
500
 
481
501
  getRecyclePlaceholder() {
@@ -612,6 +632,20 @@ class Component extends View {
612
632
  return this;
613
633
  }
614
634
 
635
+ /**
636
+ * Mark a string as safe HTML to be rendered.
637
+ * Normally you don't need to use this method, as Rasti will automatically mark strings
638
+ * as safe HTML when the component is @link{#module_component_create created} and when
639
+ * using the @link{#module_component__partial Component.partial} method.
640
+ * Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
641
+ * @static
642
+ * @param {string} value
643
+ * @return {Rasti.SafeHTML} A safe HTML object.
644
+ */
645
+ static markAsSafeHTML(value) {
646
+ return new SafeHTML(value);
647
+ }
648
+
615
649
  /**
616
650
  * Helper method used to extend a `Component`, creating a subclass.
617
651
  * @static
@@ -635,7 +669,6 @@ class Component extends View {
635
669
  * It instantiate the Component view using options,
636
670
  * appends its element into the DOM (if `el` is provided).
637
671
  * And returns the view instance.
638
- * <br><br> &#9888; **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
639
672
  * @static
640
673
  * @param {object} options The view options.
641
674
  * @param {node} el Dom element to append the view element.
@@ -717,7 +750,7 @@ class Component extends View {
717
750
  * // Create a navigation component. Add buttons as children. Iterate over items.
718
751
  * const Navigation = Component.create`
719
752
  * <nav>
720
- * ${(self) => self.options.items.map(
753
+ * ${self => self.options.items.map(
721
754
  * item => self.partial`<${Button}>${item.label}</${Button}>`
722
755
  * )}
723
756
  * </nav>
@@ -773,18 +806,18 @@ class Component extends View {
773
806
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
774
807
  // Get tag, attributes.
775
808
  tag = function() {
776
- return renderExpression(tagExpression, this);
809
+ return Component.sanitize(getExpressionResult(tagExpression, this));
777
810
  };
778
811
  // Get attributes.
779
812
  attributes = function() {
780
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
813
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
781
814
  };
782
815
  // Get events.
783
816
  events = function() {
784
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
817
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
785
818
 
786
819
  return Object.keys(onlyEvents).reduce((out, key) => {
787
- const typeListeners = renderExpression(onlyEvents[key], this);
820
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
788
821
 
789
822
  Object.keys(typeListeners).forEach(selector => {
790
823
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -797,9 +830,11 @@ class Component extends View {
797
830
  if (close) {
798
831
  const list = inner ? splitPlaceholders(inner, expressions) : [];
799
832
  template = function(addChild) {
800
- return utils_deepFlat(list.map(item => renderExpression(item, this))).map(item => {
833
+ return utils_deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
801
834
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
802
- return item instanceof Component ? addChild(item) : item;
835
+ if (item instanceof SafeHTML) return item;
836
+ if (item instanceof Component) return addChild(item);
837
+ return Component.sanitize(`${item}`);
803
838
  }
804
839
  return '';
805
840
  }).join('');
@@ -813,7 +848,7 @@ class Component extends View {
813
848
  // If there is only one expression and no tag, is a container.
814
849
  template = function(addChild) {
815
850
  // Replace expressions.
816
- return addChild(renderExpression(expressions[match[1]], this)).toString();
851
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
817
852
  };
818
853
  } else {
819
854
  throw new SyntaxError('Invalid component');
package/lib/View.cjs CHANGED
@@ -185,15 +185,15 @@ class View extends Emitter {
185
185
  * Called from the constructor if `this.el` is undefined, to ensure
186
186
  * the view has a root element.
187
187
  * @param {string} tag Tag for the element. Default to `div`
188
- * @param {object} attrs Attributes for the element.
188
+ * @param {object} attributes Attributes for the element.
189
189
  * @return {node} The created element.
190
190
  */
191
- createElement(tag = 'div', attrs = {}) {
191
+ createElement(tag = 'div', attributes = {}) {
192
192
  // Create DOM element.
193
193
  let el = document.createElement(tag);
194
194
  // Add element attributes.
195
- Object.keys(attrs)
196
- .forEach(key => el.setAttribute(key, attrs[key]));
195
+ Object.keys(attributes)
196
+ .forEach(key => el.setAttribute(key, attributes[key]));
197
197
 
198
198
  return el;
199
199
  }
@@ -314,8 +314,10 @@ class View extends Emitter {
314
314
  * of calling `this.template`, passing `this.model` as an argument.
315
315
  * <br><br> &#9888; **Security Notice:** The default implementation utilizes `innerHTML` on the root element
316
316
  * for rendering, which may introduce Cross-Site Scripting (XSS) risks. Ensure that any user-generated
317
- * content is properly sanitized before inserting it into the DOM. For best practices on secure data handling,
318
- * refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
317
+ * content is properly sanitized before inserting it into the DOM. You can use the @link{#module_view_sanitize View.sanitize}
318
+ * static method to escape HTML entities in a string.
319
+ * For best practices on secure data handling, refer to the
320
+ * [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
319
321
  * @return {Rasti.View} Returns `this` for chaining.
320
322
  */
321
323
  render() {
@@ -323,6 +325,25 @@ class View extends Emitter {
323
325
  // Return `this` for chaining.
324
326
  return this;
325
327
  }
328
+
329
+ /**
330
+ * Escape HTML entities in a string.
331
+ * Use method to sanitize user-generated content before inserting it into the DOM.
332
+ * Override this method to provide a custom escape function.
333
+ * This method is used by `Component` to escape template interpolations.
334
+ * @static
335
+ * @param {string} str String to escape.
336
+ * @return {string} Escaped string.
337
+ */
338
+ static sanitize(value) {
339
+ return `${value}`.replace(/[&<>"']/g, match => ({
340
+ '&' : '&amp;',
341
+ '<' : '&lt;',
342
+ '>' : '&gt;',
343
+ '"' : '&quot;',
344
+ '\'' : '&#039;'
345
+ }[match]));
346
+ }
326
347
  }
327
348
 
328
349
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rasti",
3
- "version": "3.0.0-alpha.1",
3
+ "version": "3.0.0-alpha.3",
4
4
  "description": "Modern MVC for building user interfaces",
5
5
  "type": "module",
6
6
  "main": "lib/index.cjs",
package/src/Component.js CHANGED
@@ -2,6 +2,19 @@ import View from './View.js';
2
2
  import getResult from './utils/getResult.js';
3
3
  import deepFlat from './utils/deepFlat.js';
4
4
 
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
+
5
18
  /*
6
19
  * Same as getResult, but pass context as argument to the expression.
7
20
  * Used to evaluate expressions in the context of a component.
@@ -9,7 +22,7 @@ import deepFlat from './utils/deepFlat.js';
9
22
  * @param {any} context The context to call the expression with.
10
23
  * @return {any} The result of the evaluated expression.
11
24
  */
12
- const renderExpression = (expression, context) => getResult(expression, context, context);
25
+ const getExpressionResult = (expression, context) => getResult(expression, context, context);
13
26
 
14
27
  /*
15
28
  * Generate string with placeholders for interpolated expressions.
@@ -44,10 +57,10 @@ const splitPlaceholders = (main, expressions) => {
44
57
  // so all the components are added as children by the parent component.
45
58
  while ((match = regExp.exec(main)) !== null) {
46
59
  const before = main.slice(lastIndex, match.index);
47
- out.push(before, expressions[match[1]]);
60
+ out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
48
61
  lastIndex = match.index + match[0].length;
49
62
  }
50
- out.push(main.slice(lastIndex));
63
+ out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
51
64
 
52
65
  return out;
53
66
  };
@@ -55,15 +68,15 @@ const splitPlaceholders = (main, expressions) => {
55
68
  /*
56
69
  * Expand attributes.
57
70
  * @param attributes {array} Array of attributes as key, value pairs.
58
- * @param renderExpression {function} Function to render expressions.
71
+ * @param getExpressionResult {function} Function to render expressions.
59
72
  * @return {object}
60
73
  * @property {object} all All attributes.
61
74
  * @property {object} events Event listeners.
62
75
  * @property {object} attributes Attributes.
63
76
  */
64
- const expandAttributes = (attributes, renderExpression) => {
77
+ const expandAttributes = (attributes, getExpressionResult) => {
65
78
  const out = attributes.reduce((out, pair) => {
66
- const attribute = renderExpression(pair[0]);
79
+ const attribute = getExpressionResult(pair[0]);
67
80
  // Attribute without value.
68
81
  if (pair.length === 1) {
69
82
  if (typeof attribute === 'object') {
@@ -75,7 +88,7 @@ const expandAttributes = (attributes, renderExpression) => {
75
88
  }
76
89
  } else {
77
90
  // Attribute with value.
78
- const value = renderExpression(pair[1]);
91
+ const value = getExpressionResult(pair[1]);
79
92
  out.all[attribute] = value;
80
93
  }
81
94
 
@@ -126,12 +139,12 @@ const expandComponents = (main, expressions) => {
126
139
  const list = splitPlaceholders(expandComponents(inner, expressions), expressions);
127
140
  // Create renderChildren function.
128
141
  renderChildren = function() {
129
- return deepFlat(list.map(item => renderExpression(item, this)));
142
+ return deepFlat(list.map(item => getExpressionResult(item, this)));
130
143
  };
131
144
  }
132
145
  // Create mount function.
133
146
  const mount = function() {
134
- const options = expandAttributes(attributes, value => renderExpression(value, this)).all;
147
+ const options = expandAttributes(attributes, value => getExpressionResult(value, this)).all;
135
148
  // Add renderChildren function to options.
136
149
  if (renderChildren) options.renderChildren = renderChildren.bind(this);
137
150
  // Mount component.
@@ -325,9 +338,11 @@ export default class Component extends View {
325
338
  const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
326
339
 
327
340
  if (this.attributes) Object.assign(attributes, getResult(this.attributes, this));
341
+ // Store previous attributes.
342
+ const previousAttributes = this.previousAttributes || {};
343
+ this.previousAttributes = attributes;
328
344
 
329
345
  Object.keys(attributes).forEach(key => {
330
- // Evaluate attribute value.
331
346
  let value = attributes[key];
332
347
  // Transform bool attribute values
333
348
  if (value === false) {
@@ -339,7 +354,13 @@ export default class Component extends View {
339
354
  if (value === null || typeof value === 'undefined') value = '';
340
355
 
341
356
  add[key] = value;
342
- html.push(`${key}="${value}"`);
357
+ html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
358
+ }
359
+ });
360
+ // Remove attributes that were in previousAttributes but not in current attributes.
361
+ Object.keys(previousAttributes).forEach(key => {
362
+ if (!(key in attributes)) {
363
+ remove[key] = true;
343
364
  }
344
365
  });
345
366
 
@@ -437,8 +458,9 @@ export default class Component extends View {
437
458
 
438
459
  /**
439
460
  * Tagged template helper method.
440
- * Used to create a partial template.
441
- * It will return a one-dimensional array with strings and expressions.
461
+ * Used to create a partial template.
462
+ * It will return a one-dimensional array with strings and expressions.
463
+ * Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
442
464
  * @param {TemplateStringsArray} strings - Template strings.
443
465
  * @param {...any} expressions - Template expressions.
444
466
  * @return {Array} Array containing strings and expressions.
@@ -466,13 +488,11 @@ export default class Component extends View {
466
488
  * });
467
489
  */
468
490
  partial(strings, ...expressions) {
469
- return deepFlat(splitPlaceholders(
470
- expandComponents(
471
- addPlaceholders(strings, expressions), expressions
472
- ), expressions
473
- ).map(item => {
474
- return renderExpression(item, this);
475
- }));
491
+ return deepFlat(
492
+ splitPlaceholders(
493
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
494
+ ).map(item => getExpressionResult(item, this))
495
+ );
476
496
  }
477
497
 
478
498
  getRecyclePlaceholder() {
@@ -609,6 +629,20 @@ export default class Component extends View {
609
629
  return this;
610
630
  }
611
631
 
632
+ /**
633
+ * Mark a string as safe HTML to be rendered.
634
+ * Normally you don't need to use this method, as Rasti will automatically mark strings
635
+ * as safe HTML when the component is @link{#module_component_create created} and when
636
+ * using the @link{#module_component__partial Component.partial} method.
637
+ * Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
638
+ * @static
639
+ * @param {string} value
640
+ * @return {Rasti.SafeHTML} A safe HTML object.
641
+ */
642
+ static markAsSafeHTML(value) {
643
+ return new SafeHTML(value);
644
+ }
645
+
612
646
  /**
613
647
  * Helper method used to extend a `Component`, creating a subclass.
614
648
  * @static
@@ -632,7 +666,6 @@ export default class Component extends View {
632
666
  * It instantiate the Component view using options,
633
667
  * appends its element into the DOM (if `el` is provided).
634
668
  * And returns the view instance.
635
- * <br><br> &#9888; **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
636
669
  * @static
637
670
  * @param {object} options The view options.
638
671
  * @param {node} el Dom element to append the view element.
@@ -714,7 +747,7 @@ export default class Component extends View {
714
747
  * // Create a navigation component. Add buttons as children. Iterate over items.
715
748
  * const Navigation = Component.create`
716
749
  * <nav>
717
- * ${(self) => self.options.items.map(
750
+ * ${self => self.options.items.map(
718
751
  * item => self.partial`<${Button}>${item.label}</${Button}>`
719
752
  * )}
720
753
  * </nav>
@@ -770,18 +803,18 @@ export default class Component extends View {
770
803
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
771
804
  // Get tag, attributes.
772
805
  tag = function() {
773
- return renderExpression(tagExpression, this);
806
+ return Component.sanitize(getExpressionResult(tagExpression, this));
774
807
  };
775
808
  // Get attributes.
776
809
  attributes = function() {
777
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
810
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
778
811
  };
779
812
  // Get events.
780
813
  events = function() {
781
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
814
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
782
815
 
783
816
  return Object.keys(onlyEvents).reduce((out, key) => {
784
- const typeListeners = renderExpression(onlyEvents[key], this);
817
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
785
818
 
786
819
  Object.keys(typeListeners).forEach(selector => {
787
820
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -794,9 +827,11 @@ export default class Component extends View {
794
827
  if (close) {
795
828
  const list = inner ? splitPlaceholders(inner, expressions) : [];
796
829
  template = function(addChild) {
797
- return deepFlat(list.map(item => renderExpression(item, this))).map(item => {
830
+ return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
798
831
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
799
- return item instanceof Component ? addChild(item) : item;
832
+ if (item instanceof SafeHTML) return item;
833
+ if (item instanceof Component) return addChild(item);
834
+ return Component.sanitize(`${item}`);
800
835
  }
801
836
  return '';
802
837
  }).join('');
@@ -810,7 +845,7 @@ export default class Component extends View {
810
845
  // If there is only one expression and no tag, is a container.
811
846
  template = function(addChild) {
812
847
  // Replace expressions.
813
- return addChild(renderExpression(expressions[match[1]], this)).toString();
848
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
814
849
  };
815
850
  } else {
816
851
  throw new SyntaxError('Invalid component');
package/src/View.js CHANGED
@@ -183,15 +183,15 @@ export default class View extends Emitter {
183
183
  * Called from the constructor if `this.el` is undefined, to ensure
184
184
  * the view has a root element.
185
185
  * @param {string} tag Tag for the element. Default to `div`
186
- * @param {object} attrs Attributes for the element.
186
+ * @param {object} attributes Attributes for the element.
187
187
  * @return {node} The created element.
188
188
  */
189
- createElement(tag = 'div', attrs = {}) {
189
+ createElement(tag = 'div', attributes = {}) {
190
190
  // Create DOM element.
191
191
  let el = document.createElement(tag);
192
192
  // Add element attributes.
193
- Object.keys(attrs)
194
- .forEach(key => el.setAttribute(key, attrs[key]));
193
+ Object.keys(attributes)
194
+ .forEach(key => el.setAttribute(key, attributes[key]));
195
195
 
196
196
  return el;
197
197
  }
@@ -312,8 +312,10 @@ export default class View extends Emitter {
312
312
  * of calling `this.template`, passing `this.model` as an argument.
313
313
  * <br><br> &#9888; **Security Notice:** The default implementation utilizes `innerHTML` on the root element
314
314
  * for rendering, which may introduce Cross-Site Scripting (XSS) risks. Ensure that any user-generated
315
- * content is properly sanitized before inserting it into the DOM. For best practices on secure data handling,
316
- * refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
315
+ * content is properly sanitized before inserting it into the DOM. You can use the @link{#module_view_sanitize View.sanitize}
316
+ * static method to escape HTML entities in a string.
317
+ * For best practices on secure data handling, refer to the
318
+ * [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
317
319
  * @return {Rasti.View} Returns `this` for chaining.
318
320
  */
319
321
  render() {
@@ -321,6 +323,25 @@ export default class View extends Emitter {
321
323
  // Return `this` for chaining.
322
324
  return this;
323
325
  }
326
+
327
+ /**
328
+ * Escape HTML entities in a string.
329
+ * Use method to sanitize user-generated content before inserting it into the DOM.
330
+ * Override this method to provide a custom escape function.
331
+ * This method is used by `Component` to escape template interpolations.
332
+ * @static
333
+ * @param {string} str String to escape.
334
+ * @return {string} Escaped string.
335
+ */
336
+ static sanitize(value) {
337
+ return `${value}`.replace(/[&<>"']/g, match => ({
338
+ '&' : '&amp;',
339
+ '<' : '&lt;',
340
+ '>' : '&gt;',
341
+ '"' : '&quot;',
342
+ '\'' : '&#039;'
343
+ }[match]));
344
+ }
324
345
  }
325
346
 
326
347
  /*