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 +2 -2
- package/dist/rasti.js +74 -33
- package/dist/rasti.min.js +1 -1
- package/es/Component.js +47 -27
- package/es/View.js +27 -6
- package/lib/Component.cjs +47 -27
- package/lib/View.cjs +27 -6
- package/package.json +1 -1
- package/src/Component.js +47 -27
- package/src/View.js +27 -6
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
|
-
${
|
|
108
|
-
({ 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}
|
|
505
|
+
* @param {object} attributes Attributes for the element.
|
|
506
506
|
* @return {node} The created element.
|
|
507
507
|
*/
|
|
508
|
-
createElement(tag = 'div',
|
|
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(
|
|
513
|
-
.forEach(key => el.setAttribute(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> ⚠ **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.
|
|
635
|
-
*
|
|
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
|
+
'&' : '&',
|
|
658
|
+
'<' : '<',
|
|
659
|
+
'>' : '>',
|
|
660
|
+
'"' : '"',
|
|
661
|
+
'\'' : '''
|
|
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
|
|
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
|
|
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,
|
|
754
|
+
const expandAttributes = (attributes, getExpressionResult) => {
|
|
721
755
|
const out = attributes.reduce((out, pair) => {
|
|
722
|
-
const attribute =
|
|
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 =
|
|
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 =>
|
|
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 =>
|
|
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(
|
|
1126
|
-
|
|
1127
|
-
addPlaceholders(strings, expressions), expressions
|
|
1128
|
-
),
|
|
1129
|
-
)
|
|
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> ⚠ **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
|
-
* ${
|
|
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
|
|
1468
|
+
return View.sanitize(getExpressionResult(tagExpression, this));
|
|
1430
1469
|
};
|
|
1431
1470
|
// Get attributes.
|
|
1432
1471
|
attributes = function() {
|
|
1433
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
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 =>
|
|
1476
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
1438
1477
|
|
|
1439
1478
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
1440
|
-
const typeListeners =
|
|
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 =>
|
|
1492
|
+
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
1454
1493
|
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
1455
|
-
|
|
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(
|
|
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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
|
|
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
|
|
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,
|
|
78
|
+
const expandAttributes = (attributes, getExpressionResult) => {
|
|
66
79
|
const out = attributes.reduce((out, pair) => {
|
|
67
|
-
const attribute =
|
|
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 =
|
|
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 =>
|
|
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 =>
|
|
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(
|
|
471
|
-
|
|
472
|
-
addPlaceholders(strings, expressions), expressions
|
|
473
|
-
),
|
|
474
|
-
)
|
|
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> ⚠ **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
|
-
* ${
|
|
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
|
|
792
|
+
return View.sanitize(getExpressionResult(tagExpression, this));
|
|
775
793
|
};
|
|
776
794
|
// Get attributes.
|
|
777
795
|
attributes = function() {
|
|
778
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
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 =>
|
|
800
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
783
801
|
|
|
784
802
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
785
|
-
const typeListeners =
|
|
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 =>
|
|
816
|
+
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
799
817
|
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
800
|
-
|
|
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(
|
|
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}
|
|
186
|
+
* @param {object} attributes Attributes for the element.
|
|
187
187
|
* @return {node} The created element.
|
|
188
188
|
*/
|
|
189
|
-
createElement(tag = 'div',
|
|
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(
|
|
194
|
-
.forEach(key => el.setAttribute(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> ⚠ **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.
|
|
316
|
-
*
|
|
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
|
+
'&' : '&',
|
|
339
|
+
'<' : '<',
|
|
340
|
+
'>' : '>',
|
|
341
|
+
'"' : '"',
|
|
342
|
+
'\'' : '''
|
|
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
|
|
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
|
|
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,
|
|
80
|
+
const expandAttributes = (attributes, getExpressionResult) => {
|
|
68
81
|
const out = attributes.reduce((out, pair) => {
|
|
69
|
-
const attribute =
|
|
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 =
|
|
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 =>
|
|
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 =>
|
|
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(
|
|
473
|
-
|
|
474
|
-
addPlaceholders(strings, expressions), expressions
|
|
475
|
-
),
|
|
476
|
-
)
|
|
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> ⚠ **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
|
-
* ${
|
|
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
|
|
794
|
+
return View.sanitize(getExpressionResult(tagExpression, this));
|
|
777
795
|
};
|
|
778
796
|
// Get attributes.
|
|
779
797
|
attributes = function() {
|
|
780
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
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 =>
|
|
802
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
785
803
|
|
|
786
804
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
787
|
-
const typeListeners =
|
|
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 =>
|
|
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
|
-
|
|
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(
|
|
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}
|
|
188
|
+
* @param {object} attributes Attributes for the element.
|
|
189
189
|
* @return {node} The created element.
|
|
190
190
|
*/
|
|
191
|
-
createElement(tag = 'div',
|
|
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(
|
|
196
|
-
.forEach(key => el.setAttribute(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> ⚠ **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.
|
|
318
|
-
*
|
|
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
|
+
'&' : '&',
|
|
341
|
+
'<' : '<',
|
|
342
|
+
'>' : '>',
|
|
343
|
+
'"' : '"',
|
|
344
|
+
'\'' : '''
|
|
345
|
+
}[match]));
|
|
346
|
+
}
|
|
326
347
|
}
|
|
327
348
|
|
|
328
349
|
/*
|
package/package.json
CHANGED
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
|
|
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
|
|
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,
|
|
77
|
+
const expandAttributes = (attributes, getExpressionResult) => {
|
|
65
78
|
const out = attributes.reduce((out, pair) => {
|
|
66
|
-
const attribute =
|
|
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 =
|
|
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 =>
|
|
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 =>
|
|
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(
|
|
470
|
-
|
|
471
|
-
addPlaceholders(strings, expressions), expressions
|
|
472
|
-
),
|
|
473
|
-
)
|
|
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> ⚠ **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
|
-
* ${
|
|
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
|
|
791
|
+
return View.sanitize(getExpressionResult(tagExpression, this));
|
|
774
792
|
};
|
|
775
793
|
// Get attributes.
|
|
776
794
|
attributes = function() {
|
|
777
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
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 =>
|
|
799
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
782
800
|
|
|
783
801
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
784
|
-
const typeListeners =
|
|
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 =>
|
|
815
|
+
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
798
816
|
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
799
|
-
|
|
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(
|
|
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}
|
|
186
|
+
* @param {object} attributes Attributes for the element.
|
|
187
187
|
* @return {node} The created element.
|
|
188
188
|
*/
|
|
189
|
-
createElement(tag = 'div',
|
|
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(
|
|
194
|
-
.forEach(key => el.setAttribute(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> ⚠ **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.
|
|
316
|
-
*
|
|
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
|
+
'&' : '&',
|
|
339
|
+
'<' : '<',
|
|
340
|
+
'>' : '>',
|
|
341
|
+
'"' : '"',
|
|
342
|
+
'\'' : '''
|
|
343
|
+
}[match]));
|
|
344
|
+
}
|
|
324
345
|
}
|
|
325
346
|
|
|
326
347
|
/*
|