rasti 3.0.0-alpha.1 → 3.0.0-alpha.2

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(new SafeHTML(before), expressions[match[1]]);
704
738
  lastIndex = match.index + match[0].length;
705
739
  }
706
- out.push(main.slice(lastIndex));
740
+ out.push(new SafeHTML(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(`${View.sanitize(key)}="${View.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
 
@@ -1122,13 +1164,11 @@
1122
1164
  * });
1123
1165
  */
1124
1166
  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
- }));
1167
+ return deepFlat(
1168
+ splitPlaceholders(
1169
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
1170
+ ).map(item => getExpressionResult(item, this))
1171
+ );
1132
1172
  }
1133
1173
 
1134
1174
  getRecyclePlaceholder() {
@@ -1288,7 +1328,6 @@
1288
1328
  * It instantiate the Component view using options,
1289
1329
  * appends its element into the DOM (if `el` is provided).
1290
1330
  * 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
1331
  * @static
1293
1332
  * @param {object} options The view options.
1294
1333
  * @param {node} el Dom element to append the view element.
@@ -1370,7 +1409,7 @@
1370
1409
  * // Create a navigation component. Add buttons as children. Iterate over items.
1371
1410
  * const Navigation = Component.create`
1372
1411
  * <nav>
1373
- * ${(self) => self.options.items.map(
1412
+ * ${self => self.options.items.map(
1374
1413
  * item => self.partial`<${Button}>${item.label}</${Button}>`
1375
1414
  * )}
1376
1415
  * </nav>
@@ -1426,18 +1465,18 @@
1426
1465
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
1427
1466
  // Get tag, attributes.
1428
1467
  tag = function() {
1429
- return renderExpression(tagExpression, this);
1468
+ return View.sanitize(getExpressionResult(tagExpression, this));
1430
1469
  };
1431
1470
  // Get attributes.
1432
1471
  attributes = function() {
1433
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
1472
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
1434
1473
  };
1435
1474
  // Get events.
1436
1475
  events = function() {
1437
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
1476
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
1438
1477
 
1439
1478
  return Object.keys(onlyEvents).reduce((out, key) => {
1440
- const typeListeners = renderExpression(onlyEvents[key], this);
1479
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
1441
1480
 
1442
1481
  Object.keys(typeListeners).forEach(selector => {
1443
1482
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -1450,9 +1489,11 @@
1450
1489
  if (close) {
1451
1490
  const list = inner ? splitPlaceholders(inner, expressions) : [];
1452
1491
  template = function(addChild) {
1453
- return deepFlat(list.map(item => renderExpression(item, this))).map(item => {
1492
+ return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
1454
1493
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
1455
- return item instanceof Component ? addChild(item) : item;
1494
+ if (item instanceof SafeHTML) return item;
1495
+ if (item instanceof Component) return addChild(item);
1496
+ return View.sanitize(`${item}`);
1456
1497
  }
1457
1498
  return '';
1458
1499
  }).join('');
@@ -1466,7 +1507,7 @@
1466
1507
  // If there is only one expression and no tag, is a container.
1467
1508
  template = function(addChild) {
1468
1509
  // Replace expressions.
1469
- return addChild(renderExpression(expressions[match[1]], this)).toString();
1510
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
1470
1511
  };
1471
1512
  } else {
1472
1513
  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(f.PLACEHOLDER_EXPRESSION(i)),t)),[]).join(""),a=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)"),i=new RegExp(`${s}`,"g"),n=[];let r,o=0;for(;null!==(r=i.exec(t));){const s=t.slice(o,r.index);n.push(new h(s),e[r[1]]),o=r.index+r[0].length}return n.push(new h(t.slice(o))),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=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:h}=d(arguments,e);if(!(t.prototype instanceof f))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)})),f.PLACEHOLDER_EXPRESSION(e.length-1)}))},d=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)"),[i,n,r,h,o,l,a,c,u,d]=t,E={raw:i,attributes:[]};let p;n?(E.tag=void 0!==r?e[r]:n,E.inner=o,E.close=void 0!==a?e[a]:l,p=h):(E.tag=void 0!==u?e[u]:c,p=d);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},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},p={key:!0,state:!0,onCreate:!0,onChange:!0,onRender:!0};class f extends n{constructor(t={}){super(...arguments),Object.keys(t).forEach((e=>{p[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=[],r={[f.DATA_ATTRIBUTE_UID]:this.uid};this.attributes&&Object.assign(r,s(this.attributes,this));const h=this.previousAttributes||{};return this.previousAttributes=r,Object.keys(r).forEach((s=>{let h=r[s];!1===h?e[s]=!0:!0===h?(t[s]="",i.push(s)):(null==h&&(h=""),t[s]=h,i.push(`${n.sanitize(s)}="${n.sanitize(h)}"`))})),Object.keys(h).forEach((t=>{t in r||(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,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(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=`${f.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,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,E,p,y;"function"==typeof t&&(e=[t],t=["",""]);const g=u(l(t,e),e);let b=g.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${s})([^>]*)>([\\s\\S]*?)</(\\1|${s})>\\s*$|^\\s*<([a-z]+[1-6]?|${s})([^>]*)/>\\s*$`));if(b){const{tag:t,attributes:s,inner:l,close:u}=d(b,e);if(i=function(){return n.sanitize(o(t,this))},E=function(){return c(s,(t=>o(t,this))).attributes},p=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):[];y=function(e){return r(t.map((t=>o(t,this)))).map((t=>null!=t&&!1!==t&&!0!==t?t instanceof h?t:t instanceof f?e(t):n.sanitize(`${t}`):"")).join("")}}}else{if(b=g.match(new RegExp(`^\\s*${s}\\s*$`)),!b)throw new SyntaxError("Invalid component");y=function(t){return t(o(e[b[1]],this)).toString()}}return this.extend({tag:i,attributes:E,events:p,template:y})}}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 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(new SafeHTML(before), expressions[match[1]]);
49
62
  lastIndex = match.index + match[0].length;
50
63
  }
51
- out.push(main.slice(lastIndex));
64
+ out.push(new SafeHTML(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(`${View.sanitize(key)}="${View.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
 
@@ -467,13 +488,11 @@ class Component extends View {
467
488
  * });
468
489
  */
469
490
  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
- }));
491
+ return deepFlat(
492
+ splitPlaceholders(
493
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
494
+ ).map(item => getExpressionResult(item, this))
495
+ );
477
496
  }
478
497
 
479
498
  getRecyclePlaceholder() {
@@ -633,7 +652,6 @@ class Component extends View {
633
652
  * It instantiate the Component view using options,
634
653
  * appends its element into the DOM (if `el` is provided).
635
654
  * 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
655
  * @static
638
656
  * @param {object} options The view options.
639
657
  * @param {node} el Dom element to append the view element.
@@ -715,7 +733,7 @@ class Component extends View {
715
733
  * // Create a navigation component. Add buttons as children. Iterate over items.
716
734
  * const Navigation = Component.create`
717
735
  * <nav>
718
- * ${(self) => self.options.items.map(
736
+ * ${self => self.options.items.map(
719
737
  * item => self.partial`<${Button}>${item.label}</${Button}>`
720
738
  * )}
721
739
  * </nav>
@@ -771,18 +789,18 @@ class Component extends View {
771
789
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
772
790
  // Get tag, attributes.
773
791
  tag = function() {
774
- return renderExpression(tagExpression, this);
792
+ return View.sanitize(getExpressionResult(tagExpression, this));
775
793
  };
776
794
  // Get attributes.
777
795
  attributes = function() {
778
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
796
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
779
797
  };
780
798
  // Get events.
781
799
  events = function() {
782
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
800
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
783
801
 
784
802
  return Object.keys(onlyEvents).reduce((out, key) => {
785
- const typeListeners = renderExpression(onlyEvents[key], this);
803
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
786
804
 
787
805
  Object.keys(typeListeners).forEach(selector => {
788
806
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -795,9 +813,11 @@ class Component extends View {
795
813
  if (close) {
796
814
  const list = inner ? splitPlaceholders(inner, expressions) : [];
797
815
  template = function(addChild) {
798
- return deepFlat(list.map(item => renderExpression(item, this))).map(item => {
816
+ return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
799
817
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
800
- return item instanceof Component ? addChild(item) : item;
818
+ if (item instanceof SafeHTML) return item;
819
+ if (item instanceof Component) return addChild(item);
820
+ return View.sanitize(`${item}`);
801
821
  }
802
822
  return '';
803
823
  }).join('');
@@ -811,7 +831,7 @@ class Component extends View {
811
831
  // If there is only one expression and no tag, is a container.
812
832
  template = function(addChild) {
813
833
  // Replace expressions.
814
- return addChild(renderExpression(expressions[match[1]], this)).toString();
834
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
815
835
  };
816
836
  } else {
817
837
  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(new SafeHTML(before), expressions[match[1]]);
51
64
  lastIndex = match.index + match[0].length;
52
65
  }
53
- out.push(main.slice(lastIndex));
66
+ out.push(new SafeHTML(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(`${View.sanitize(key)}="${View.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
 
@@ -469,13 +490,11 @@ class Component extends View {
469
490
  * });
470
491
  */
471
492
  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
- }));
493
+ return utils_deepFlat(
494
+ splitPlaceholders(
495
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
496
+ ).map(item => getExpressionResult(item, this))
497
+ );
479
498
  }
480
499
 
481
500
  getRecyclePlaceholder() {
@@ -635,7 +654,6 @@ class Component extends View {
635
654
  * It instantiate the Component view using options,
636
655
  * appends its element into the DOM (if `el` is provided).
637
656
  * 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
657
  * @static
640
658
  * @param {object} options The view options.
641
659
  * @param {node} el Dom element to append the view element.
@@ -717,7 +735,7 @@ class Component extends View {
717
735
  * // Create a navigation component. Add buttons as children. Iterate over items.
718
736
  * const Navigation = Component.create`
719
737
  * <nav>
720
- * ${(self) => self.options.items.map(
738
+ * ${self => self.options.items.map(
721
739
  * item => self.partial`<${Button}>${item.label}</${Button}>`
722
740
  * )}
723
741
  * </nav>
@@ -773,18 +791,18 @@ class Component extends View {
773
791
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
774
792
  // Get tag, attributes.
775
793
  tag = function() {
776
- return renderExpression(tagExpression, this);
794
+ return View.sanitize(getExpressionResult(tagExpression, this));
777
795
  };
778
796
  // Get attributes.
779
797
  attributes = function() {
780
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
798
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
781
799
  };
782
800
  // Get events.
783
801
  events = function() {
784
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
802
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
785
803
 
786
804
  return Object.keys(onlyEvents).reduce((out, key) => {
787
- const typeListeners = renderExpression(onlyEvents[key], this);
805
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
788
806
 
789
807
  Object.keys(typeListeners).forEach(selector => {
790
808
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -797,9 +815,11 @@ class Component extends View {
797
815
  if (close) {
798
816
  const list = inner ? splitPlaceholders(inner, expressions) : [];
799
817
  template = function(addChild) {
800
- return utils_deepFlat(list.map(item => renderExpression(item, this))).map(item => {
818
+ return utils_deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
801
819
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
802
- return item instanceof Component ? addChild(item) : item;
820
+ if (item instanceof SafeHTML) return item;
821
+ if (item instanceof Component) return addChild(item);
822
+ return View.sanitize(`${item}`);
803
823
  }
804
824
  return '';
805
825
  }).join('');
@@ -813,7 +833,7 @@ class Component extends View {
813
833
  // If there is only one expression and no tag, is a container.
814
834
  template = function(addChild) {
815
835
  // Replace expressions.
816
- return addChild(renderExpression(expressions[match[1]], this)).toString();
836
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
817
837
  };
818
838
  } else {
819
839
  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.2",
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(new SafeHTML(before), expressions[match[1]]);
48
61
  lastIndex = match.index + match[0].length;
49
62
  }
50
- out.push(main.slice(lastIndex));
63
+ out.push(new SafeHTML(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(`${View.sanitize(key)}="${View.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
 
@@ -466,13 +487,11 @@ export default class Component extends View {
466
487
  * });
467
488
  */
468
489
  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
- }));
490
+ return deepFlat(
491
+ splitPlaceholders(
492
+ expandComponents(addPlaceholders(strings, expressions), expressions), expressions
493
+ ).map(item => getExpressionResult(item, this))
494
+ );
476
495
  }
477
496
 
478
497
  getRecyclePlaceholder() {
@@ -632,7 +651,6 @@ export default class Component extends View {
632
651
  * It instantiate the Component view using options,
633
652
  * appends its element into the DOM (if `el` is provided).
634
653
  * 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
654
  * @static
637
655
  * @param {object} options The view options.
638
656
  * @param {node} el Dom element to append the view element.
@@ -714,7 +732,7 @@ export default class Component extends View {
714
732
  * // Create a navigation component. Add buttons as children. Iterate over items.
715
733
  * const Navigation = Component.create`
716
734
  * <nav>
717
- * ${(self) => self.options.items.map(
735
+ * ${self => self.options.items.map(
718
736
  * item => self.partial`<${Button}>${item.label}</${Button}>`
719
737
  * )}
720
738
  * </nav>
@@ -770,18 +788,18 @@ export default class Component extends View {
770
788
  const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
771
789
  // Get tag, attributes.
772
790
  tag = function() {
773
- return renderExpression(tagExpression, this);
791
+ return View.sanitize(getExpressionResult(tagExpression, this));
774
792
  };
775
793
  // Get attributes.
776
794
  attributes = function() {
777
- return expandAttributes(attributesAndEvents, value => renderExpression(value, this)).attributes;
795
+ return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
778
796
  };
779
797
  // Get events.
780
798
  events = function() {
781
- const onlyEvents = expandAttributes(attributesAndEvents, value => renderExpression(value, this)).events;
799
+ const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
782
800
 
783
801
  return Object.keys(onlyEvents).reduce((out, key) => {
784
- const typeListeners = renderExpression(onlyEvents[key], this);
802
+ const typeListeners = getExpressionResult(onlyEvents[key], this);
785
803
 
786
804
  Object.keys(typeListeners).forEach(selector => {
787
805
  out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
@@ -794,9 +812,11 @@ export default class Component extends View {
794
812
  if (close) {
795
813
  const list = inner ? splitPlaceholders(inner, expressions) : [];
796
814
  template = function(addChild) {
797
- return deepFlat(list.map(item => renderExpression(item, this))).map(item => {
815
+ return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
798
816
  if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
799
- return item instanceof Component ? addChild(item) : item;
817
+ if (item instanceof SafeHTML) return item;
818
+ if (item instanceof Component) return addChild(item);
819
+ return View.sanitize(`${item}`);
800
820
  }
801
821
  return '';
802
822
  }).join('');
@@ -810,7 +830,7 @@ export default class Component extends View {
810
830
  // If there is only one expression and no tag, is a container.
811
831
  template = function(addChild) {
812
832
  // Replace expressions.
813
- return addChild(renderExpression(expressions[match[1]], this)).toString();
833
+ return addChild(getExpressionResult(expressions[match[1]], this)).toString();
814
834
  };
815
835
  } else {
816
836
  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
  /*