rasti 3.0.0-alpha.1 → 3.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/rasti.js +91 -35
- package/dist/rasti.min.js +1 -1
- package/es/Component.js +64 -29
- package/es/View.js +27 -6
- package/lib/Component.cjs +64 -29
- package/lib/View.cjs +27 -6
- package/package.json +1 -1
- package/src/Component.js +64 -29
- 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(Component.markAsSafeHTML(before), expressions[match[1]]);
|
|
704
738
|
lastIndex = match.index + match[0].length;
|
|
705
739
|
}
|
|
706
|
-
out.push(main.slice(lastIndex));
|
|
740
|
+
out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
|
|
707
741
|
|
|
708
742
|
return out;
|
|
709
743
|
};
|
|
@@ -711,15 +745,15 @@
|
|
|
711
745
|
/*
|
|
712
746
|
* Expand attributes.
|
|
713
747
|
* @param attributes {array} Array of attributes as key, value pairs.
|
|
714
|
-
* @param
|
|
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(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
// Remove attributes that were in previousAttributes but not in current attributes.
|
|
1038
|
+
Object.keys(previousAttributes).forEach(key => {
|
|
1039
|
+
if (!(key in attributes)) {
|
|
1040
|
+
remove[key] = true;
|
|
999
1041
|
}
|
|
1000
1042
|
});
|
|
1001
1043
|
|
|
@@ -1093,8 +1135,9 @@
|
|
|
1093
1135
|
|
|
1094
1136
|
/**
|
|
1095
1137
|
* Tagged template helper method.
|
|
1096
|
-
* Used to create a partial template.
|
|
1097
|
-
* It will return a one-dimensional array with strings and expressions.
|
|
1138
|
+
* Used to create a partial template.
|
|
1139
|
+
* It will return a one-dimensional array with strings and expressions.
|
|
1140
|
+
* Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
|
|
1098
1141
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
1099
1142
|
* @param {...any} expressions - Template expressions.
|
|
1100
1143
|
* @return {Array} Array containing strings and expressions.
|
|
@@ -1122,13 +1165,11 @@
|
|
|
1122
1165
|
* });
|
|
1123
1166
|
*/
|
|
1124
1167
|
partial(strings, ...expressions) {
|
|
1125
|
-
return deepFlat(
|
|
1126
|
-
|
|
1127
|
-
addPlaceholders(strings, expressions), expressions
|
|
1128
|
-
),
|
|
1129
|
-
)
|
|
1130
|
-
return renderExpression(item, this);
|
|
1131
|
-
}));
|
|
1168
|
+
return deepFlat(
|
|
1169
|
+
splitPlaceholders(
|
|
1170
|
+
expandComponents(addPlaceholders(strings, expressions), expressions), expressions
|
|
1171
|
+
).map(item => getExpressionResult(item, this))
|
|
1172
|
+
);
|
|
1132
1173
|
}
|
|
1133
1174
|
|
|
1134
1175
|
getRecyclePlaceholder() {
|
|
@@ -1265,6 +1306,20 @@
|
|
|
1265
1306
|
return this;
|
|
1266
1307
|
}
|
|
1267
1308
|
|
|
1309
|
+
/**
|
|
1310
|
+
* Mark a string as safe HTML to be rendered.
|
|
1311
|
+
* Normally you don't need to use this method, as Rasti will automatically mark strings
|
|
1312
|
+
* as safe HTML when the component is @link{#module_component_create created} and when
|
|
1313
|
+
* using the @link{#module_component__partial Component.partial} method.
|
|
1314
|
+
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
1315
|
+
* @static
|
|
1316
|
+
* @param {string} value
|
|
1317
|
+
* @return {Rasti.SafeHTML} A safe HTML object.
|
|
1318
|
+
*/
|
|
1319
|
+
static markAsSafeHTML(value) {
|
|
1320
|
+
return new SafeHTML(value);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1268
1323
|
/**
|
|
1269
1324
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
1270
1325
|
* @static
|
|
@@ -1288,7 +1343,6 @@
|
|
|
1288
1343
|
* It instantiate the Component view using options,
|
|
1289
1344
|
* appends its element into the DOM (if `el` is provided).
|
|
1290
1345
|
* And returns the view instance.
|
|
1291
|
-
* <br><br> ⚠ **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
|
|
1292
1346
|
* @static
|
|
1293
1347
|
* @param {object} options The view options.
|
|
1294
1348
|
* @param {node} el Dom element to append the view element.
|
|
@@ -1370,7 +1424,7 @@
|
|
|
1370
1424
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
1371
1425
|
* const Navigation = Component.create`
|
|
1372
1426
|
* <nav>
|
|
1373
|
-
* ${
|
|
1427
|
+
* ${self => self.options.items.map(
|
|
1374
1428
|
* item => self.partial`<${Button}>${item.label}</${Button}>`
|
|
1375
1429
|
* )}
|
|
1376
1430
|
* </nav>
|
|
@@ -1426,18 +1480,18 @@
|
|
|
1426
1480
|
const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
|
|
1427
1481
|
// Get tag, attributes.
|
|
1428
1482
|
tag = function() {
|
|
1429
|
-
return
|
|
1483
|
+
return Component.sanitize(getExpressionResult(tagExpression, this));
|
|
1430
1484
|
};
|
|
1431
1485
|
// Get attributes.
|
|
1432
1486
|
attributes = function() {
|
|
1433
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
1487
|
+
return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
|
|
1434
1488
|
};
|
|
1435
1489
|
// Get events.
|
|
1436
1490
|
events = function() {
|
|
1437
|
-
const onlyEvents = expandAttributes(attributesAndEvents, value =>
|
|
1491
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
1438
1492
|
|
|
1439
1493
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
1440
|
-
const typeListeners =
|
|
1494
|
+
const typeListeners = getExpressionResult(onlyEvents[key], this);
|
|
1441
1495
|
|
|
1442
1496
|
Object.keys(typeListeners).forEach(selector => {
|
|
1443
1497
|
out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
|
|
@@ -1450,9 +1504,11 @@
|
|
|
1450
1504
|
if (close) {
|
|
1451
1505
|
const list = inner ? splitPlaceholders(inner, expressions) : [];
|
|
1452
1506
|
template = function(addChild) {
|
|
1453
|
-
return deepFlat(list.map(item =>
|
|
1507
|
+
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
1454
1508
|
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
1455
|
-
|
|
1509
|
+
if (item instanceof SafeHTML) return item;
|
|
1510
|
+
if (item instanceof Component) return addChild(item);
|
|
1511
|
+
return Component.sanitize(`${item}`);
|
|
1456
1512
|
}
|
|
1457
1513
|
return '';
|
|
1458
1514
|
}).join('');
|
|
@@ -1466,7 +1522,7 @@
|
|
|
1466
1522
|
// If there is only one expression and no tag, is a container.
|
|
1467
1523
|
template = function(addChild) {
|
|
1468
1524
|
// Replace expressions.
|
|
1469
|
-
return addChild(
|
|
1525
|
+
return addChild(getExpressionResult(expressions[match[1]], this)).toString();
|
|
1470
1526
|
};
|
|
1471
1527
|
} else {
|
|
1472
1528
|
throw new SyntaxError('Invalid component');
|
package/dist/rasti.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).Rasti={})}(this,(function(t){"use strict";class e{on(t,e){if("function"!=typeof e)throw new TypeError("Listener must be a function");return this.listeners||(this.listeners={}),this.listeners[t]||(this.listeners[t]=[]),this.listeners[t].push(e),()=>this.off(t,e)}once(t,e){if("function"==typeof e){const s=this,i=e;e=function(...n){i(...n),s.off(t,e)}}return this.on(t,e)}off(t,e){this.listeners&&(t?this.listeners[t]&&(e?(this.listeners[t]=this.listeners[t].filter((t=>t!==e)),this.listeners[t].length||delete this.listeners[t]):delete this.listeners[t],Object.keys(this.listeners).length||delete this.listeners):delete this.listeners)}emit(t,...e){this.listeners&&this.listeners[t]&&this.listeners[t].slice().forEach((function(t){t(...e)}))}}var s=(t,e,...s)=>"function"!=typeof t?t:t.apply(e,s);const i={el:!0,tag:!0,attributes:!0,events:!0,model:!0,template:!0,onDestroy:!0};class n extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments),this.uid="uid"+ ++n.uid,this.delegatedEventListeners=[],this.children=[],this.destroyQueue=[],Object.keys(t).forEach((e=>{i[e]&&(this[e]=t[e])})),this.ensureElement()}preinitialize(){}$(t){return this.el.querySelector(t)}$$(t){return this.el.querySelectorAll(t)}destroy(){return this.destroyChildren(),this.undelegateEvents(),this.off(),this.destroyQueue.forEach((t=>t())),this.destroyQueue=[],this.onDestroy.apply(this,arguments),this}onDestroy(){}addChild(t){return this.children.push(t),t}destroyChildren(){this.children.forEach((t=>t.destroy())),this.children=[]}ensureElement(){if(this.el)this.el=s(this.el,this);else{const t=s(this.tag,this),e=s(this.attributes,this);this.el=this.createElement(t,e)}this.delegateEvents()}createElement(t="div",e={}){let s=document.createElement(t);return Object.keys(e).forEach((t=>s.setAttribute(t,e[t]))),s}removeElement(){return this.el.parentNode.removeChild(this.el),this}delegateEvents(t){if(t||(t=s(this.events,this)),!t)return this;this.delegatedEventListeners.length&&this.undelegateEvents();let e={};return Object.keys(t).forEach((s=>{const i=s.split(" "),n=i.shift(),r=i.join(" ");let h=t[s];h=("string"==typeof h?this[h]:h).bind(this),e[n]||(e[n]=[]),e[n].push({selector:r,listener:h})})),Object.keys(e).forEach((t=>{const s=s=>{e[t].forEach((({selector:t,listener:e})=>{t&&!s.target.closest(t)||e(s,this)}))};this.delegatedEventListeners.push({type:t,listener:s}),this.el.addEventListener(t,s)})),this}undelegateEvents(){return this.delegatedEventListeners.forEach((({type:t,listener:e})=>{this.el.removeEventListener(t,e)})),this.delegatedEventListeners=[],this}render(){return this.template&&(this.el.innerHTML=this.template(this.model)),this}}n.uid=0;const r=t=>t.reduce(((t,e)=>(Array.isArray(e)?t.push(...r(e)):t.push(e),t)),[]),h=(t,e)=>s(t,e,e),l=(t,e)=>t.reduce(((t,s,i)=>(t.push(s),void 0!==e[i]&&t.push(f.PLACEHOLDER_EXPRESSION(i)),t)),[]).join(""),o=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)"),i=new RegExp(`${s}`,"g"),n=[];let r,h=0;for(;null!==(r=i.exec(t));){const s=t.slice(h,r.index);n.push(s,e[r[1]]),h=r.index+r[0].length}return n.push(t.slice(h)),n},c=(t,e)=>{const s=t.reduce(((t,s)=>{const i=e(s[0]);if(1===s.length)"object"==typeof i?t.all=Object.assign(t.all,i):"string"==typeof i&&(t.all[i]=!0);else{const n=e(s[1]);t.all[i]=n}return t}),{all:{},events:{},attributes:{}});return Object.keys(s.all).forEach((t=>{const e=t.match(/on(([A-Z]{1}[a-z]+)+)/);e&&e[1]?s.events[e[1].toLowerCase()]=s.all[t]:s.attributes[t]=s.all[t]})),s},a=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)");return t.replace(new RegExp(`<(${s})([^>]*)>([\\s\\S]*?)</(${s})>|<(${s})([^>]*)/>`,"g"),(function(){const{tag:t,attributes:s,inner:i,close:n,raw:l}=d(arguments,e);if(!(t.prototype instanceof f))return l;let u;if(n){if(t!==n)return l;const s=o(a(i,e),e);u=function(){return r(s.map((t=>h(t,this))))}}return e.push((function(){const e=c(s,(t=>h(t,this))).all;return u&&(e.renderChildren=u.bind(this)),t.mount(e)})),f.PLACEHOLDER_EXPRESSION(e.length-1)}))},d=(t,e)=>{const s=f.PLACEHOLDER_EXPRESSION("(\\d+)"),[i,n,r,h,l,o,c,a,d,u]=t,E={raw:i,attributes:[]};let p;n?(E.tag=void 0!==r?e[r]:n,E.inner=l,E.close=void 0!==c?e[c]:o,p=h):(E.tag=void 0!==d?e[d]:a,p=u);const y=new RegExp(`(${s}|[\\w-]+)(?:=(["']?)(?:${s}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`,"g");let g;for(;null!==(g=y.exec(p));){const[,t,s,,i,n]=g,r=void 0!==s?e[s]:t,h=void 0!==i?e[i]:n;void 0!==h?E.attributes.push([r,h]):E.attributes.push([r])}return E},u={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,source:!0,track:!0,wbr:!0},E={key:!0,state:!0,onCreate:!0,onChange:!0,onRender:!0};class f extends n{constructor(t={}){super(...arguments),Object.keys(t).forEach((e=>{E[e]&&(this[e]=t[e])})),this.options=t,this.onCreate.apply(this,arguments)}subscribe(t){if(!t.on)return;const e=this.onChange.bind(this),s=t.on("change",e);return this.destroyQueue.push("function"==typeof s?s:()=>t.off("change",e)),this}isContainer(){return!(this.tag||!this.template)}ensureElement(){this.el&&(this.el=s(this.el,this),this.delegateEvents())}findElement(t){return(t||document).querySelector(`[${f.DATA_ATTRIBUTE_UID}="${this.uid}"]`)}getAttributes(){const t={},e={},i=[],n={[f.DATA_ATTRIBUTE_UID]:this.uid};return this.attributes&&Object.assign(n,s(this.attributes,this)),Object.keys(n).forEach((s=>{let r=n[s];!1===r?e[s]=!0:!0===r?(t[s]="",i.push(s)):(null==r&&(r=""),t[s]=r,i.push(`${s}="${r}"`))})),{add:t,remove:e,html:i.join(" ")}}hydrate(t){return this.model&&this.subscribe(this.model),this.state&&this.subscribe(this.state),this.isContainer()?(this.children[0].hydrate(t),this.el=this.children[0].el):(this.el=this.findElement(t),this.delegateEvents(),this.children.forEach((t=>t.hydrate(this.el)))),this.onRender.call(this,f.RENDER_TYPE_HYDRATE),this}recycle(t){if(this.isContainer())return this.children[0].recycle(t);return this.findElement(t).replaceWith(this.el),this.onRender.call(this,f.RENDER_TYPE_RECYCLE),this}destroy(){return super.destroy.apply(this,arguments),this.destroyed=!0,this}onCreate(){}onChange(){this.render()}onRender(){}onDestroy(){}partial(t,...e){return r(o(a(l(t,e),e),e).map((t=>h(t,this))))}getRecyclePlaceholder(){if(this.isContainer())return this.children[0].getRecyclePlaceholder();const t=s(this.tag,this)||"div",e=`${f.DATA_ATTRIBUTE_UID}="${this.uid}"`;return this.template||!u[t]?`<${t} ${e}></${t}>`:`<${t} ${e} />`}toString(){if(this.destroyChildren(),this.isContainer())return this.template.call(this,this.addChild.bind(this));const t=s(this.tag,this)||"div",e=this.getAttributes().html,i=this.template?this.template.call(this,this.addChild.bind(this)):"";return this.template||!u[t]?`<${t} ${e}>${i}</${t}>`:`<${t} ${e} />`}render(){if(this.destroyed)return this;if(!this.isContainer()){if(!this.el){const t=this.createElement("template");return t.innerHTML=this,this.hydrate(t.content),this}const t=this.getAttributes();Object.keys(t.remove).forEach((t=>{this.el.removeAttribute(t)})),Object.keys(t.add).forEach((e=>{this.el.setAttribute(e,t.add[e])}))}if(this.template){const t=document.activeElement,e=[],s=[],i=this.children;this.children=[];const n=this.template.call(this,(t=>{let n=t;const r=t.key&&i.find((e=>e.key===t.key));return r?(n=r.getRecyclePlaceholder(),s.push(r),t.destroy()):e.push(t),n}));if(this.isContainer())if(e[0]){const t=this.createElement("template");t.innerHTML=n,this.addChild(e[0]).hydrate(t.content);const s=t.content.children[0];this.el&&this.el.replaceWith(s),this.el=s}else{if(!s[0])throw new Error("Container component must have a child component");this.addChild(s[0])}else this.el.innerHTML=n,e.forEach((t=>{this.addChild(t).hydrate(this.el)})),s.forEach((t=>{this.addChild(t).recycle(this.el)}));i.forEach((t=>{s.indexOf(t)>-1||t.destroy()})),this.el.contains(t)&&t.focus()}return this.onRender.call(this,f.RENDER_TYPE_RENDER),this}static extend(t){const e=this;class s extends e{}return Object.assign(s.prototype,"function"==typeof t?t(e.prototype):t),s}static mount(t,e,s){const i=new this(t);return e&&(s?(i.toString(),i.hydrate(e)):e.appendChild(i.render().el)),i}static create(t,...e){const s=f.PLACEHOLDER_EXPRESSION("(\\d+)");let i,n,u,E;"function"==typeof t&&(e=[t],t=["",""]);const p=a(l(t,e),e);let y=p.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${s})([^>]*)>([\\s\\S]*?)</(\\1|${s})>\\s*$|^\\s*<([a-z]+[1-6]?|${s})([^>]*)/>\\s*$`));if(y){const{tag:t,attributes:s,inner:l,close:a}=d(y,e);if(i=function(){return h(t,this)},n=function(){return c(s,(t=>h(t,this))).attributes},u=function(){const t=c(s,(t=>h(t,this))).events;return Object.keys(t).reduce(((e,s)=>{const i=h(t[s],this);return Object.keys(i).forEach((t=>{e[`${s}${"&"===t?"":` ${t}`}`]=i[t]})),e}),{})},a){const t=l?o(l,e):[];E=function(e){return r(t.map((t=>h(t,this)))).map((t=>null!=t&&!1!==t&&!0!==t?t instanceof f?e(t):t:"")).join("")}}}else{if(y=p.match(new RegExp(`^\\s*${s}\\s*$`)),!y)throw new SyntaxError("Invalid component");E=function(t){return t(h(e[y[1]],this)).toString()}}return this.extend({tag:i,attributes:n,events:u,template:E})}}f.PLACEHOLDER_EXPRESSION=t=>`__RASTI_{${t}}__`,f.DATA_ATTRIBUTE_UID="data-rasti-uid",f.RENDER_TYPE_HYDRATE="hydrate",f.RENDER_TYPE_RECYCLE="recycle",f.RENDER_TYPE_RENDER="render",t.Component=f,t.Emitter=e,t.Model=class extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments);const e=s(this.defaults,this)||{};this.attributes=Object.assign({},e,t),this.previous={},Object.keys(this.attributes).forEach(this.defineAttribute.bind(this))}preinitialize(){}defineAttribute(t){Object.defineProperty(this,t,{get:()=>this.get(t),set:e=>{this.set(t,e)}})}get(t){return this.attributes[t]}set(t,e,...s){let i,n;"object"==typeof t?(i=t,n=[e,...s]):(i={[t]:e},n=s);const r=this._changing;this._changing=!0;const h={};r||(this.previous=Object.assign({},this.attributes)),Object.keys(i).forEach((t=>{i[t]!==this.attributes[t]&&(h[t]=i[t],this.attributes[t]=i[t])}));const l=Object.keys(h);if(l.length&&(this._pending=["change",this,h,...n]),l.forEach((t=>{this.emit(`change:${t}`,this,i[t],...n)})),r)return this;for(;this._pending;){const t=this._pending;this._pending=null,this.emit.apply(this,t)}return this._pending=null,this._changing=!1,this}toJSON(){return Object.assign({},this.attributes)}},t.View=n}));
|
|
1
|
+
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).Rasti={})}(this,(function(t){"use strict";class e{on(t,e){if("function"!=typeof e)throw new TypeError("Listener must be a function");return this.listeners||(this.listeners={}),this.listeners[t]||(this.listeners[t]=[]),this.listeners[t].push(e),()=>this.off(t,e)}once(t,e){if("function"==typeof e){const s=this,i=e;e=function(...n){i(...n),s.off(t,e)}}return this.on(t,e)}off(t,e){this.listeners&&(t?this.listeners[t]&&(e?(this.listeners[t]=this.listeners[t].filter((t=>t!==e)),this.listeners[t].length||delete this.listeners[t]):delete this.listeners[t],Object.keys(this.listeners).length||delete this.listeners):delete this.listeners)}emit(t,...e){this.listeners&&this.listeners[t]&&this.listeners[t].slice().forEach((function(t){t(...e)}))}}var s=(t,e,...s)=>"function"!=typeof t?t:t.apply(e,s);const i={el:!0,tag:!0,attributes:!0,events:!0,model:!0,template:!0,onDestroy:!0};class n extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments),this.uid="uid"+ ++n.uid,this.delegatedEventListeners=[],this.children=[],this.destroyQueue=[],Object.keys(t).forEach((e=>{i[e]&&(this[e]=t[e])})),this.ensureElement()}preinitialize(){}$(t){return this.el.querySelector(t)}$$(t){return this.el.querySelectorAll(t)}destroy(){return this.destroyChildren(),this.undelegateEvents(),this.off(),this.destroyQueue.forEach((t=>t())),this.destroyQueue=[],this.onDestroy.apply(this,arguments),this}onDestroy(){}addChild(t){return this.children.push(t),t}destroyChildren(){this.children.forEach((t=>t.destroy())),this.children=[]}ensureElement(){if(this.el)this.el=s(this.el,this);else{const t=s(this.tag,this),e=s(this.attributes,this);this.el=this.createElement(t,e)}this.delegateEvents()}createElement(t="div",e={}){let s=document.createElement(t);return Object.keys(e).forEach((t=>s.setAttribute(t,e[t]))),s}removeElement(){return this.el.parentNode.removeChild(this.el),this}delegateEvents(t){if(t||(t=s(this.events,this)),!t)return this;this.delegatedEventListeners.length&&this.undelegateEvents();let e={};return Object.keys(t).forEach((s=>{const i=s.split(" "),n=i.shift(),r=i.join(" ");let h=t[s];h=("string"==typeof h?this[h]:h).bind(this),e[n]||(e[n]=[]),e[n].push({selector:r,listener:h})})),Object.keys(e).forEach((t=>{const s=s=>{e[t].forEach((({selector:t,listener:e})=>{t&&!s.target.closest(t)||e(s,this)}))};this.delegatedEventListeners.push({type:t,listener:s}),this.el.addEventListener(t,s)})),this}undelegateEvents(){return this.delegatedEventListeners.forEach((({type:t,listener:e})=>{this.el.removeEventListener(t,e)})),this.delegatedEventListeners=[],this}render(){return this.template&&(this.el.innerHTML=this.template(this.model)),this}static sanitize(t){return`${t}`.replace(/[&<>"']/g,(t=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[t])))}}n.uid=0;const r=t=>t.reduce(((t,e)=>(Array.isArray(e)?t.push(...r(e)):t.push(e),t)),[]);class h{constructor(t){this.value=t}toString(){return this.value}}const o=(t,e)=>s(t,e,e),l=(t,e)=>t.reduce(((t,s,i)=>(t.push(s),void 0!==e[i]&&t.push(p.PLACEHOLDER_EXPRESSION(i)),t)),[]).join(""),a=(t,e)=>{const s=p.PLACEHOLDER_EXPRESSION("(\\d+)"),i=new RegExp(`${s}`,"g"),n=[];let r,h=0;for(;null!==(r=i.exec(t));){const s=t.slice(h,r.index);n.push(p.markAsSafeHTML(s),e[r[1]]),h=r.index+r[0].length}return n.push(p.markAsSafeHTML(t.slice(h))),n},c=(t,e)=>{const s=t.reduce(((t,s)=>{const i=e(s[0]);if(1===s.length)"object"==typeof i?t.all=Object.assign(t.all,i):"string"==typeof i&&(t.all[i]=!0);else{const n=e(s[1]);t.all[i]=n}return t}),{all:{},events:{},attributes:{}});return Object.keys(s.all).forEach((t=>{const e=t.match(/on(([A-Z]{1}[a-z]+)+)/);e&&e[1]?s.events[e[1].toLowerCase()]=s.all[t]:s.attributes[t]=s.all[t]})),s},u=(t,e)=>{const s=p.PLACEHOLDER_EXPRESSION("(\\d+)");return t.replace(new RegExp(`<(${s})([^>]*)>([\\s\\S]*?)</(${s})>|<(${s})([^>]*)/>`,"g"),(function(){const{tag:t,attributes:s,inner:i,close:n,raw:h}=d(arguments,e);if(!(t.prototype instanceof p))return h;let l;if(n){if(t!==n)return h;const s=a(u(i,e),e);l=function(){return r(s.map((t=>o(t,this))))}}return e.push((function(){const e=c(s,(t=>o(t,this))).all;return l&&(e.renderChildren=l.bind(this)),t.mount(e)})),p.PLACEHOLDER_EXPRESSION(e.length-1)}))},d=(t,e)=>{const s=p.PLACEHOLDER_EXPRESSION("(\\d+)"),[i,n,r,h,o,l,a,c,u,d]=t,E={raw:i,attributes:[]};let f;n?(E.tag=void 0!==r?e[r]:n,E.inner=o,E.close=void 0!==a?e[a]:l,f=h):(E.tag=void 0!==u?e[u]:c,f=d);const y=new RegExp(`(${s}|[\\w-]+)(?:=(["']?)(?:${s}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`,"g");let g;for(;null!==(g=y.exec(f));){const[,t,s,,i,n]=g,r=void 0!==s?e[s]:t,h=void 0!==i?e[i]:n;void 0!==h?E.attributes.push([r,h]):E.attributes.push([r])}return E},E={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,link:!0,meta:!0,source:!0,track:!0,wbr:!0},f={key:!0,state:!0,onCreate:!0,onChange:!0,onRender:!0};class p extends n{constructor(t={}){super(...arguments),Object.keys(t).forEach((e=>{f[e]&&(this[e]=t[e])})),this.options=t,this.onCreate.apply(this,arguments)}subscribe(t){if(!t.on)return;const e=this.onChange.bind(this),s=t.on("change",e);return this.destroyQueue.push("function"==typeof s?s:()=>t.off("change",e)),this}isContainer(){return!(this.tag||!this.template)}ensureElement(){this.el&&(this.el=s(this.el,this),this.delegateEvents())}findElement(t){return(t||document).querySelector(`[${p.DATA_ATTRIBUTE_UID}="${this.uid}"]`)}getAttributes(){const t={},e={},i=[],n={[p.DATA_ATTRIBUTE_UID]:this.uid};this.attributes&&Object.assign(n,s(this.attributes,this));const r=this.previousAttributes||{};return this.previousAttributes=n,Object.keys(n).forEach((s=>{let r=n[s];!1===r?e[s]=!0:!0===r?(t[s]="",i.push(s)):(null==r&&(r=""),t[s]=r,i.push(`${p.sanitize(s)}="${p.sanitize(r)}"`))})),Object.keys(r).forEach((t=>{t in n||(e[t]=!0)})),{add:t,remove:e,html:i.join(" ")}}hydrate(t){return this.model&&this.subscribe(this.model),this.state&&this.subscribe(this.state),this.isContainer()?(this.children[0].hydrate(t),this.el=this.children[0].el):(this.el=this.findElement(t),this.delegateEvents(),this.children.forEach((t=>t.hydrate(this.el)))),this.onRender.call(this,p.RENDER_TYPE_HYDRATE),this}recycle(t){if(this.isContainer())return this.children[0].recycle(t);return this.findElement(t).replaceWith(this.el),this.onRender.call(this,p.RENDER_TYPE_RECYCLE),this}destroy(){return super.destroy.apply(this,arguments),this.destroyed=!0,this}onCreate(){}onChange(){this.render()}onRender(){}onDestroy(){}partial(t,...e){return r(a(u(l(t,e),e),e).map((t=>o(t,this))))}getRecyclePlaceholder(){if(this.isContainer())return this.children[0].getRecyclePlaceholder();const t=s(this.tag,this)||"div",e=`${p.DATA_ATTRIBUTE_UID}="${this.uid}"`;return this.template||!E[t]?`<${t} ${e}></${t}>`:`<${t} ${e} />`}toString(){if(this.destroyChildren(),this.isContainer())return this.template.call(this,this.addChild.bind(this));const t=s(this.tag,this)||"div",e=this.getAttributes().html,i=this.template?this.template.call(this,this.addChild.bind(this)):"";return this.template||!E[t]?`<${t} ${e}>${i}</${t}>`:`<${t} ${e} />`}render(){if(this.destroyed)return this;if(!this.isContainer()){if(!this.el){const t=this.createElement("template");return t.innerHTML=this,this.hydrate(t.content),this}const t=this.getAttributes();Object.keys(t.remove).forEach((t=>{this.el.removeAttribute(t)})),Object.keys(t.add).forEach((e=>{this.el.setAttribute(e,t.add[e])}))}if(this.template){const t=document.activeElement,e=[],s=[],i=this.children;this.children=[];const n=this.template.call(this,(t=>{let n=t;const r=t.key&&i.find((e=>e.key===t.key));return r?(n=r.getRecyclePlaceholder(),s.push(r),t.destroy()):e.push(t),n}));if(this.isContainer())if(e[0]){const t=this.createElement("template");t.innerHTML=n,this.addChild(e[0]).hydrate(t.content);const s=t.content.children[0];this.el&&this.el.replaceWith(s),this.el=s}else{if(!s[0])throw new Error("Container component must have a child component");this.addChild(s[0])}else this.el.innerHTML=n,e.forEach((t=>{this.addChild(t).hydrate(this.el)})),s.forEach((t=>{this.addChild(t).recycle(this.el)}));i.forEach((t=>{s.indexOf(t)>-1||t.destroy()})),this.el.contains(t)&&t.focus()}return this.onRender.call(this,p.RENDER_TYPE_RENDER),this}static markAsSafeHTML(t){return new h(t)}static extend(t){const e=this;class s extends e{}return Object.assign(s.prototype,"function"==typeof t?t(e.prototype):t),s}static mount(t,e,s){const i=new this(t);return e&&(s?(i.toString(),i.hydrate(e)):e.appendChild(i.render().el)),i}static create(t,...e){const s=p.PLACEHOLDER_EXPRESSION("(\\d+)");let i,n,E,f;"function"==typeof t&&(e=[t],t=["",""]);const y=u(l(t,e),e);let g=y.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${s})([^>]*)>([\\s\\S]*?)</(\\1|${s})>\\s*$|^\\s*<([a-z]+[1-6]?|${s})([^>]*)/>\\s*$`));if(g){const{tag:t,attributes:s,inner:l,close:u}=d(g,e);if(i=function(){return p.sanitize(o(t,this))},n=function(){return c(s,(t=>o(t,this))).attributes},E=function(){const t=c(s,(t=>o(t,this))).events;return Object.keys(t).reduce(((e,s)=>{const i=o(t[s],this);return Object.keys(i).forEach((t=>{e[`${s}${"&"===t?"":` ${t}`}`]=i[t]})),e}),{})},u){const t=l?a(l,e):[];f=function(e){return r(t.map((t=>o(t,this)))).map((t=>null!=t&&!1!==t&&!0!==t?t instanceof h?t:t instanceof p?e(t):p.sanitize(`${t}`):"")).join("")}}}else{if(g=y.match(new RegExp(`^\\s*${s}\\s*$`)),!g)throw new SyntaxError("Invalid component");f=function(t){return t(o(e[g[1]],this)).toString()}}return this.extend({tag:i,attributes:n,events:E,template:f})}}p.PLACEHOLDER_EXPRESSION=t=>`__RASTI_{${t}}__`,p.DATA_ATTRIBUTE_UID="data-rasti-uid",p.RENDER_TYPE_HYDRATE="hydrate",p.RENDER_TYPE_RECYCLE="recycle",p.RENDER_TYPE_RENDER="render",t.Component=p,t.Emitter=e,t.Model=class extends e{constructor(t={}){super(),this.preinitialize.apply(this,arguments);const e=s(this.defaults,this)||{};this.attributes=Object.assign({},e,t),this.previous={},Object.keys(this.attributes).forEach(this.defineAttribute.bind(this))}preinitialize(){}defineAttribute(t){Object.defineProperty(this,t,{get:()=>this.get(t),set:e=>{this.set(t,e)}})}get(t){return this.attributes[t]}set(t,e,...s){let i,n;"object"==typeof t?(i=t,n=[e,...s]):(i={[t]:e},n=s);const r=this._changing;this._changing=!0;const h={};r||(this.previous=Object.assign({},this.attributes)),Object.keys(i).forEach((t=>{i[t]!==this.attributes[t]&&(h[t]=i[t],this.attributes[t]=i[t])}));const o=Object.keys(h);if(o.length&&(this._pending=["change",this,h,...n]),o.forEach((t=>{this.emit(`change:${t}`,this,i[t],...n)})),r)return this;for(;this._pending;){const t=this._pending;this._pending=null,this.emit.apply(this,t)}return this._pending=null,this._changing=!1,this}toJSON(){return Object.assign({},this.attributes)}},t.View=n}));
|
package/es/Component.js
CHANGED
|
@@ -3,6 +3,19 @@ import getResult from './utils/getResult.js';
|
|
|
3
3
|
import deepFlat from './utils/deepFlat.js';
|
|
4
4
|
import './Emitter.js';
|
|
5
5
|
|
|
6
|
+
/*
|
|
7
|
+
* Wrapper class for HTML strings marked as safe.
|
|
8
|
+
*/
|
|
9
|
+
class SafeHTML {
|
|
10
|
+
constructor(value) {
|
|
11
|
+
this.value = value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
toString() {
|
|
15
|
+
return this.value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
/*
|
|
7
20
|
* Same as getResult, but pass context as argument to the expression.
|
|
8
21
|
* Used to evaluate expressions in the context of a component.
|
|
@@ -10,7 +23,7 @@ import './Emitter.js';
|
|
|
10
23
|
* @param {any} context The context to call the expression with.
|
|
11
24
|
* @return {any} The result of the evaluated expression.
|
|
12
25
|
*/
|
|
13
|
-
const
|
|
26
|
+
const getExpressionResult = (expression, context) => getResult(expression, context, context);
|
|
14
27
|
|
|
15
28
|
/*
|
|
16
29
|
* Generate string with placeholders for interpolated expressions.
|
|
@@ -45,10 +58,10 @@ const splitPlaceholders = (main, expressions) => {
|
|
|
45
58
|
// so all the components are added as children by the parent component.
|
|
46
59
|
while ((match = regExp.exec(main)) !== null) {
|
|
47
60
|
const before = main.slice(lastIndex, match.index);
|
|
48
|
-
out.push(before, expressions[match[1]]);
|
|
61
|
+
out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
|
|
49
62
|
lastIndex = match.index + match[0].length;
|
|
50
63
|
}
|
|
51
|
-
out.push(main.slice(lastIndex));
|
|
64
|
+
out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
|
|
52
65
|
|
|
53
66
|
return out;
|
|
54
67
|
};
|
|
@@ -56,15 +69,15 @@ const splitPlaceholders = (main, expressions) => {
|
|
|
56
69
|
/*
|
|
57
70
|
* Expand attributes.
|
|
58
71
|
* @param attributes {array} Array of attributes as key, value pairs.
|
|
59
|
-
* @param
|
|
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(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
// Remove attributes that were in previousAttributes but not in current attributes.
|
|
362
|
+
Object.keys(previousAttributes).forEach(key => {
|
|
363
|
+
if (!(key in attributes)) {
|
|
364
|
+
remove[key] = true;
|
|
344
365
|
}
|
|
345
366
|
});
|
|
346
367
|
|
|
@@ -438,8 +459,9 @@ class Component extends View {
|
|
|
438
459
|
|
|
439
460
|
/**
|
|
440
461
|
* Tagged template helper method.
|
|
441
|
-
* Used to create a partial template.
|
|
442
|
-
* It will return a one-dimensional array with strings and expressions.
|
|
462
|
+
* Used to create a partial template.
|
|
463
|
+
* It will return a one-dimensional array with strings and expressions.
|
|
464
|
+
* Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
|
|
443
465
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
444
466
|
* @param {...any} expressions - Template expressions.
|
|
445
467
|
* @return {Array} Array containing strings and expressions.
|
|
@@ -467,13 +489,11 @@ class Component extends View {
|
|
|
467
489
|
* });
|
|
468
490
|
*/
|
|
469
491
|
partial(strings, ...expressions) {
|
|
470
|
-
return deepFlat(
|
|
471
|
-
|
|
472
|
-
addPlaceholders(strings, expressions), expressions
|
|
473
|
-
),
|
|
474
|
-
)
|
|
475
|
-
return renderExpression(item, this);
|
|
476
|
-
}));
|
|
492
|
+
return deepFlat(
|
|
493
|
+
splitPlaceholders(
|
|
494
|
+
expandComponents(addPlaceholders(strings, expressions), expressions), expressions
|
|
495
|
+
).map(item => getExpressionResult(item, this))
|
|
496
|
+
);
|
|
477
497
|
}
|
|
478
498
|
|
|
479
499
|
getRecyclePlaceholder() {
|
|
@@ -610,6 +630,20 @@ class Component extends View {
|
|
|
610
630
|
return this;
|
|
611
631
|
}
|
|
612
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Mark a string as safe HTML to be rendered.
|
|
635
|
+
* Normally you don't need to use this method, as Rasti will automatically mark strings
|
|
636
|
+
* as safe HTML when the component is @link{#module_component_create created} and when
|
|
637
|
+
* using the @link{#module_component__partial Component.partial} method.
|
|
638
|
+
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
639
|
+
* @static
|
|
640
|
+
* @param {string} value
|
|
641
|
+
* @return {Rasti.SafeHTML} A safe HTML object.
|
|
642
|
+
*/
|
|
643
|
+
static markAsSafeHTML(value) {
|
|
644
|
+
return new SafeHTML(value);
|
|
645
|
+
}
|
|
646
|
+
|
|
613
647
|
/**
|
|
614
648
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
615
649
|
* @static
|
|
@@ -633,7 +667,6 @@ class Component extends View {
|
|
|
633
667
|
* It instantiate the Component view using options,
|
|
634
668
|
* appends its element into the DOM (if `el` is provided).
|
|
635
669
|
* And returns the view instance.
|
|
636
|
-
* <br><br> ⚠ **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
|
|
637
670
|
* @static
|
|
638
671
|
* @param {object} options The view options.
|
|
639
672
|
* @param {node} el Dom element to append the view element.
|
|
@@ -715,7 +748,7 @@ class Component extends View {
|
|
|
715
748
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
716
749
|
* const Navigation = Component.create`
|
|
717
750
|
* <nav>
|
|
718
|
-
* ${
|
|
751
|
+
* ${self => self.options.items.map(
|
|
719
752
|
* item => self.partial`<${Button}>${item.label}</${Button}>`
|
|
720
753
|
* )}
|
|
721
754
|
* </nav>
|
|
@@ -771,18 +804,18 @@ class Component extends View {
|
|
|
771
804
|
const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
|
|
772
805
|
// Get tag, attributes.
|
|
773
806
|
tag = function() {
|
|
774
|
-
return
|
|
807
|
+
return Component.sanitize(getExpressionResult(tagExpression, this));
|
|
775
808
|
};
|
|
776
809
|
// Get attributes.
|
|
777
810
|
attributes = function() {
|
|
778
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
811
|
+
return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
|
|
779
812
|
};
|
|
780
813
|
// Get events.
|
|
781
814
|
events = function() {
|
|
782
|
-
const onlyEvents = expandAttributes(attributesAndEvents, value =>
|
|
815
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
783
816
|
|
|
784
817
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
785
|
-
const typeListeners =
|
|
818
|
+
const typeListeners = getExpressionResult(onlyEvents[key], this);
|
|
786
819
|
|
|
787
820
|
Object.keys(typeListeners).forEach(selector => {
|
|
788
821
|
out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
|
|
@@ -795,9 +828,11 @@ class Component extends View {
|
|
|
795
828
|
if (close) {
|
|
796
829
|
const list = inner ? splitPlaceholders(inner, expressions) : [];
|
|
797
830
|
template = function(addChild) {
|
|
798
|
-
return deepFlat(list.map(item =>
|
|
831
|
+
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
799
832
|
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
800
|
-
|
|
833
|
+
if (item instanceof SafeHTML) return item;
|
|
834
|
+
if (item instanceof Component) return addChild(item);
|
|
835
|
+
return Component.sanitize(`${item}`);
|
|
801
836
|
}
|
|
802
837
|
return '';
|
|
803
838
|
}).join('');
|
|
@@ -811,7 +846,7 @@ class Component extends View {
|
|
|
811
846
|
// If there is only one expression and no tag, is a container.
|
|
812
847
|
template = function(addChild) {
|
|
813
848
|
// Replace expressions.
|
|
814
|
-
return addChild(
|
|
849
|
+
return addChild(getExpressionResult(expressions[match[1]], this)).toString();
|
|
815
850
|
};
|
|
816
851
|
} else {
|
|
817
852
|
throw new SyntaxError('Invalid component');
|
package/es/View.js
CHANGED
|
@@ -183,15 +183,15 @@ class View extends Emitter {
|
|
|
183
183
|
* Called from the constructor if `this.el` is undefined, to ensure
|
|
184
184
|
* the view has a root element.
|
|
185
185
|
* @param {string} tag Tag for the element. Default to `div`
|
|
186
|
-
* @param {object}
|
|
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(Component.markAsSafeHTML(before), expressions[match[1]]);
|
|
51
64
|
lastIndex = match.index + match[0].length;
|
|
52
65
|
}
|
|
53
|
-
out.push(main.slice(lastIndex));
|
|
66
|
+
out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
|
|
54
67
|
|
|
55
68
|
return out;
|
|
56
69
|
};
|
|
@@ -58,15 +71,15 @@ const splitPlaceholders = (main, expressions) => {
|
|
|
58
71
|
/*
|
|
59
72
|
* Expand attributes.
|
|
60
73
|
* @param attributes {array} Array of attributes as key, value pairs.
|
|
61
|
-
* @param
|
|
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(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
// Remove attributes that were in previousAttributes but not in current attributes.
|
|
364
|
+
Object.keys(previousAttributes).forEach(key => {
|
|
365
|
+
if (!(key in attributes)) {
|
|
366
|
+
remove[key] = true;
|
|
346
367
|
}
|
|
347
368
|
});
|
|
348
369
|
|
|
@@ -440,8 +461,9 @@ class Component extends View {
|
|
|
440
461
|
|
|
441
462
|
/**
|
|
442
463
|
* Tagged template helper method.
|
|
443
|
-
* Used to create a partial template.
|
|
444
|
-
* It will return a one-dimensional array with strings and expressions.
|
|
464
|
+
* Used to create a partial template.
|
|
465
|
+
* It will return a one-dimensional array with strings and expressions.
|
|
466
|
+
* Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
|
|
445
467
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
446
468
|
* @param {...any} expressions - Template expressions.
|
|
447
469
|
* @return {Array} Array containing strings and expressions.
|
|
@@ -469,13 +491,11 @@ class Component extends View {
|
|
|
469
491
|
* });
|
|
470
492
|
*/
|
|
471
493
|
partial(strings, ...expressions) {
|
|
472
|
-
return utils_deepFlat(
|
|
473
|
-
|
|
474
|
-
addPlaceholders(strings, expressions), expressions
|
|
475
|
-
),
|
|
476
|
-
)
|
|
477
|
-
return renderExpression(item, this);
|
|
478
|
-
}));
|
|
494
|
+
return utils_deepFlat(
|
|
495
|
+
splitPlaceholders(
|
|
496
|
+
expandComponents(addPlaceholders(strings, expressions), expressions), expressions
|
|
497
|
+
).map(item => getExpressionResult(item, this))
|
|
498
|
+
);
|
|
479
499
|
}
|
|
480
500
|
|
|
481
501
|
getRecyclePlaceholder() {
|
|
@@ -612,6 +632,20 @@ class Component extends View {
|
|
|
612
632
|
return this;
|
|
613
633
|
}
|
|
614
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Mark a string as safe HTML to be rendered.
|
|
637
|
+
* Normally you don't need to use this method, as Rasti will automatically mark strings
|
|
638
|
+
* as safe HTML when the component is @link{#module_component_create created} and when
|
|
639
|
+
* using the @link{#module_component__partial Component.partial} method.
|
|
640
|
+
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
641
|
+
* @static
|
|
642
|
+
* @param {string} value
|
|
643
|
+
* @return {Rasti.SafeHTML} A safe HTML object.
|
|
644
|
+
*/
|
|
645
|
+
static markAsSafeHTML(value) {
|
|
646
|
+
return new SafeHTML(value);
|
|
647
|
+
}
|
|
648
|
+
|
|
615
649
|
/**
|
|
616
650
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
617
651
|
* @static
|
|
@@ -635,7 +669,6 @@ class Component extends View {
|
|
|
635
669
|
* It instantiate the Component view using options,
|
|
636
670
|
* appends its element into the DOM (if `el` is provided).
|
|
637
671
|
* And returns the view instance.
|
|
638
|
-
* <br><br> ⚠ **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
|
|
639
672
|
* @static
|
|
640
673
|
* @param {object} options The view options.
|
|
641
674
|
* @param {node} el Dom element to append the view element.
|
|
@@ -717,7 +750,7 @@ class Component extends View {
|
|
|
717
750
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
718
751
|
* const Navigation = Component.create`
|
|
719
752
|
* <nav>
|
|
720
|
-
* ${
|
|
753
|
+
* ${self => self.options.items.map(
|
|
721
754
|
* item => self.partial`<${Button}>${item.label}</${Button}>`
|
|
722
755
|
* )}
|
|
723
756
|
* </nav>
|
|
@@ -773,18 +806,18 @@ class Component extends View {
|
|
|
773
806
|
const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
|
|
774
807
|
// Get tag, attributes.
|
|
775
808
|
tag = function() {
|
|
776
|
-
return
|
|
809
|
+
return Component.sanitize(getExpressionResult(tagExpression, this));
|
|
777
810
|
};
|
|
778
811
|
// Get attributes.
|
|
779
812
|
attributes = function() {
|
|
780
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
813
|
+
return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
|
|
781
814
|
};
|
|
782
815
|
// Get events.
|
|
783
816
|
events = function() {
|
|
784
|
-
const onlyEvents = expandAttributes(attributesAndEvents, value =>
|
|
817
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
785
818
|
|
|
786
819
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
787
|
-
const typeListeners =
|
|
820
|
+
const typeListeners = getExpressionResult(onlyEvents[key], this);
|
|
788
821
|
|
|
789
822
|
Object.keys(typeListeners).forEach(selector => {
|
|
790
823
|
out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
|
|
@@ -797,9 +830,11 @@ class Component extends View {
|
|
|
797
830
|
if (close) {
|
|
798
831
|
const list = inner ? splitPlaceholders(inner, expressions) : [];
|
|
799
832
|
template = function(addChild) {
|
|
800
|
-
return utils_deepFlat(list.map(item =>
|
|
833
|
+
return utils_deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
801
834
|
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
802
|
-
|
|
835
|
+
if (item instanceof SafeHTML) return item;
|
|
836
|
+
if (item instanceof Component) return addChild(item);
|
|
837
|
+
return Component.sanitize(`${item}`);
|
|
803
838
|
}
|
|
804
839
|
return '';
|
|
805
840
|
}).join('');
|
|
@@ -813,7 +848,7 @@ class Component extends View {
|
|
|
813
848
|
// If there is only one expression and no tag, is a container.
|
|
814
849
|
template = function(addChild) {
|
|
815
850
|
// Replace expressions.
|
|
816
|
-
return addChild(
|
|
851
|
+
return addChild(getExpressionResult(expressions[match[1]], this)).toString();
|
|
817
852
|
};
|
|
818
853
|
} else {
|
|
819
854
|
throw new SyntaxError('Invalid component');
|
package/lib/View.cjs
CHANGED
|
@@ -185,15 +185,15 @@ class View extends Emitter {
|
|
|
185
185
|
* Called from the constructor if `this.el` is undefined, to ensure
|
|
186
186
|
* the view has a root element.
|
|
187
187
|
* @param {string} tag Tag for the element. Default to `div`
|
|
188
|
-
* @param {object}
|
|
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(Component.markAsSafeHTML(before), expressions[match[1]]);
|
|
48
61
|
lastIndex = match.index + match[0].length;
|
|
49
62
|
}
|
|
50
|
-
out.push(main.slice(lastIndex));
|
|
63
|
+
out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
|
|
51
64
|
|
|
52
65
|
return out;
|
|
53
66
|
};
|
|
@@ -55,15 +68,15 @@ const splitPlaceholders = (main, expressions) => {
|
|
|
55
68
|
/*
|
|
56
69
|
* Expand attributes.
|
|
57
70
|
* @param attributes {array} Array of attributes as key, value pairs.
|
|
58
|
-
* @param
|
|
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(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
// Remove attributes that were in previousAttributes but not in current attributes.
|
|
361
|
+
Object.keys(previousAttributes).forEach(key => {
|
|
362
|
+
if (!(key in attributes)) {
|
|
363
|
+
remove[key] = true;
|
|
343
364
|
}
|
|
344
365
|
});
|
|
345
366
|
|
|
@@ -437,8 +458,9 @@ export default class Component extends View {
|
|
|
437
458
|
|
|
438
459
|
/**
|
|
439
460
|
* Tagged template helper method.
|
|
440
|
-
* Used to create a partial template.
|
|
441
|
-
* It will return a one-dimensional array with strings and expressions.
|
|
461
|
+
* Used to create a partial template.
|
|
462
|
+
* It will return a one-dimensional array with strings and expressions.
|
|
463
|
+
* Components will be added as children by the parent component. Template strings will be marked as safe HTML to be rendered.
|
|
442
464
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
443
465
|
* @param {...any} expressions - Template expressions.
|
|
444
466
|
* @return {Array} Array containing strings and expressions.
|
|
@@ -466,13 +488,11 @@ export default class Component extends View {
|
|
|
466
488
|
* });
|
|
467
489
|
*/
|
|
468
490
|
partial(strings, ...expressions) {
|
|
469
|
-
return deepFlat(
|
|
470
|
-
|
|
471
|
-
addPlaceholders(strings, expressions), expressions
|
|
472
|
-
),
|
|
473
|
-
)
|
|
474
|
-
return renderExpression(item, this);
|
|
475
|
-
}));
|
|
491
|
+
return deepFlat(
|
|
492
|
+
splitPlaceholders(
|
|
493
|
+
expandComponents(addPlaceholders(strings, expressions), expressions), expressions
|
|
494
|
+
).map(item => getExpressionResult(item, this))
|
|
495
|
+
);
|
|
476
496
|
}
|
|
477
497
|
|
|
478
498
|
getRecyclePlaceholder() {
|
|
@@ -609,6 +629,20 @@ export default class Component extends View {
|
|
|
609
629
|
return this;
|
|
610
630
|
}
|
|
611
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Mark a string as safe HTML to be rendered.
|
|
634
|
+
* Normally you don't need to use this method, as Rasti will automatically mark strings
|
|
635
|
+
* as safe HTML when the component is @link{#module_component_create created} and when
|
|
636
|
+
* using the @link{#module_component__partial Component.partial} method.
|
|
637
|
+
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
638
|
+
* @static
|
|
639
|
+
* @param {string} value
|
|
640
|
+
* @return {Rasti.SafeHTML} A safe HTML object.
|
|
641
|
+
*/
|
|
642
|
+
static markAsSafeHTML(value) {
|
|
643
|
+
return new SafeHTML(value);
|
|
644
|
+
}
|
|
645
|
+
|
|
612
646
|
/**
|
|
613
647
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
614
648
|
* @static
|
|
@@ -632,7 +666,6 @@ export default class Component extends View {
|
|
|
632
666
|
* It instantiate the Component view using options,
|
|
633
667
|
* appends its element into the DOM (if `el` is provided).
|
|
634
668
|
* And returns the view instance.
|
|
635
|
-
* <br><br> ⚠ **Security Notice:** `Component` utilizes `innerHTML` on a document fragment for rendering, which may introduce Cross - Site Scripting (XSS) risks. Ensure that any user-generated content is properly sanitized before inserting it into the DOM. For best practices on secure data handling, refer to the [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
|
|
636
669
|
* @static
|
|
637
670
|
* @param {object} options The view options.
|
|
638
671
|
* @param {node} el Dom element to append the view element.
|
|
@@ -714,7 +747,7 @@ export default class Component extends View {
|
|
|
714
747
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
715
748
|
* const Navigation = Component.create`
|
|
716
749
|
* <nav>
|
|
717
|
-
* ${
|
|
750
|
+
* ${self => self.options.items.map(
|
|
718
751
|
* item => self.partial`<${Button}>${item.label}</${Button}>`
|
|
719
752
|
* )}
|
|
720
753
|
* </nav>
|
|
@@ -770,18 +803,18 @@ export default class Component extends View {
|
|
|
770
803
|
const { tag : tagExpression, attributes : attributesAndEvents, inner, close } = parseMatch(match, expressions);
|
|
771
804
|
// Get tag, attributes.
|
|
772
805
|
tag = function() {
|
|
773
|
-
return
|
|
806
|
+
return Component.sanitize(getExpressionResult(tagExpression, this));
|
|
774
807
|
};
|
|
775
808
|
// Get attributes.
|
|
776
809
|
attributes = function() {
|
|
777
|
-
return expandAttributes(attributesAndEvents, value =>
|
|
810
|
+
return expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).attributes;
|
|
778
811
|
};
|
|
779
812
|
// Get events.
|
|
780
813
|
events = function() {
|
|
781
|
-
const onlyEvents = expandAttributes(attributesAndEvents, value =>
|
|
814
|
+
const onlyEvents = expandAttributes(attributesAndEvents, value => getExpressionResult(value, this)).events;
|
|
782
815
|
|
|
783
816
|
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
784
|
-
const typeListeners =
|
|
817
|
+
const typeListeners = getExpressionResult(onlyEvents[key], this);
|
|
785
818
|
|
|
786
819
|
Object.keys(typeListeners).forEach(selector => {
|
|
787
820
|
out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
|
|
@@ -794,9 +827,11 @@ export default class Component extends View {
|
|
|
794
827
|
if (close) {
|
|
795
828
|
const list = inner ? splitPlaceholders(inner, expressions) : [];
|
|
796
829
|
template = function(addChild) {
|
|
797
|
-
return deepFlat(list.map(item =>
|
|
830
|
+
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
798
831
|
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
799
|
-
|
|
832
|
+
if (item instanceof SafeHTML) return item;
|
|
833
|
+
if (item instanceof Component) return addChild(item);
|
|
834
|
+
return Component.sanitize(`${item}`);
|
|
800
835
|
}
|
|
801
836
|
return '';
|
|
802
837
|
}).join('');
|
|
@@ -810,7 +845,7 @@ export default class Component extends View {
|
|
|
810
845
|
// If there is only one expression and no tag, is a container.
|
|
811
846
|
template = function(addChild) {
|
|
812
847
|
// Replace expressions.
|
|
813
|
-
return addChild(
|
|
848
|
+
return addChild(getExpressionResult(expressions[match[1]], this)).toString();
|
|
814
849
|
};
|
|
815
850
|
} else {
|
|
816
851
|
throw new SyntaxError('Invalid component');
|
package/src/View.js
CHANGED
|
@@ -183,15 +183,15 @@ export default class View extends Emitter {
|
|
|
183
183
|
* Called from the constructor if `this.el` is undefined, to ensure
|
|
184
184
|
* the view has a root element.
|
|
185
185
|
* @param {string} tag Tag for the element. Default to `div`
|
|
186
|
-
* @param {object}
|
|
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
|
/*
|