diff --git a/public/lib/amd/build/local/templates/renderer.min.js b/public/lib/amd/build/local/templates/renderer.min.js index bf2bc29a7a7..cf8c1df53ee 100644 --- a/public/lib/amd/build/local/templates/renderer.min.js +++ b/public/lib/amd/build/local/templates/renderer.min.js @@ -9,6 +9,6 @@ define("core/local/templates/renderer",["exports","core/log","core/truncate","co * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.3 */ -class Renderer{constructor(){_defineProperty(this,"requiredStrings",null),_defineProperty(this,"requiredDates",[]),_defineProperty(this,"requiredJS",null),_defineProperty(this,"currentThemeName",""),_defineProperty(this,"iconSystem",null),this.requiredStrings=[],this.requiredJS=[],this.requiredDates=[],this.currentThemeName=""}static setLoader(loader){this.loader=loader}static getLoader(){return this.loader}async renderIcon(key,component,title){component=(0,_utils.getNormalisedComponent)(component),await this.setupIconSystem();const template=await Renderer.getLoader().getTemplate(this.iconSystem.getTemplateName(),this.currentThemeName);return this.iconSystem.renderIcon(key,component,title,template)}async setupIconSystem(){return this.iconSystem||(this.iconSystem=await _icon_system.default.instance()),this.iconSystem}pixHelper(context,sectionText,helper){const parts=sectionText.split(",");let key="",component="",text="";parts.length>0&&(key=helper(parts.shift().trim(),context)),parts.length>0&&(component=helper(parts.shift().trim(),context)),parts.length>0&&(text=helper(parts.join(",").trim(),context));const Loader=Renderer.getLoader(),templateName=this.iconSystem.getTemplateName(),searchKey=Loader.getSearchKey(this.currentThemeName,templateName),template=Loader.getTemplateFromCache(searchKey);return component=(0,_utils.getNormalisedComponent)(component),key=key.replace(///gi,"/"),this.iconSystem.renderIcon(key,component,text,template)}jsHelper(context,sectionText,helper){return this.requiredJS.push(helper(sectionText,context)),""}stringHelper(context,sectionText,helper){let parts=sectionText.split(",");const key=parts.length>0?parts.shift().trim():"",component=parts.length>0?(0,_utils.getNormalisedComponent)(parts.shift().trim()):"";let param=parts.length>0?parts.join(",").trim():"";if(""!==param&&(param=helper(param,context)),param.match(/^{\s*"/gm))try{const parsedParam=JSON.parse(param);parsedParam&&"object"==typeof parsedParam&&(param=parsedParam)}catch(err){window.console.warn(err.message)}const index=this.requiredStrings.length;return this.requiredStrings.push({key:key,component:component,param:param}),"[[_s".concat(index,"]]")}cleanStringHelper(context,sectionText,helper){return this.stringHelper(context,sectionText,helper).replace("s","c")}quoteHelper(context,sectionText,helper){let content=helper(sectionText.trim(),context);return content=JSON.stringify(content),content=content.replace(/([{}]{2,3})/g,"{{=<% %>=}}$1<%={{ }}=%>"),content}shortenTextHelper(context,sectionText,helper){const parts=sectionText.match(/(.*?),(.*)/),length=parts[1].trim(),content=helper(parts[2].trim(),context);return Truncate.truncate(content,{length:length,words:!0,ellipsis:"..."})}userDateHelper(context,sectionText,helper){const parts=sectionText.match(/(.*?),(.*)/),timestamp=helper(parts[1].trim(),context),format=helper(parts[2].trim(),context),index=this.requiredDates.length;return this.requiredDates.push({timestamp:timestamp,format:format}),"[[_t_".concat(index,"]]")}addHelperFunction(helperFunction,context){return function(){return function(sectionText,helper){const originalHelpers=Renderer.disallowedNestedHelpers.reduce(((carry,name)=>(context.hasOwnProperty(name)&&(carry[name]=context[name]),carry)),{});Renderer.disallowedNestedHelpers.forEach((helperName=>{context[helperName]=()=>""}));const result=helperFunction.apply(this,[context,sectionText,helper]);for(const name in originalHelpers)context[name]=originalHelpers[name];return result}.bind(this)}.bind(this)}addHelpers(context,themeName){this.currentThemeName=themeName,this.requiredStrings=[],this.requiredJS=[],context.uniqid=Renderer.uniqInstances++,context.str=this.addHelperFunction(this.stringHelper,context),context.cleanstr=this.addHelperFunction(this.cleanStringHelper,context),context.pix=this.addHelperFunction(this.pixHelper,context),context.js=this.addHelperFunction(this.jsHelper,context),context.quote=this.addHelperFunction(this.quoteHelper,context),context.shortentext=this.addHelperFunction(this.shortenTextHelper,context),context.userdate=this.addHelperFunction(this.userDateHelper,context),context.globals={config:_config.default},context.currentTheme=themeName}getJS(){return this.requiredJS.join(";\n")}treatStringsInContent(content,stringMap){const stringPattern=/(?\[\[_(?[cs])(?\d+)\]\])/g,getUpdatedString=_ref=>{let{placeholder:placeholder,stringType:stringType,stringIndex:stringIndex}=_ref;if(stringMap.has(placeholder))return stringMap.get(placeholder);if("c"===stringType){const uncleanString=stringMap.get("[[_s".concat(stringIndex,"]]"));if(uncleanString)return stringMap.set(placeholder,_mustache.default.escape(uncleanString)),stringMap.get(placeholder)}return Log.debug("Could not find string for pattern ".concat(placeholder)),""};let updatedContent=content,placeholderFound=!0;for(;placeholderFound;){let match,result=[],lastIndex=0;for(placeholderFound=!1;null!==(match=stringPattern.exec(updatedContent));)placeholderFound=!0,result.push(updatedContent.slice(lastIndex,match.index)),result.push(getUpdatedString(match.groups)),lastIndex=match.index+match[0].length;result.push(updatedContent.slice(lastIndex)),updatedContent=result.join("")}return updatedContent}treatDatesInContent(content,dates){return dates.forEach(((date,index)=>{content=content.replace(new RegExp("\\[\\[_t_".concat(index,"\\]\\]"),"g"),date)})),content}async doRender(templateSourcePromise,context,themeName){this.currentThemeName=themeName;const iconTemplate=this.iconSystem.getTemplateName(),pendingPromise=new _pending.default("core/templates:doRender"),[templateSource]=await Promise.all([templateSourcePromise,Renderer.getLoader().getTemplate(iconTemplate,themeName)]),templateContext={...context};this.addHelpers(templateContext,themeName);const renderedContent=await _mustache.default.render(templateSource,templateContext,(partialName=>Renderer.getLoader().partialHelper(partialName,themeName))),{html:html,js:js}=await this.processRenderedContent(renderedContent);return pendingPromise.resolve(),{html:html,js:js}}async processRenderedContent(renderedContent){let html=renderedContent.trim(),js=this.getJS();if(this.requiredStrings.length>0){const stringMap=new Map((await(0,_str.getStrings)(this.requiredStrings)).map(((string,index)=>["[[_s".concat(index,"]]"),string])));this.requiredDates=this.requiredDates.map(function(date){return{timestamp:this.treatStringsInContent(date.timestamp,stringMap),format:this.treatStringsInContent(date.format,stringMap)}}.bind(this)),html=this.treatStringsInContent(html,stringMap),js=this.treatStringsInContent(js,stringMap)}if(this.requiredDates.length>0){const dates=await UserDate.get(this.requiredDates);html=this.treatDatesInContent(html,dates),js=this.treatDatesInContent(js,dates)}return{html:html,js:js}}async render(templateName){let context=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},themeName=arguments.length>2&&void 0!==arguments[2]?arguments[2]:_config.default.theme;this.currentThemeName=themeName,await this.setupIconSystem();const templateSource=Renderer.getLoader().cachePartials(templateName,themeName);return this.doRender(templateSource,context,themeName)}}return _exports.default=Renderer,_defineProperty(Renderer,"uniqInstances",0),_defineProperty(Renderer,"loadTemplateBuffer",[]),_defineProperty(Renderer,"isLoadingTemplates",!1),_defineProperty(Renderer,"disallowedNestedHelpers",["js"]),_defineProperty(Renderer,"templateCache",{}),_defineProperty(Renderer,"templatePromises",{}),_defineProperty(Renderer,"loader",_loader.default),_exports.default})); +class Renderer{constructor(){_defineProperty(this,"requiredStrings",null),_defineProperty(this,"requiredDates",[]),_defineProperty(this,"requiredJS",null),_defineProperty(this,"currentThemeName",""),_defineProperty(this,"iconSystem",null),this.requiredStrings=[],this.requiredJS=[],this.requiredDates=[],this.currentThemeName=""}static setLoader(loader){this.loader=loader}static getLoader(){return this.loader}async renderIcon(key,component,title){component=(0,_utils.getNormalisedComponent)(component),await this.setupIconSystem();const template=await Renderer.getLoader().getTemplate(this.iconSystem.getTemplateName(),this.currentThemeName);return this.iconSystem.renderIcon(key,component,title,template)}async setupIconSystem(){return this.iconSystem||(this.iconSystem=await _icon_system.default.instance()),this.iconSystem}pixHelper(context,sectionText,helper){const parts=sectionText.split(",");let key="",component="",text="";parts.length>0&&(key=helper(parts.shift().trim(),context)),parts.length>0&&(component=helper(parts.shift().trim(),context)),parts.length>0&&(text=helper(parts.join(",").trim(),context));const Loader=Renderer.getLoader(),templateName=this.iconSystem.getTemplateName(),searchKey=Loader.getSearchKey(this.currentThemeName,templateName),template=Loader.getTemplateFromCache(searchKey);return component=(0,_utils.getNormalisedComponent)(component),key=key.replace(///gi,"/"),this.iconSystem.renderIcon(key,component,text,template)}jsHelper(context,sectionText,helper){return this.requiredJS.push(helper(sectionText,context)),""}reactHelper(context,sectionText,helper){const trimmedContent=helper(sectionText,context),createReactRoot=content=>{const data=JSON.parse(content.trim().replace(/,(\s*[}\]])/g,"$1")),root=document.createElement("div");for(const[key,value]of Object.entries(data))null!==value&&""!==value&&("component"===key?root.setAttribute("data-react-component",value):"props"===key?root.setAttribute("data-react-props",JSON.stringify(value)):"boolean"==typeof value?value&&root.setAttribute(key,""):"object"==typeof value?root.setAttribute(key,JSON.stringify(value)):root.setAttribute(key,String(value)));return root},firstCurlyIndex=trimmedContent.indexOf("{");if(-1===firstCurlyIndex)return helper(sectionText,context);let lastCurlyIndex=trimmedContent.length;do{try{const contentDiv=createReactRoot(trimmedContent.substring(firstCurlyIndex,lastCurlyIndex+1));return contentDiv.innerHTML=trimmedContent.substring(lastCurlyIndex+1),contentDiv.outerHTML}catch(e){}lastCurlyIndex=trimmedContent.lastIndexOf("}",lastCurlyIndex-1)}while(lastCurlyIndex>firstCurlyIndex);return trimmedContent}stringHelper(context,sectionText,helper){let parts=sectionText.split(",");const key=parts.length>0?parts.shift().trim():"",component=parts.length>0?(0,_utils.getNormalisedComponent)(parts.shift().trim()):"";let param=parts.length>0?parts.join(",").trim():"";if(""!==param&&(param=helper(param,context)),param.match(/^{\s*"/gm))try{const parsedParam=JSON.parse(param);parsedParam&&"object"==typeof parsedParam&&(param=parsedParam)}catch(err){window.console.warn(err.message)}const index=this.requiredStrings.length;return this.requiredStrings.push({key:key,component:component,param:param}),"[[_s".concat(index,"]]")}cleanStringHelper(context,sectionText,helper){return this.stringHelper(context,sectionText,helper).replace("s","c")}quoteHelper(context,sectionText,helper){let content=helper(sectionText.trim(),context);return content=JSON.stringify(content),content=content.replace(/([{}]{2,3})/g,"{{=<% %>=}}$1<%={{ }}=%>"),content}shortenTextHelper(context,sectionText,helper){const parts=sectionText.match(/(.*?),(.*)/),length=parts[1].trim(),content=helper(parts[2].trim(),context);return Truncate.truncate(content,{length:length,words:!0,ellipsis:"..."})}userDateHelper(context,sectionText,helper){const parts=sectionText.match(/(.*?),(.*)/),timestamp=helper(parts[1].trim(),context),format=helper(parts[2].trim(),context),index=this.requiredDates.length;return this.requiredDates.push({timestamp:timestamp,format:format}),"[[_t_".concat(index,"]]")}addHelperFunction(helperFunction,context){return function(){return function(sectionText,helper){const originalHelpers=Renderer.disallowedNestedHelpers.reduce(((carry,name)=>(context.hasOwnProperty(name)&&(carry[name]=context[name]),carry)),{});Renderer.disallowedNestedHelpers.forEach((helperName=>{context[helperName]=()=>""}));const result=helperFunction.apply(this,[context,sectionText,helper]);for(const name in originalHelpers)context[name]=originalHelpers[name];return result}.bind(this)}.bind(this)}addHelpers(context,themeName){this.currentThemeName=themeName,this.requiredStrings=[],this.requiredJS=[],context.uniqid=Renderer.uniqInstances++,context.str=this.addHelperFunction(this.stringHelper,context),context.cleanstr=this.addHelperFunction(this.cleanStringHelper,context),context.pix=this.addHelperFunction(this.pixHelper,context),context.js=this.addHelperFunction(this.jsHelper,context),context.react=this.addHelperFunction(this.reactHelper,context),context.quote=this.addHelperFunction(this.quoteHelper,context),context.shortentext=this.addHelperFunction(this.shortenTextHelper,context),context.userdate=this.addHelperFunction(this.userDateHelper,context),context.globals={config:_config.default},context.currentTheme=themeName}getJS(){return this.requiredJS.join(";\n")}treatStringsInContent(content,stringMap){const stringPattern=/(?\[\[_(?[cs])(?\d+)\]\])/g,getUpdatedString=_ref=>{let{placeholder:placeholder,stringType:stringType,stringIndex:stringIndex}=_ref;if(stringMap.has(placeholder))return stringMap.get(placeholder);if("c"===stringType){const uncleanString=stringMap.get("[[_s".concat(stringIndex,"]]"));if(uncleanString)return stringMap.set(placeholder,_mustache.default.escape(uncleanString)),stringMap.get(placeholder)}return Log.debug("Could not find string for pattern ".concat(placeholder)),""};let updatedContent=content,placeholderFound=!0;for(;placeholderFound;){let match,result=[],lastIndex=0;for(placeholderFound=!1;null!==(match=stringPattern.exec(updatedContent));)placeholderFound=!0,result.push(updatedContent.slice(lastIndex,match.index)),result.push(getUpdatedString(match.groups)),lastIndex=match.index+match[0].length;result.push(updatedContent.slice(lastIndex)),updatedContent=result.join("")}return updatedContent}treatDatesInContent(content,dates){return dates.forEach(((date,index)=>{content=content.replace(new RegExp("\\[\\[_t_".concat(index,"\\]\\]"),"g"),date)})),content}async doRender(templateSourcePromise,context,themeName){this.currentThemeName=themeName;const iconTemplate=this.iconSystem.getTemplateName(),pendingPromise=new _pending.default("core/templates:doRender"),[templateSource]=await Promise.all([templateSourcePromise,Renderer.getLoader().getTemplate(iconTemplate,themeName)]),templateContext={...context};this.addHelpers(templateContext,themeName);const renderedContent=await _mustache.default.render(templateSource,templateContext,(partialName=>Renderer.getLoader().partialHelper(partialName,themeName))),{html:html,js:js}=await this.processRenderedContent(renderedContent);return pendingPromise.resolve(),{html:html,js:js}}async processRenderedContent(renderedContent){let html=renderedContent.trim(),js=this.getJS();if(this.requiredStrings.length>0){const stringMap=new Map((await(0,_str.getStrings)(this.requiredStrings)).map(((string,index)=>["[[_s".concat(index,"]]"),string])));this.requiredDates=this.requiredDates.map(function(date){return{timestamp:this.treatStringsInContent(date.timestamp,stringMap),format:this.treatStringsInContent(date.format,stringMap)}}.bind(this)),html=this.treatStringsInContent(html,stringMap),js=this.treatStringsInContent(js,stringMap)}if(this.requiredDates.length>0){const dates=await UserDate.get(this.requiredDates);html=this.treatDatesInContent(html,dates),js=this.treatDatesInContent(js,dates)}return{html:html,js:js}}async render(templateName){let context=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},themeName=arguments.length>2&&void 0!==arguments[2]?arguments[2]:_config.default.theme;this.currentThemeName=themeName,await this.setupIconSystem();const templateSource=Renderer.getLoader().cachePartials(templateName,themeName);return this.doRender(templateSource,context,themeName)}}return _exports.default=Renderer,_defineProperty(Renderer,"uniqInstances",0),_defineProperty(Renderer,"loadTemplateBuffer",[]),_defineProperty(Renderer,"isLoadingTemplates",!1),_defineProperty(Renderer,"disallowedNestedHelpers",["js"]),_defineProperty(Renderer,"templateCache",{}),_defineProperty(Renderer,"templatePromises",{}),_defineProperty(Renderer,"loader",_loader.default),_exports.default})); //# sourceMappingURL=renderer.min.js.map \ No newline at end of file diff --git a/public/lib/amd/build/local/templates/renderer.min.js.map b/public/lib/amd/build/local/templates/renderer.min.js.map index 464ffb4b1fa..a231cae0d22 100644 --- a/public/lib/amd/build/local/templates/renderer.min.js.map +++ b/public/lib/amd/build/local/templates/renderer.min.js.map @@ -1 +1 @@ -{"version":3,"file":"renderer.min.js","sources":["../../../src/local/templates/renderer.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport * as Log from 'core/log';\nimport * as Truncate from 'core/truncate';\nimport * as UserDate from 'core/user_date';\nimport Pending from 'core/pending';\nimport {getStrings} from 'core/str';\nimport IconSystem from 'core/icon_system';\nimport config from 'core/config';\nimport mustache from 'core/mustache';\nimport Loader from './loader';\nimport {getNormalisedComponent} from 'core/utils';\n\n/** @var {string} The placeholder character used for standard strings (unclean) */\nconst placeholderString = 's';\n\n/** @var {string} The placeholder character used for cleaned strings */\nconst placeholderCleanedString = 'c';\n\n/** @var {Function} originalMustacheEscape */\nconst originalMustacheEscape = mustache.escape;\n\n// Replicate escaping logic of PHP s() function.\nmustache.escape = function(string) {\n string = originalMustacheEscape(string);\n string = string.replace(/&#([0-9]+|x[0-9a-fA-F]+);/g, '&#$1;');\n return string;\n};\n\n/**\n * Template Renderer Class.\n *\n * Note: This class is not intended to be instantiated directly. Instead, use the core/templates module.\n *\n * @module core/local/templates/renderer\n * @copyright 2023 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 4.3\n */\nexport default class Renderer {\n /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */\n requiredStrings = null;\n\n /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */\n requiredDates = [];\n\n /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */\n requiredJS = null;\n\n /** @var {String} themeName for the current render */\n currentThemeName = '';\n\n /** @var {Number} uniqInstances Count of times this constructor has been called. */\n static uniqInstances = 0;\n\n /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */\n static loadTemplateBuffer = [];\n\n /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */\n static isLoadingTemplates = false;\n\n /** @var {Object} iconSystem - Object extending core/iconsystem */\n iconSystem = null;\n\n /** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */\n static disallowedNestedHelpers = [\n 'js',\n ];\n\n /** @var {String[]} templateCache - Cache of already loaded template strings */\n static templateCache = {};\n\n /**\n * Cache of already loaded template promises.\n *\n * @type {Promise[]}\n * @static\n * @private\n */\n static templatePromises = {};\n\n /**\n * The loader used to fetch templates.\n * @type {Loader}\n * @static\n * @private\n */\n static loader = Loader;\n\n /**\n * Constructor\n *\n * Each call to templates.render gets it's own instance of this class.\n */\n constructor() {\n this.requiredStrings = [];\n this.requiredJS = [];\n this.requiredDates = [];\n this.currentThemeName = '';\n }\n\n /**\n * Set the template loader to use for all Template renderers.\n *\n * @param {Loader} loader\n */\n static setLoader(loader) {\n this.loader = loader;\n }\n\n /**\n * Get the Loader used to fetch templates.\n *\n * @returns {Loader}\n */\n static getLoader() {\n return this.loader;\n }\n\n /**\n * Render a single image icon.\n *\n * @method renderIcon\n * @private\n * @param {string} key The icon key.\n * @param {string} component The component name.\n * @param {string} title The icon title\n * @returns {Promise}\n */\n async renderIcon(key, component, title) {\n // Preload the module to do the icon rendering based on the theme iconsystem.\n component = getNormalisedComponent(component);\n\n await this.setupIconSystem();\n const template = await Renderer.getLoader().getTemplate(\n this.iconSystem.getTemplateName(),\n this.currentThemeName,\n );\n\n return this.iconSystem.renderIcon(\n key,\n component,\n title,\n template\n );\n }\n\n /**\n * Helper to set up the icon system.\n */\n async setupIconSystem() {\n if (!this.iconSystem) {\n this.iconSystem = await IconSystem.instance();\n }\n\n return this.iconSystem;\n }\n\n /**\n * Render image icons.\n *\n * @method pixHelper\n * @private\n * @param {object} context The mustache context\n * @param {string} sectionText The text to parse arguments from.\n * @param {function} helper Used to render the alt attribute of the text.\n * @returns {string}\n */\n pixHelper(context, sectionText, helper) {\n const parts = sectionText.split(',');\n let key = '';\n let component = '';\n let text = '';\n\n if (parts.length > 0) {\n key = helper(parts.shift().trim(), context);\n }\n if (parts.length > 0) {\n component = helper(parts.shift().trim(), context);\n }\n if (parts.length > 0) {\n text = helper(parts.join(',').trim(), context);\n }\n\n // Note: We cannot use Promises in Mustache helpers.\n // We must fetch straight from the Loader cache.\n // The Loader cache is statically defined on the Loader class and should be used by all children.\n const Loader = Renderer.getLoader();\n const templateName = this.iconSystem.getTemplateName();\n const searchKey = Loader.getSearchKey(this.currentThemeName, templateName);\n const template = Loader.getTemplateFromCache(searchKey);\n\n component = getNormalisedComponent(component);\n\n // The key might have been escaped by the JS Mustache engine which\n // converts forward slashes to HTML entities. Let us undo that here.\n key = key.replace(///gi, '/');\n\n return this.iconSystem.renderIcon(\n key,\n component,\n text,\n template\n );\n }\n\n /**\n * Render blocks of javascript and save them in an array.\n *\n * @method jsHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to save as a js block.\n * @param {function} helper Used to render the block.\n * @returns {string}\n */\n jsHelper(context, sectionText, helper) {\n this.requiredJS.push(helper(sectionText, context));\n return '';\n }\n\n /**\n * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}\n * into a get_string call.\n *\n * @method stringHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n stringHelper(context, sectionText, helper) {\n // A string instruction is in the format:\n // key, component, params.\n\n let parts = sectionText.split(',');\n\n const key = parts.length > 0 ? parts.shift().trim() : '';\n const component = parts.length > 0 ? getNormalisedComponent(parts.shift().trim()) : '';\n let param = parts.length > 0 ? parts.join(',').trim() : '';\n\n if (param !== '') {\n // Allow variable expansion in the param part only.\n param = helper(param, context);\n }\n\n if (param.match(/^{\\s*\"/gm)) {\n // If it can't be parsed then the string is not a JSON format.\n try {\n const parsedParam = JSON.parse(param);\n // Handle non-exception-throwing cases, e.g. null, integer, boolean.\n if (parsedParam && typeof parsedParam === \"object\") {\n param = parsedParam;\n }\n } catch (err) {\n // This was probably not JSON.\n // Keep the error message visible but do not promote it because it may not be an error.\n window.console.warn(err.message);\n }\n }\n\n const index = this.requiredStrings.length;\n this.requiredStrings.push({\n key,\n component,\n param,\n });\n\n // The placeholder must not use {{}} as those can be misinterpreted by the engine.\n return `[[_s${index}]]`;\n }\n\n /**\n * String helper to render {{#cleanstr}}abd component { a : 'fish'}{{/cleanstr}}\n * into a get_string following by an HTML escape.\n *\n * @method cleanStringHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n cleanStringHelper(context, sectionText, helper) {\n // We're going to use [[_cx]] format for clean strings, where x is a number.\n // Hence, replacing 's' with 'c' in the placeholder that stringHelper returns.\n return this\n .stringHelper(context, sectionText, helper)\n .replace(placeholderString, placeholderCleanedString);\n }\n\n /**\n * Quote helper used to wrap content in quotes, and escape all special JSON characters present in the content.\n *\n * @method quoteHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n quoteHelper(context, sectionText, helper) {\n let content = helper(sectionText.trim(), context);\n\n // Escape the {{ and JSON encode.\n // This involves wrapping {{, and }} in change delimeter tags.\n content = JSON.stringify(content);\n content = content.replace(/([{}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>');\n return content;\n }\n\n /**\n * Shorten text helper to truncate text and append a trailing ellipsis.\n *\n * @method shortenTextHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n shortenTextHelper(context, sectionText, helper) {\n // Non-greedy split on comma to grab section text into the length and\n // text parts.\n const parts = sectionText.match(/(.*?),(.*)/);\n\n // The length is the part matched in the first set of parethesis.\n const length = parts[1].trim();\n // The length is the part matched in the second set of parethesis.\n const text = parts[2].trim();\n const content = helper(text, context);\n return Truncate.truncate(content, {\n length,\n words: true,\n ellipsis: '...'\n });\n }\n\n /**\n * User date helper to render user dates from timestamps.\n *\n * @method userDateHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n userDateHelper(context, sectionText, helper) {\n // Non-greedy split on comma to grab the timestamp and format.\n const parts = sectionText.match(/(.*?),(.*)/);\n\n const timestamp = helper(parts[1].trim(), context);\n const format = helper(parts[2].trim(), context);\n const index = this.requiredDates.length;\n\n this.requiredDates.push({\n timestamp: timestamp,\n format: format\n });\n\n return `[[_t_${index}]]`;\n }\n\n /**\n * Return a helper function to be added to the context for rendering the a\n * template.\n *\n * This will parse the provided text before giving it to the helper function\n * in order to remove any disallowed nested helpers to prevent one helper\n * from calling another.\n *\n * In particular to prevent the JS helper from being called from within another\n * helper because it can lead to security issues when the JS portion is user\n * provided.\n *\n * @param {function} helperFunction The helper function to add\n * @param {object} context The template context for the helper function\n * @returns {Function} To be set in the context\n */\n addHelperFunction(helperFunction, context) {\n return function() {\n return function(sectionText, helper) {\n // Override the disallowed helpers in the template context with\n // a function that returns an empty string for use when executing\n // other helpers. This is to prevent these helpers from being\n // executed as part of the rendering of another helper in order to\n // prevent any potential security issues.\n const originalHelpers = Renderer.disallowedNestedHelpers.reduce((carry, name) => {\n if (context.hasOwnProperty(name)) {\n carry[name] = context[name];\n }\n\n return carry;\n }, {});\n\n Renderer.disallowedNestedHelpers.forEach((helperName) => {\n context[helperName] = () => '';\n });\n\n // Execute the helper with the modified context that doesn't include\n // the disallowed nested helpers. This prevents the disallowed\n // helpers from being called from within other helpers.\n const result = helperFunction.apply(this, [context, sectionText, helper]);\n\n // Restore the original helper implementation in the context so that\n // any further rendering has access to them again.\n for (const name in originalHelpers) {\n context[name] = originalHelpers[name];\n }\n\n return result;\n }.bind(this);\n }.bind(this);\n }\n\n /**\n * Add some common helper functions to all context objects passed to templates.\n * These helpers match exactly the helpers available in php.\n *\n * @method addHelpers\n * @private\n * @param {Object} context Simple types used as the context for the template.\n * @param {String} themeName We set this multiple times, because there are async calls.\n */\n addHelpers(context, themeName) {\n this.currentThemeName = themeName;\n this.requiredStrings = [];\n this.requiredJS = [];\n context.uniqid = (Renderer.uniqInstances++);\n\n // Please note that these helpers _must_ not return a Promise.\n context.str = this.addHelperFunction(this.stringHelper, context);\n context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context);\n context.pix = this.addHelperFunction(this.pixHelper, context);\n context.js = this.addHelperFunction(this.jsHelper, context);\n context.quote = this.addHelperFunction(this.quoteHelper, context);\n context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);\n context.userdate = this.addHelperFunction(this.userDateHelper, context);\n context.globals = {config: config};\n context.currentTheme = themeName;\n }\n\n /**\n * Get all the JS blocks from the last rendered template.\n *\n * @method getJS\n * @private\n * @returns {string}\n */\n getJS() {\n return this.requiredJS.join(\";\\n\");\n }\n\n /**\n * Treat strings in content.\n *\n * The purpose of this method is to replace the placeholders found in a string\n * with the their respective translated strings.\n *\n * Previously we were relying on String.replace() but the complexity increased with\n * the numbers of strings to replace. Now we manually walk the string and stop at each\n * placeholder we find, only then we replace it. Most of the time we will\n * replace all the placeholders in a single run, at times we will need a few\n * more runs when placeholders are replaced with strings that contain placeholders\n * themselves.\n *\n * @param {String} content The content in which string placeholders are to be found.\n * @param {Map} stringMap The strings to replace with.\n * @returns {String} The treated content.\n */\n treatStringsInContent(content, stringMap) {\n // Placeholders are in the for [[_sX]] or [[_cX]] where X is the string index.\n const stringPattern = /(?\\[\\[_(?[cs])(?\\d+)\\]\\])/g;\n\n // A helper to fetch the string for a given placeholder.\n const getUpdatedString = ({placeholder, stringType, stringIndex}) => {\n if (stringMap.has(placeholder)) {\n return stringMap.get(placeholder);\n }\n\n if (stringType === placeholderCleanedString) {\n // Attempt to find the unclean string and clean it. Store it for later use.\n const uncleanString = stringMap.get(`[[_s${stringIndex}]]`);\n if (uncleanString) {\n stringMap.set(placeholder, mustache.escape(uncleanString));\n return stringMap.get(placeholder);\n }\n }\n\n Log.debug(`Could not find string for pattern ${placeholder}`);\n return ''; // Fallback if no match is found.\n };\n\n let updatedContent = content; // Start with the original content.\n let placeholderFound = true; // Flag to track if we are still finding placeholders.\n\n // Continue looping until no more placeholders are found in the updated content.\n while (placeholderFound) {\n let match;\n let result = [];\n let lastIndex = 0;\n placeholderFound = false; // Assume no placeholders are found.\n\n // Find all placeholders in the content and replace them with their respective strings.\n while ((match = stringPattern.exec(updatedContent)) !== null) {\n placeholderFound = true; // A placeholder was found, so continue looping.\n\n // Add the content before the matched placeholder.\n result.push(updatedContent.slice(lastIndex, match.index));\n\n // Add the updated string for the placeholder.\n result.push(getUpdatedString(match.groups));\n\n // Update lastIndex to move past the current match.\n lastIndex = match.index + match[0].length;\n }\n\n // Add the remaining part of the content after the last match.\n result.push(updatedContent.slice(lastIndex));\n\n // Join the parts of the result array into the updated content.\n updatedContent = result.join('');\n }\n\n return updatedContent; // Return the fully updated content after all loops.\n }\n\n /**\n * Treat strings in content.\n *\n * The purpose of this method is to replace the date placeholders found in the\n * content with the their respective translated dates.\n *\n * @param {String} content The content in which string placeholders are to be found.\n * @param {Array} dates The dates to replace with.\n * @returns {String} The treated content.\n */\n treatDatesInContent(content, dates) {\n dates.forEach((date, index) => {\n content = content.replace(\n new RegExp(`\\\\[\\\\[_t_${index}\\\\]\\\\]`, 'g'),\n date,\n );\n });\n\n return content;\n }\n\n /**\n * Render a template and then call the callback with the result.\n *\n * @method doRender\n * @private\n * @param {string|Promise} templateSourcePromise The mustache template to render.\n * @param {Object} context Simple types used as the context for the template.\n * @param {String} themeName Name of the current theme.\n * @returns {Promise>} The rendered HTML and JS.\n */\n async doRender(templateSourcePromise, context, themeName) {\n this.currentThemeName = themeName;\n const iconTemplate = this.iconSystem.getTemplateName();\n\n const pendingPromise = new Pending('core/templates:doRender');\n const [templateSource] = await Promise.all([\n templateSourcePromise,\n Renderer.getLoader().getTemplate(iconTemplate, themeName),\n ]);\n\n // Clone context object to avoid manipulating the original context object.\n const templateContext = {...context};\n this.addHelpers(templateContext, themeName);\n\n // Render the template.\n const renderedContent = await mustache.render(\n templateSource,\n templateContext,\n // Note: The third parameter is a function that will be called to process partials.\n (partialName) => Renderer.getLoader().partialHelper(partialName, themeName),\n );\n\n const {html, js} = await this.processRenderedContent(renderedContent);\n\n pendingPromise.resolve();\n return {html, js};\n }\n\n /**\n * Process the rendered content, treating any strings and applying and helper strings, dates, etc.\n * @param {string} renderedContent\n * @returns {Promise>} The rendered HTML and JS.\n */\n async processRenderedContent(renderedContent) {\n let html = renderedContent.trim();\n let js = this.getJS();\n\n if (this.requiredStrings.length > 0) {\n // Fetch the strings into a new Map using the placeholder as an index.\n // Note: We only fetch the unclean version. Cleaning of strings happens lazily in treatStringsInContent.\n const stringMap = new Map(\n (await getStrings(this.requiredStrings)).map((string, index) => (\n [`[[_s${index}]]`, string]\n ))\n );\n\n // Make sure string substitutions are done for the userdate\n // values as well.\n this.requiredDates = this.requiredDates.map(function(date) {\n return {\n timestamp: this.treatStringsInContent(date.timestamp, stringMap),\n format: this.treatStringsInContent(date.format, stringMap)\n };\n }.bind(this));\n\n // Why do we not do another call the render here?\n //\n // Because that would expose DOS holes. E.g.\n // I create an assignment called \"{{fish\" which\n // would get inserted in the template in the first pass\n // and cause the template to die on the second pass (unbalanced).\n html = this.treatStringsInContent(html, stringMap);\n js = this.treatStringsInContent(js, stringMap);\n }\n\n // This has to happen after the strings replacement because you can\n // use the string helper in content for the user date helper.\n if (this.requiredDates.length > 0) {\n const dates = await UserDate.get(this.requiredDates);\n html = this.treatDatesInContent(html, dates);\n js = this.treatDatesInContent(js, dates);\n }\n\n return {html, js};\n }\n\n /**\n * Load a template and call doRender on it.\n *\n * @method render\n * @private\n * @param {string} templateName - should consist of the component and the name of the template like this:\n * core/menu (lib/templates/menu.mustache) or\n * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)\n * @param {Object} [context={}] - Could be array, string or simple value for the context of the template.\n * @param {string} [themeName] - Name of the current theme.\n * @returns {Promise} Native promise object resolved when the template has been rendered.}\n */\n async render(\n templateName,\n context = {},\n themeName = config.theme,\n ) {\n this.currentThemeName = themeName;\n\n // Preload the module to do the icon rendering based on the theme iconsystem.\n await this.setupIconSystem();\n\n const templateSource = Renderer.getLoader().cachePartials(templateName, themeName);\n return this.doRender(templateSource, context, themeName);\n }\n}\n"],"names":["originalMustacheEscape","mustache","escape","string","replace","Renderer","constructor","requiredStrings","requiredJS","requiredDates","currentThemeName","loader","this","key","component","title","setupIconSystem","template","getLoader","getTemplate","iconSystem","getTemplateName","renderIcon","IconSystem","instance","pixHelper","context","sectionText","helper","parts","split","text","length","shift","trim","join","Loader","templateName","searchKey","getSearchKey","getTemplateFromCache","jsHelper","push","stringHelper","param","match","parsedParam","JSON","parse","err","window","console","warn","message","index","cleanStringHelper","quoteHelper","content","stringify","shortenTextHelper","Truncate","truncate","words","ellipsis","userDateHelper","timestamp","format","addHelperFunction","helperFunction","originalHelpers","disallowedNestedHelpers","reduce","carry","name","hasOwnProperty","forEach","helperName","result","apply","bind","addHelpers","themeName","uniqid","uniqInstances","str","cleanstr","pix","js","quote","shortentext","userdate","globals","config","currentTheme","getJS","treatStringsInContent","stringMap","stringPattern","getUpdatedString","_ref","placeholder","stringType","stringIndex","has","get","uncleanString","set","Log","debug","updatedContent","placeholderFound","lastIndex","exec","slice","groups","treatDatesInContent","dates","date","RegExp","templateSourcePromise","iconTemplate","pendingPromise","Pending","templateSource","Promise","all","templateContext","renderedContent","render","partialName","partialHelper","html","processRenderedContent","resolve","Map","map","UserDate","theme","cachePartials","doRender"],"mappings":"03DAiCMA,uBAAyBC,kBAASC,yBAG/BA,OAAS,SAASC,eAEvBA,QADAA,OAASH,uBAAuBG,SAChBC,QAAQ,iCAAkC;;;;;;;;;;;MAczCC,SAuDjBC,qDArDkB,2CAGF,sCAGH,8CAGM,sCAYN,WAiCJC,gBAAkB,QAClBC,WAAa,QACbC,cAAgB,QAChBC,iBAAmB,oBAQXC,aACRA,OAASA,iCASPC,KAAKD,wBAaCE,IAAKC,UAAWC,OAE7BD,WAAY,iCAAuBA,iBAE7BF,KAAKI,wBACLC,eAAiBZ,SAASa,YAAYC,YACxCP,KAAKQ,WAAWC,kBAChBT,KAAKF,yBAGFE,KAAKQ,WAAWE,WACnBT,IACAC,UACAC,MACAE,yCAQCL,KAAKQ,kBACDA,iBAAmBG,qBAAWC,YAGhCZ,KAAKQ,WAahBK,UAAUC,QAASC,YAAaC,cACtBC,MAAQF,YAAYG,MAAM,SAC5BjB,IAAM,GACNC,UAAY,GACZiB,KAAO,GAEPF,MAAMG,OAAS,IACfnB,IAAMe,OAAOC,MAAMI,QAAQC,OAAQR,UAEnCG,MAAMG,OAAS,IACflB,UAAYc,OAAOC,MAAMI,QAAQC,OAAQR,UAEzCG,MAAMG,OAAS,IACfD,KAAOH,OAAOC,MAAMM,KAAK,KAAKD,OAAQR,gBAMpCU,OAAS/B,SAASa,YAClBmB,aAAezB,KAAKQ,WAAWC,kBAC/BiB,UAAYF,OAAOG,aAAa3B,KAAKF,iBAAkB2B,cACvDpB,SAAWmB,OAAOI,qBAAqBF,kBAE7CxB,WAAY,iCAAuBA,WAInCD,IAAMA,IAAIT,QAAQ,WAAY,KAEvBQ,KAAKQ,WAAWE,WACnBT,IACAC,UACAiB,KACAd,UAcRwB,SAASf,QAASC,YAAaC,oBACtBpB,WAAWkC,KAAKd,OAAOD,YAAaD,UAClC,GAcXiB,aAAajB,QAASC,YAAaC,YAI3BC,MAAQF,YAAYG,MAAM,WAExBjB,IAAMgB,MAAMG,OAAS,EAAIH,MAAMI,QAAQC,OAAS,GAChDpB,UAAYe,MAAMG,OAAS,GAAI,iCAAuBH,MAAMI,QAAQC,QAAU,OAChFU,MAAQf,MAAMG,OAAS,EAAIH,MAAMM,KAAK,KAAKD,OAAS,MAE1C,KAAVU,QAEAA,MAAQhB,OAAOgB,MAAOlB,UAGtBkB,MAAMC,MAAM,sBAGFC,YAAcC,KAAKC,MAAMJ,OAE3BE,aAAsC,iBAAhBA,cACtBF,MAAQE,aAEd,MAAOG,KAGLC,OAAOC,QAAQC,KAAKH,IAAII,eAI1BC,MAAQ1C,KAAKL,gBAAgByB,mBAC9BzB,gBAAgBmC,KAAK,CACtB7B,IAAAA,IACAC,UAAAA,UACA8B,MAAAA,sBAIUU,YAclBC,kBAAkB7B,QAASC,YAAaC,eAG7BhB,KACF+B,aAAajB,QAASC,YAAaC,QACnCxB,QAnRa,IAGO,KA6R7BoD,YAAY9B,QAASC,YAAaC,YAC1B6B,QAAU7B,OAAOD,YAAYO,OAAQR,gBAIzC+B,QAAUV,KAAKW,UAAUD,SACzBA,QAAUA,QAAQrD,QAAQ,eAAgB,4BACnCqD,QAaXE,kBAAkBjC,QAASC,YAAaC,cAG9BC,MAAQF,YAAYkB,MAAM,cAG1Bb,OAASH,MAAM,GAAGK,OAGlBuB,QAAU7B,OADHC,MAAM,GAAGK,OACOR,gBACtBkC,SAASC,SAASJ,QAAS,CAC9BzB,OAAAA,OACA8B,OAAO,EACPC,SAAU,QAclBC,eAAetC,QAASC,YAAaC,cAE3BC,MAAQF,YAAYkB,MAAM,cAE1BoB,UAAYrC,OAAOC,MAAM,GAAGK,OAAQR,SACpCwC,OAAStC,OAAOC,MAAM,GAAGK,OAAQR,SACjC4B,MAAQ1C,KAAKH,cAAcuB,mBAE5BvB,cAAciC,KAAK,CACpBuB,UAAWA,UACXC,OAAQA,wBAGGZ,YAmBnBa,kBAAkBC,eAAgB1C,gBACvB,kBACI,SAASC,YAAaC,cAMnByC,gBAAkBhE,SAASiE,wBAAwBC,QAAO,CAACC,MAAOC,QAChE/C,QAAQgD,eAAeD,QACvBD,MAAMC,MAAQ/C,QAAQ+C,OAGnBD,QACR,IAEHnE,SAASiE,wBAAwBK,SAASC,aACtClD,QAAQkD,YAAc,IAAM,YAM1BC,OAAST,eAAeU,MAAMlE,KAAM,CAACc,QAASC,YAAaC,aAI5D,MAAM6C,QAAQJ,gBACf3C,QAAQ+C,MAAQJ,gBAAgBI,aAG7BI,QACTE,KAAKnE,OACTmE,KAAKnE,MAYXoE,WAAWtD,QAASuD,gBACXvE,iBAAmBuE,eACnB1E,gBAAkB,QAClBC,WAAa,GAClBkB,QAAQwD,OAAU7E,SAAS8E,gBAG3BzD,QAAQ0D,IAAMxE,KAAKuD,kBAAkBvD,KAAK+B,aAAcjB,SACxDA,QAAQ2D,SAAWzE,KAAKuD,kBAAkBvD,KAAK2C,kBAAmB7B,SAClEA,QAAQ4D,IAAM1E,KAAKuD,kBAAkBvD,KAAKa,UAAWC,SACrDA,QAAQ6D,GAAK3E,KAAKuD,kBAAkBvD,KAAK6B,SAAUf,SACnDA,QAAQ8D,MAAQ5E,KAAKuD,kBAAkBvD,KAAK4C,YAAa9B,SACzDA,QAAQ+D,YAAc7E,KAAKuD,kBAAkBvD,KAAK+C,kBAAmBjC,SACrEA,QAAQgE,SAAW9E,KAAKuD,kBAAkBvD,KAAKoD,eAAgBtC,SAC/DA,QAAQiE,QAAU,CAACC,OAAQA,iBAC3BlE,QAAQmE,aAAeZ,UAU3Ba,eACWlF,KAAKJ,WAAW2B,KAAK,OAoBhC4D,sBAAsBtC,QAASuC,iBAErBC,cAAgB,mEAGhBC,iBAAmBC,WAACC,YAACA,YAADC,WAAcA,WAAdC,YAA0BA,qBAC5CN,UAAUO,IAAIH,oBACPJ,UAAUQ,IAAIJ,gBA9cJ,MAidjBC,WAAyC,OAEnCI,cAAgBT,UAAUQ,kBAAWF,sBACvCG,qBACAT,UAAUU,IAAIN,YAAanG,kBAASC,OAAOuG,gBACpCT,UAAUQ,IAAIJ,oBAI7BO,IAAIC,kDAA2CR,cACxC,QAGPS,eAAiBpD,QACjBqD,kBAAmB,OAGhBA,kBAAkB,KACjBjE,MACAgC,OAAS,GACTkC,UAAY,MAChBD,kBAAmB,EAGqC,QAAhDjE,MAAQoD,cAAce,KAAKH,kBAC/BC,kBAAmB,EAGnBjC,OAAOnC,KAAKmE,eAAeI,MAAMF,UAAWlE,MAAMS,QAGlDuB,OAAOnC,KAAKwD,iBAAiBrD,MAAMqE,SAGnCH,UAAYlE,MAAMS,MAAQT,MAAM,GAAGb,OAIvC6C,OAAOnC,KAAKmE,eAAeI,MAAMF,YAGjCF,eAAiBhC,OAAO1C,KAAK,WAG1B0E,eAaXM,oBAAoB1D,QAAS2D,cACzBA,MAAMzC,SAAQ,CAAC0C,KAAM/D,SACjBG,QAAUA,QAAQrD,QACd,IAAIkH,0BAAmBhE,gBAAe,KACtC+D,SAID5D,uBAaI8D,sBAAuB7F,QAASuD,gBACtCvE,iBAAmBuE,gBAClBuC,aAAe5G,KAAKQ,WAAWC,kBAE/BoG,eAAiB,IAAIC,iBAAQ,4BAC5BC,sBAAwBC,QAAQC,IAAI,CACvCN,sBACAlH,SAASa,YAAYC,YAAYqG,aAAcvC,aAI7C6C,gBAAkB,IAAIpG,cACvBsD,WAAW8C,gBAAiB7C,iBAG3B8C,sBAAwB9H,kBAAS+H,OACnCL,eACAG,iBAECG,aAAgB5H,SAASa,YAAYgH,cAAcD,YAAahD,cAG/DkD,KAACA,KAAD5C,GAAOA,UAAY3E,KAAKwH,uBAAuBL,wBAErDN,eAAeY,UACR,CAACF,KAAAA,KAAM5C,GAAAA,iCAQWwC,qBACrBI,KAAOJ,gBAAgB7F,OACvBqD,GAAK3E,KAAKkF,WAEVlF,KAAKL,gBAAgByB,OAAS,EAAG,OAG3BgE,UAAY,IAAIsC,WACX,mBAAW1H,KAAKL,kBAAkBgI,KAAI,CAACpI,OAAQmD,QAClD,eAAQA,YAAWnD,gBAMtBM,cAAgBG,KAAKH,cAAc8H,IAAI,SAASlB,YAC1C,CACHpD,UAAWrD,KAAKmF,sBAAsBsB,KAAKpD,UAAW+B,WACtD9B,OAAQtD,KAAKmF,sBAAsBsB,KAAKnD,OAAQ8B,aAEtDjB,KAAKnE,OAQPuH,KAAOvH,KAAKmF,sBAAsBoC,KAAMnC,WACxCT,GAAK3E,KAAKmF,sBAAsBR,GAAIS,cAKpCpF,KAAKH,cAAcuB,OAAS,EAAG,OACzBoF,YAAcoB,SAAShC,IAAI5F,KAAKH,eACtC0H,KAAOvH,KAAKuG,oBAAoBgB,KAAMf,OACtC7B,GAAK3E,KAAKuG,oBAAoB5B,GAAI6B,aAG/B,CAACe,KAAAA,KAAM5C,GAAAA,iBAgBdlD,kBACAX,+DAAU,GACVuD,iEAAYW,gBAAO6C,WAEd/H,iBAAmBuE,gBAGlBrE,KAAKI,wBAEL2G,eAAiBtH,SAASa,YAAYwH,cAAcrG,aAAc4C,kBACjErE,KAAK+H,SAAShB,eAAgBjG,QAASuD,6DA5mBjC5E,yBAcM,mBAdNA,8BAiBW,oBAjBXA,+BAoBW,mBApBXA,mCA0BgB,CAC7B,uBA3BaA,yBA+BM,oBA/BNA,4BAwCS,oBAxCTA,kBAgDD+B"} \ No newline at end of file +{"version":3,"file":"renderer.min.js","sources":["../../../src/local/templates/renderer.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport * as Log from 'core/log';\nimport * as Truncate from 'core/truncate';\nimport * as UserDate from 'core/user_date';\nimport Pending from 'core/pending';\nimport {getStrings} from 'core/str';\nimport IconSystem from 'core/icon_system';\nimport config from 'core/config';\nimport mustache from 'core/mustache';\nimport Loader from './loader';\nimport {getNormalisedComponent} from 'core/utils';\n\n/** @var {string} The placeholder character used for standard strings (unclean) */\nconst placeholderString = 's';\n\n/** @var {string} The placeholder character used for cleaned strings */\nconst placeholderCleanedString = 'c';\n\n/** @var {Function} originalMustacheEscape */\nconst originalMustacheEscape = mustache.escape;\n\n// Replicate escaping logic of PHP s() function.\nmustache.escape = function(string) {\n string = originalMustacheEscape(string);\n string = string.replace(/&#([0-9]+|x[0-9a-fA-F]+);/g, '&#$1;');\n return string;\n};\n\n/**\n * Template Renderer Class.\n *\n * Note: This class is not intended to be instantiated directly. Instead, use the core/templates module.\n *\n * @module core/local/templates/renderer\n * @copyright 2023 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 4.3\n */\nexport default class Renderer {\n /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */\n requiredStrings = null;\n\n /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */\n requiredDates = [];\n\n /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */\n requiredJS = null;\n\n /** @var {String} themeName for the current render */\n currentThemeName = '';\n\n /** @var {Number} uniqInstances Count of times this constructor has been called. */\n static uniqInstances = 0;\n\n /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */\n static loadTemplateBuffer = [];\n\n /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */\n static isLoadingTemplates = false;\n\n /** @var {Object} iconSystem - Object extending core/iconsystem */\n iconSystem = null;\n\n /** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */\n static disallowedNestedHelpers = [\n 'js',\n ];\n\n /** @var {String[]} templateCache - Cache of already loaded template strings */\n static templateCache = {};\n\n /**\n * Cache of already loaded template promises.\n *\n * @type {Promise[]}\n * @static\n * @private\n */\n static templatePromises = {};\n\n /**\n * The loader used to fetch templates.\n * @type {Loader}\n * @static\n * @private\n */\n static loader = Loader;\n\n /**\n * Constructor\n *\n * Each call to templates.render gets it's own instance of this class.\n */\n constructor() {\n this.requiredStrings = [];\n this.requiredJS = [];\n this.requiredDates = [];\n this.currentThemeName = '';\n }\n\n /**\n * Set the template loader to use for all Template renderers.\n *\n * @param {Loader} loader\n */\n static setLoader(loader) {\n this.loader = loader;\n }\n\n /**\n * Get the Loader used to fetch templates.\n *\n * @returns {Loader}\n */\n static getLoader() {\n return this.loader;\n }\n\n /**\n * Render a single image icon.\n *\n * @method renderIcon\n * @private\n * @param {string} key The icon key.\n * @param {string} component The component name.\n * @param {string} title The icon title\n * @returns {Promise}\n */\n async renderIcon(key, component, title) {\n // Preload the module to do the icon rendering based on the theme iconsystem.\n component = getNormalisedComponent(component);\n\n await this.setupIconSystem();\n const template = await Renderer.getLoader().getTemplate(\n this.iconSystem.getTemplateName(),\n this.currentThemeName,\n );\n\n return this.iconSystem.renderIcon(\n key,\n component,\n title,\n template\n );\n }\n\n /**\n * Helper to set up the icon system.\n */\n async setupIconSystem() {\n if (!this.iconSystem) {\n this.iconSystem = await IconSystem.instance();\n }\n\n return this.iconSystem;\n }\n\n /**\n * Render image icons.\n *\n * @method pixHelper\n * @private\n * @param {object} context The mustache context\n * @param {string} sectionText The text to parse arguments from.\n * @param {function} helper Used to render the alt attribute of the text.\n * @returns {string}\n */\n pixHelper(context, sectionText, helper) {\n const parts = sectionText.split(',');\n let key = '';\n let component = '';\n let text = '';\n\n if (parts.length > 0) {\n key = helper(parts.shift().trim(), context);\n }\n if (parts.length > 0) {\n component = helper(parts.shift().trim(), context);\n }\n if (parts.length > 0) {\n text = helper(parts.join(',').trim(), context);\n }\n\n // Note: We cannot use Promises in Mustache helpers.\n // We must fetch straight from the Loader cache.\n // The Loader cache is statically defined on the Loader class and should be used by all children.\n const Loader = Renderer.getLoader();\n const templateName = this.iconSystem.getTemplateName();\n const searchKey = Loader.getSearchKey(this.currentThemeName, templateName);\n const template = Loader.getTemplateFromCache(searchKey);\n\n component = getNormalisedComponent(component);\n\n // The key might have been escaped by the JS Mustache engine which\n // converts forward slashes to HTML entities. Let us undo that here.\n key = key.replace(///gi, '/');\n\n return this.iconSystem.renderIcon(\n key,\n component,\n text,\n template\n );\n }\n\n /**\n * Render blocks of javascript and save them in an array.\n *\n * @method jsHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to save as a js block.\n * @param {function} helper Used to render the block.\n * @returns {string}\n */\n jsHelper(context, sectionText, helper) {\n this.requiredJS.push(helper(sectionText, context));\n return '';\n }\n\n /**\n * Helper used to render {{#react}}{\"component\": \"mycomponent\", \"props\": {\"a\": \"b\"}}[optional placeholder content]{{/react}}.\n *\n * @param {object} context\n * @param {string} sectionText\n * @param {function} helper\n * @returns {string}\n */\n reactHelper(context, sectionText, helper) {\n // The sectionText should be a JSON string containing the component and props for the React component to render.\n // It has an optional placeholder content that can be used to provide content for the React component in case\n // the JS fails to load or execute. This is rendered as part of the helper and passed to the React component as a prop.\n const trimmedContent = helper(\n sectionText,\n context,\n );\n\n const createReactRoot = (content) => {\n // Attempt to parse the content as JSON.\n // Strip trailing commas (common mistake) to match the PHP helper behaviour.\n // If this fails, it will be caught in the parent.\n const data = JSON.parse(content.trim().replace(/,(\\s*[}\\]])/g, '$1'));\n\n const root = document.createElement('div');\n\n for (const [key, value] of Object.entries(data)) {\n // Skip null or empty values.\n if (value === null || value === '') {\n continue;\n } else if (key === 'component') {\n root.setAttribute('data-react-component', value);\n } else if (key === 'props') {\n root.setAttribute('data-react-props', JSON.stringify(value));\n } else if (typeof value === 'boolean') {\n if (value) {\n root.setAttribute(key, '');\n }\n } else if (typeof value === 'object') {\n root.setAttribute(key, JSON.stringify(value));\n } else {\n // By the time you reach the else branch, every other type has been handled\n // The only remaining types are number and string.\n root.setAttribute(key, String(value));\n }\n }\n\n return root;\n };\n\n // We are going to try and extract the JSON part of the content by looking for the first {\n // Then we'll look for the matching } by moving from the end of the string until we find a valid JSON.\n // This allows us to support content after the JSON which can be used as a placeholder for when JS fails to load or execute.\n const firstCurlyIndex = trimmedContent.indexOf('{');\n if (firstCurlyIndex === -1) {\n // No JSON found, render the whole content as a placeholder.\n return helper(sectionText, context);\n }\n\n let lastCurlyIndex = trimmedContent.length;\n\n do {\n try {\n const contentDiv = createReactRoot(trimmedContent.substring(firstCurlyIndex, lastCurlyIndex + 1));\n contentDiv.innerHTML = trimmedContent.substring(lastCurlyIndex + 1);\n\n return contentDiv.outerHTML;\n } catch (e) {\n // Still not valid JSON. Keep trying.\n }\n\n // Find the last curly brace before the current lastCurlyIndex.\n lastCurlyIndex = trimmedContent.lastIndexOf('}', lastCurlyIndex - 1);\n } while (lastCurlyIndex > firstCurlyIndex);\n\n // No JSON found, render the whole content as a placeholder.\n return trimmedContent;\n }\n\n /**\n * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}\n * into a get_string call.\n *\n * @method stringHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n stringHelper(context, sectionText, helper) {\n // A string instruction is in the format:\n // key, component, params.\n\n let parts = sectionText.split(',');\n\n const key = parts.length > 0 ? parts.shift().trim() : '';\n const component = parts.length > 0 ? getNormalisedComponent(parts.shift().trim()) : '';\n let param = parts.length > 0 ? parts.join(',').trim() : '';\n\n if (param !== '') {\n // Allow variable expansion in the param part only.\n param = helper(param, context);\n }\n\n if (param.match(/^{\\s*\"/gm)) {\n // If it can't be parsed then the string is not a JSON format.\n try {\n const parsedParam = JSON.parse(param);\n // Handle non-exception-throwing cases, e.g. null, integer, boolean.\n if (parsedParam && typeof parsedParam === \"object\") {\n param = parsedParam;\n }\n } catch (err) {\n // This was probably not JSON.\n // Keep the error message visible but do not promote it because it may not be an error.\n window.console.warn(err.message);\n }\n }\n\n const index = this.requiredStrings.length;\n this.requiredStrings.push({\n key,\n component,\n param,\n });\n\n // The placeholder must not use {{}} as those can be misinterpreted by the engine.\n return `[[_s${index}]]`;\n }\n\n /**\n * String helper to render {{#cleanstr}}abd component { a : 'fish'}{{/cleanstr}}\n * into a get_string following by an HTML escape.\n *\n * @method cleanStringHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n cleanStringHelper(context, sectionText, helper) {\n // We're going to use [[_cx]] format for clean strings, where x is a number.\n // Hence, replacing 's' with 'c' in the placeholder that stringHelper returns.\n return this\n .stringHelper(context, sectionText, helper)\n .replace(placeholderString, placeholderCleanedString);\n }\n\n /**\n * Quote helper used to wrap content in quotes, and escape all special JSON characters present in the content.\n *\n * @method quoteHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n quoteHelper(context, sectionText, helper) {\n let content = helper(sectionText.trim(), context);\n\n // Escape the {{ and JSON encode.\n // This involves wrapping {{, and }} in change delimeter tags.\n content = JSON.stringify(content);\n content = content.replace(/([{}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>');\n return content;\n }\n\n /**\n * Shorten text helper to truncate text and append a trailing ellipsis.\n *\n * @method shortenTextHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n shortenTextHelper(context, sectionText, helper) {\n // Non-greedy split on comma to grab section text into the length and\n // text parts.\n const parts = sectionText.match(/(.*?),(.*)/);\n\n // The length is the part matched in the first set of parethesis.\n const length = parts[1].trim();\n // The length is the part matched in the second set of parethesis.\n const text = parts[2].trim();\n const content = helper(text, context);\n return Truncate.truncate(content, {\n length,\n words: true,\n ellipsis: '...'\n });\n }\n\n /**\n * User date helper to render user dates from timestamps.\n *\n * @method userDateHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @returns {string}\n */\n userDateHelper(context, sectionText, helper) {\n // Non-greedy split on comma to grab the timestamp and format.\n const parts = sectionText.match(/(.*?),(.*)/);\n\n const timestamp = helper(parts[1].trim(), context);\n const format = helper(parts[2].trim(), context);\n const index = this.requiredDates.length;\n\n this.requiredDates.push({\n timestamp: timestamp,\n format: format\n });\n\n return `[[_t_${index}]]`;\n }\n\n /**\n * Return a helper function to be added to the context for rendering the a\n * template.\n *\n * This will parse the provided text before giving it to the helper function\n * in order to remove any disallowed nested helpers to prevent one helper\n * from calling another.\n *\n * In particular to prevent the JS helper from being called from within another\n * helper because it can lead to security issues when the JS portion is user\n * provided.\n *\n * @param {function} helperFunction The helper function to add\n * @param {object} context The template context for the helper function\n * @returns {Function} To be set in the context\n */\n addHelperFunction(helperFunction, context) {\n return function() {\n return function(sectionText, helper) {\n // Override the disallowed helpers in the template context with\n // a function that returns an empty string for use when executing\n // other helpers. This is to prevent these helpers from being\n // executed as part of the rendering of another helper in order to\n // prevent any potential security issues.\n const originalHelpers = Renderer.disallowedNestedHelpers.reduce((carry, name) => {\n if (context.hasOwnProperty(name)) {\n carry[name] = context[name];\n }\n\n return carry;\n }, {});\n\n Renderer.disallowedNestedHelpers.forEach((helperName) => {\n context[helperName] = () => '';\n });\n\n // Execute the helper with the modified context that doesn't include\n // the disallowed nested helpers. This prevents the disallowed\n // helpers from being called from within other helpers.\n const result = helperFunction.apply(this, [context, sectionText, helper]);\n\n // Restore the original helper implementation in the context so that\n // any further rendering has access to them again.\n for (const name in originalHelpers) {\n context[name] = originalHelpers[name];\n }\n\n return result;\n }.bind(this);\n }.bind(this);\n }\n\n /**\n * Add some common helper functions to all context objects passed to templates.\n * These helpers match exactly the helpers available in php.\n *\n * @method addHelpers\n * @private\n * @param {Object} context Simple types used as the context for the template.\n * @param {String} themeName We set this multiple times, because there are async calls.\n */\n addHelpers(context, themeName) {\n this.currentThemeName = themeName;\n this.requiredStrings = [];\n this.requiredJS = [];\n context.uniqid = (Renderer.uniqInstances++);\n\n // Please note that these helpers _must_ not return a Promise.\n context.str = this.addHelperFunction(this.stringHelper, context);\n context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context);\n context.pix = this.addHelperFunction(this.pixHelper, context);\n context.js = this.addHelperFunction(this.jsHelper, context);\n context.react = this.addHelperFunction(this.reactHelper, context);\n context.quote = this.addHelperFunction(this.quoteHelper, context);\n context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);\n context.userdate = this.addHelperFunction(this.userDateHelper, context);\n context.globals = {config: config};\n context.currentTheme = themeName;\n }\n\n /**\n * Get all the JS blocks from the last rendered template.\n *\n * @method getJS\n * @private\n * @returns {string}\n */\n getJS() {\n return this.requiredJS.join(\";\\n\");\n }\n\n /**\n * Treat strings in content.\n *\n * The purpose of this method is to replace the placeholders found in a string\n * with the their respective translated strings.\n *\n * Previously we were relying on String.replace() but the complexity increased with\n * the numbers of strings to replace. Now we manually walk the string and stop at each\n * placeholder we find, only then we replace it. Most of the time we will\n * replace all the placeholders in a single run, at times we will need a few\n * more runs when placeholders are replaced with strings that contain placeholders\n * themselves.\n *\n * @param {String} content The content in which string placeholders are to be found.\n * @param {Map} stringMap The strings to replace with.\n * @returns {String} The treated content.\n */\n treatStringsInContent(content, stringMap) {\n // Placeholders are in the for [[_sX]] or [[_cX]] where X is the string index.\n const stringPattern = /(?\\[\\[_(?[cs])(?\\d+)\\]\\])/g;\n\n // A helper to fetch the string for a given placeholder.\n const getUpdatedString = ({placeholder, stringType, stringIndex}) => {\n if (stringMap.has(placeholder)) {\n return stringMap.get(placeholder);\n }\n\n if (stringType === placeholderCleanedString) {\n // Attempt to find the unclean string and clean it. Store it for later use.\n const uncleanString = stringMap.get(`[[_s${stringIndex}]]`);\n if (uncleanString) {\n stringMap.set(placeholder, mustache.escape(uncleanString));\n return stringMap.get(placeholder);\n }\n }\n\n Log.debug(`Could not find string for pattern ${placeholder}`);\n return ''; // Fallback if no match is found.\n };\n\n let updatedContent = content; // Start with the original content.\n let placeholderFound = true; // Flag to track if we are still finding placeholders.\n\n // Continue looping until no more placeholders are found in the updated content.\n while (placeholderFound) {\n let match;\n let result = [];\n let lastIndex = 0;\n placeholderFound = false; // Assume no placeholders are found.\n\n // Find all placeholders in the content and replace them with their respective strings.\n while ((match = stringPattern.exec(updatedContent)) !== null) {\n placeholderFound = true; // A placeholder was found, so continue looping.\n\n // Add the content before the matched placeholder.\n result.push(updatedContent.slice(lastIndex, match.index));\n\n // Add the updated string for the placeholder.\n result.push(getUpdatedString(match.groups));\n\n // Update lastIndex to move past the current match.\n lastIndex = match.index + match[0].length;\n }\n\n // Add the remaining part of the content after the last match.\n result.push(updatedContent.slice(lastIndex));\n\n // Join the parts of the result array into the updated content.\n updatedContent = result.join('');\n }\n\n return updatedContent; // Return the fully updated content after all loops.\n }\n\n /**\n * Treat strings in content.\n *\n * The purpose of this method is to replace the date placeholders found in the\n * content with the their respective translated dates.\n *\n * @param {String} content The content in which string placeholders are to be found.\n * @param {Array} dates The dates to replace with.\n * @returns {String} The treated content.\n */\n treatDatesInContent(content, dates) {\n dates.forEach((date, index) => {\n content = content.replace(\n new RegExp(`\\\\[\\\\[_t_${index}\\\\]\\\\]`, 'g'),\n date,\n );\n });\n\n return content;\n }\n\n /**\n * Render a template and then call the callback with the result.\n *\n * @method doRender\n * @private\n * @param {string|Promise} templateSourcePromise The mustache template to render.\n * @param {Object} context Simple types used as the context for the template.\n * @param {String} themeName Name of the current theme.\n * @returns {Promise>} The rendered HTML and JS.\n */\n async doRender(templateSourcePromise, context, themeName) {\n this.currentThemeName = themeName;\n const iconTemplate = this.iconSystem.getTemplateName();\n\n const pendingPromise = new Pending('core/templates:doRender');\n const [templateSource] = await Promise.all([\n templateSourcePromise,\n Renderer.getLoader().getTemplate(iconTemplate, themeName),\n ]);\n\n // Clone context object to avoid manipulating the original context object.\n const templateContext = {...context};\n this.addHelpers(templateContext, themeName);\n\n // Render the template.\n const renderedContent = await mustache.render(\n templateSource,\n templateContext,\n // Note: The third parameter is a function that will be called to process partials.\n (partialName) => Renderer.getLoader().partialHelper(partialName, themeName),\n );\n\n const {html, js} = await this.processRenderedContent(renderedContent);\n\n pendingPromise.resolve();\n return {html, js};\n }\n\n /**\n * Process the rendered content, treating any strings and applying and helper strings, dates, etc.\n * @param {string} renderedContent\n * @returns {Promise>} The rendered HTML and JS.\n */\n async processRenderedContent(renderedContent) {\n let html = renderedContent.trim();\n let js = this.getJS();\n\n if (this.requiredStrings.length > 0) {\n // Fetch the strings into a new Map using the placeholder as an index.\n // Note: We only fetch the unclean version. Cleaning of strings happens lazily in treatStringsInContent.\n const stringMap = new Map(\n (await getStrings(this.requiredStrings)).map((string, index) => (\n [`[[_s${index}]]`, string]\n ))\n );\n\n // Make sure string substitutions are done for the userdate\n // values as well.\n this.requiredDates = this.requiredDates.map(function(date) {\n return {\n timestamp: this.treatStringsInContent(date.timestamp, stringMap),\n format: this.treatStringsInContent(date.format, stringMap)\n };\n }.bind(this));\n\n // Why do we not do another call the render here?\n //\n // Because that would expose DOS holes. E.g.\n // I create an assignment called \"{{fish\" which\n // would get inserted in the template in the first pass\n // and cause the template to die on the second pass (unbalanced).\n html = this.treatStringsInContent(html, stringMap);\n js = this.treatStringsInContent(js, stringMap);\n }\n\n // This has to happen after the strings replacement because you can\n // use the string helper in content for the user date helper.\n if (this.requiredDates.length > 0) {\n const dates = await UserDate.get(this.requiredDates);\n html = this.treatDatesInContent(html, dates);\n js = this.treatDatesInContent(js, dates);\n }\n\n return {html, js};\n }\n\n /**\n * Load a template and call doRender on it.\n *\n * @method render\n * @private\n * @param {string} templateName - should consist of the component and the name of the template like this:\n * core/menu (lib/templates/menu.mustache) or\n * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)\n * @param {Object} [context={}] - Could be array, string or simple value for the context of the template.\n * @param {string} [themeName] - Name of the current theme.\n * @returns {Promise} Native promise object resolved when the template has been rendered.}\n */\n async render(\n templateName,\n context = {},\n themeName = config.theme,\n ) {\n this.currentThemeName = themeName;\n\n // Preload the module to do the icon rendering based on the theme iconsystem.\n await this.setupIconSystem();\n\n const templateSource = Renderer.getLoader().cachePartials(templateName, themeName);\n return this.doRender(templateSource, context, themeName);\n }\n}\n"],"names":["originalMustacheEscape","mustache","escape","string","replace","Renderer","constructor","requiredStrings","requiredJS","requiredDates","currentThemeName","loader","this","key","component","title","setupIconSystem","template","getLoader","getTemplate","iconSystem","getTemplateName","renderIcon","IconSystem","instance","pixHelper","context","sectionText","helper","parts","split","text","length","shift","trim","join","Loader","templateName","searchKey","getSearchKey","getTemplateFromCache","jsHelper","push","reactHelper","trimmedContent","createReactRoot","content","data","JSON","parse","root","document","createElement","value","Object","entries","setAttribute","stringify","String","firstCurlyIndex","indexOf","lastCurlyIndex","contentDiv","substring","innerHTML","outerHTML","e","lastIndexOf","stringHelper","param","match","parsedParam","err","window","console","warn","message","index","cleanStringHelper","quoteHelper","shortenTextHelper","Truncate","truncate","words","ellipsis","userDateHelper","timestamp","format","addHelperFunction","helperFunction","originalHelpers","disallowedNestedHelpers","reduce","carry","name","hasOwnProperty","forEach","helperName","result","apply","bind","addHelpers","themeName","uniqid","uniqInstances","str","cleanstr","pix","js","react","quote","shortentext","userdate","globals","config","currentTheme","getJS","treatStringsInContent","stringMap","stringPattern","getUpdatedString","_ref","placeholder","stringType","stringIndex","has","get","uncleanString","set","Log","debug","updatedContent","placeholderFound","lastIndex","exec","slice","groups","treatDatesInContent","dates","date","RegExp","templateSourcePromise","iconTemplate","pendingPromise","Pending","templateSource","Promise","all","templateContext","renderedContent","render","partialName","partialHelper","html","processRenderedContent","resolve","Map","map","UserDate","theme","cachePartials","doRender"],"mappings":"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"} \ No newline at end of file diff --git a/public/lib/amd/src/local/templates/renderer.js b/public/lib/amd/src/local/templates/renderer.js index 633f2166893..852471d3d7b 100644 --- a/public/lib/amd/src/local/templates/renderer.js +++ b/public/lib/amd/src/local/templates/renderer.js @@ -232,6 +232,84 @@ export default class Renderer { return ''; } + /** + * Helper used to render {{#react}}{"component": "mycomponent", "props": {"a": "b"}}[optional placeholder content]{{/react}}. + * + * @param {object} context + * @param {string} sectionText + * @param {function} helper + * @returns {string} + */ + reactHelper(context, sectionText, helper) { + // The sectionText should be a JSON string containing the component and props for the React component to render. + // It has an optional placeholder content that can be used to provide content for the React component in case + // the JS fails to load or execute. This is rendered as part of the helper and passed to the React component as a prop. + const trimmedContent = helper( + sectionText, + context, + ); + + const createReactRoot = (content) => { + // Attempt to parse the content as JSON. + // Strip trailing commas (common mistake) to match the PHP helper behaviour. + // If this fails, it will be caught in the parent. + const data = JSON.parse(content.trim().replace(/,(\s*[}\]])/g, '$1')); + + const root = document.createElement('div'); + + for (const [key, value] of Object.entries(data)) { + // Skip null or empty values. + if (value === null || value === '') { + continue; + } else if (key === 'component') { + root.setAttribute('data-react-component', value); + } else if (key === 'props') { + root.setAttribute('data-react-props', JSON.stringify(value)); + } else if (typeof value === 'boolean') { + if (value) { + root.setAttribute(key, ''); + } + } else if (typeof value === 'object') { + root.setAttribute(key, JSON.stringify(value)); + } else { + // By the time you reach the else branch, every other type has been handled + // The only remaining types are number and string. + root.setAttribute(key, String(value)); + } + } + + return root; + }; + + // We are going to try and extract the JSON part of the content by looking for the first { + // Then we'll look for the matching } by moving from the end of the string until we find a valid JSON. + // This allows us to support content after the JSON which can be used as a placeholder for when JS fails to load or execute. + const firstCurlyIndex = trimmedContent.indexOf('{'); + if (firstCurlyIndex === -1) { + // No JSON found, render the whole content as a placeholder. + return helper(sectionText, context); + } + + let lastCurlyIndex = trimmedContent.length; + + do { + try { + const contentDiv = createReactRoot(trimmedContent.substring(firstCurlyIndex, lastCurlyIndex + 1)); + contentDiv.innerHTML = trimmedContent.substring(lastCurlyIndex + 1); + + return contentDiv.outerHTML; + } catch (e) { + // Still not valid JSON. Keep trying. + } + + // Find the last curly brace before the current lastCurlyIndex. + lastCurlyIndex = trimmedContent.lastIndexOf('}', lastCurlyIndex - 1); + } while (lastCurlyIndex > firstCurlyIndex); + + // No JSON found, render the whole content as a placeholder. + return trimmedContent; + } + /** * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}} * into a get_string call. @@ -448,6 +526,7 @@ export default class Renderer { context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context); context.pix = this.addHelperFunction(this.pixHelper, context); context.js = this.addHelperFunction(this.jsHelper, context); + context.react = this.addHelperFunction(this.reactHelper, context); context.quote = this.addHelperFunction(this.quoteHelper, context); context.shortentext = this.addHelperFunction(this.shortenTextHelper, context); context.userdate = this.addHelperFunction(this.userDateHelper, context); diff --git a/public/lib/classes/output/mustache_react_helper.php b/public/lib/classes/output/mustache_react_helper.php new file mode 100644 index 00000000000..fbb46aadd67 --- /dev/null +++ b/public/lib/classes/output/mustache_react_helper.php @@ -0,0 +1,204 @@ +. + +namespace core\output; + +use Mustache\LambdaHelper; + +/** + * Mustache helper for rendering React component mount points. + * + * Generates a `
` with data attributes that contain the component reference and props. + * The component value must be a fully-qualified ESM specifier in the form `@moodle/lms//`, + * for example `@moodle/lms/mod_book/viewer` or `@moodle/lms/mod_forum/discussion`. + * + * ``` + * {{#react}} + * { + * "component": "@moodle/lms/mod_book/viewer", + * "props": { + * "title": "{{#str}}confirm, core{{/str}}", + * "buttons": ["cancel", "confirm"] + * }, + * "id": "confirmation-modal", + * "class": "modal-wrapper", + * } + *

Loading...

+ * {{/react}} + * ``` + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mustache_react_helper { + /** + * Render React component mount point. + * + * @param string $text JSON config and optional inner content + * @param LambdaHelper $helper Mustache lambda helper + * @return string HTML output + */ + public function react(string $text, LambdaHelper $helper): string { + $text = trim($helper->render($text)); + + if (empty($text)) { + return ''; + } + + [$json, $content] = $this->split_json_content($text); + $config = $this->decode_json($json); + + // Fallback to plain div if JSON invalid but has content. + if ($config === null) { + return $content ? '
' . $content . '
' : ''; + } + + $attrs = $this->get_attributes($config); + return '' . $content . '
'; + } + + /** + * Split input into JSON block and remaining content. + * + * @param string $text Input text + * @return array [json_string, inner_content] + */ + private function split_json_content(string $text): array { + if ($text[0] !== '{') { + return ['', $text]; + } + + // The most common case is that the JSON config is the only content, so we can skip the more complex parsing. + if (json_validate($text)) { + return [$text, '']; + } + + // The next simplest case is that the JSON config is at the start, so we can just find the closing brace of the first JSON block. + $lastcurlindex = strrpos($text, '}'); + if ($lastcurlindex !== false) { + $potentialjson = substr($text, 0, $lastcurlindex + 1); + if (json_validate($potentialjson)) { + return [$potentialjson, trim(substr($text, $lastcurlindex + 1))]; + } + } + + $len = strlen($text); + $depth = 0; + $inquotes = false; + $escaped = false; + + for ($i = 0; $i < $len; $i++) { + $char = $text[$i]; + + if ($escaped) { + $escaped = false; + continue; + } + + if ($char === '\\') { + $escaped = true; + continue; + } + + if ($char === '"') { + $inquotes = !$inquotes; + continue; + } + + if ($inquotes) { + continue; + } + + if ($char === '{') { + $depth++; + } else if ($char === '}') { + $depth--; + if ($depth === 0) { + return [ + substr($text, 0, $i + 1), + trim(substr($text, $i + 1)), + ]; + } + } + } + + return [$text, '']; + } + + /** + * Decode JSON with automatic cleanup. + * + * @param string $json JSON string + * @return array|null Decoded array or null on failure + */ + private function decode_json(string $json): ?array { + if (json_validate($json) === false) { + // Attempt to clean common issues like trailing commas and re-validate. + $json = preg_replace('/,\s*([}\]])/', '$1', $json); + if (json_validate($json) === false) { + debugging('Invalid JSON in mustache react helper.' . "\n" . $json, DEBUG_DEVELOPER); + return null; + } + } + + $result = json_decode($json, true); + + return is_array($result) ? $result : null; + } + + /** + * Build an HTML attribute string from config. + * + * `data-react-props` is single-quoted so that JSON's double quotes do not + * need HTML-encoding and remain parseable by JSON.parse() without decoding. + * All other attribute values are escaped with s() to prevent XSS. + * + * @param array $config Configuration array + * @return string Attribute string with a leading space, ready to splice into a tag. + */ + private function get_attributes(array $config): string { + $out = ''; + + if (!empty($config['component'])) { + $out .= ' data-react-component="' . s($config['component']) . '"'; + } + + if (isset($config['props']) && is_array($config['props'])) { + $props = json_encode($config['props'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS); + $out .= ' data-react-props=\'' . $props . '\''; + } + + foreach ($config as $name => $val) { + if ($name === 'component' || $name === 'props' || $val === null || $val === '') { + continue; + } + + if (is_bool($val)) { + if ($val) { + $out .= ' ' . s($name); + } + } else if (is_array($val)) { + $encoded = json_encode($val, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS); + $out .= ' ' . s($name) . '="' . s($encoded) . '"'; + } else { + $out .= ' ' . s($name) . '="' . s($val) . '"'; + } + } + + return $out; + } +} diff --git a/public/lib/classes/output/renderer_base.php b/public/lib/classes/output/renderer_base.php index 617a15d9946..7b840780785 100644 --- a/public/lib/classes/output/renderer_base.php +++ b/public/lib/classes/output/renderer_base.php @@ -101,6 +101,7 @@ class renderer_base { $pixhelper = new mustache_pix_helper($this); $shortentexthelper = new mustache_shorten_text_helper(); $userdatehelper = new mustache_user_date_helper(); + $reacthelper = new mustache_react_helper(); // We only expose the variables that are exposed to JS templates. $safeconfig = $this->page->requires->get_config_for_javascript($this->page, $this); @@ -113,6 +114,7 @@ class renderer_base { 'pix' => [$pixhelper, 'pix'], 'shortentext' => [$shortentexthelper, 'shorten'], 'userdate' => [$userdatehelper, 'transform'], + 'react' => [$reacthelper, 'react'], ]; $this->mustache = new mustache_engine([ diff --git a/public/lib/tests/output/mustache_react_helper_test.php b/public/lib/tests/output/mustache_react_helper_test.php new file mode 100644 index 00000000000..ab685ffa110 --- /dev/null +++ b/public/lib/tests/output/mustache_react_helper_test.php @@ -0,0 +1,245 @@ +. + +declare(strict_types=1); + +namespace core\output; + +use Mustache\LambdaHelper; + +/** + * Unit tests for mustache_react_helper. + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversClass(mustache_react_helper::class)] +final class mustache_react_helper_test extends \advanced_testcase { + /** @var LambdaHelper|null Helper to handle lambda rendering. */ + private $lambdahelper = null; + /** @var mustache_react_helper|null Instance of the React mustache helper under test. */ + private $helper = null; + + /** + * Sets up the test environment before each test case is run. + */ + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->lambdahelper = new LambdaHelper(new \Mustache\Engine(), new \Mustache\Context()); + $this->helper = new mustache_react_helper(); + } + + /** + * Cleans up the test environment after each test case has run. + */ + public function tearDown(): void { + $this->lambdahelper = null; + $this->helper = null; + parent::tearDown(); + } + + /** + * Data provider for test_react_output. + * + * Each entry: [ input, strings_that_must_be_present, strings_that_must_be_absent ] + * + * @return array[] + */ + public static function react_output_provider(): array { + return [ + 'basic component with props' => [ + '{"component":"@moodle/lms/mod_book/viewer","props":{"label":"Save"}}', + ['data-react-component="@moodle/lms/mod_book/viewer"', 'data-react-props=\'{"label":"Save"}\''], + [], + ], + 'component without props' => [ + '{"component":"@moodle/lms/mod_book/viewer"}', + ['data-react-component="@moodle/lms/mod_book/viewer"'], + ['data-react-props'], + ], + 'props without component' => [ + '{"props":{"user":"John","role":"admin"}}', + ['data-react-props=\'{"user":"John","role":"admin"}\''], + ['data-react-component'], + ], + 'custom HTML attributes' => [ + '{"component":"@moodle/lms/mod_book/viewer","id":"test-modal","class":"large"}', + ['id="test-modal"', 'class="large"'], + [], + ], + 'boolean true attribute is rendered, false is omitted' => [ + '{"component":"@moodle/lms/mod_book/viewer","disabled":true,"hidden":false}', + [' disabled'], + ['hidden'], + ], + 'array values in attributes are JSON-encoded' => [ + '{"component":"@moodle/lms/mod_book/viewer","data-values":[10,20,30]}', + ['data-values="[10,20,30]"'], + [], + ], + 'array values containing strings break attribute quoting' => [ + '{"component":"@moodle/lms/mod_book/viewer","data-values":["a","b"]}', + ['data-values="["a","b"]"'], + [], + ], + 'inner content is preserved' => [ + '{"component":"@moodle/lms/mod_book/toc"}

Loading...

', + ['

Loading...

', 'data-react-component="@moodle/lms/mod_book/toc"'], + [], + ], + 'multiline JSON with content' => [ + '{ + "component": "@moodle/lms/mod_book/viewer", + "props": { + "title": "Confirm" + } +} +
', + ['data-react-component="@moodle/lms/mod_book/viewer"', '
'], + [], + ], + 'trailing comma after last top-level property is auto-fixed' => [ + '{"component":"@moodle/lms/mod_book/viewer","class":"primary",}', + ['data-react-component="@moodle/lms/mod_book/viewer"', 'class="primary"'], + [], + ], + 'trailing comma inside nested props object is auto-fixed' => [ + '{"component":"@moodle/lms/mod_book/viewer","props":{"label":"Save","type":"submit",}}', + ['data-react-component="@moodle/lms/mod_book/viewer"', '"label":"Save"', '"type":"submit"'], + [], + ], + 'trailing comma inside props array is auto-fixed' => [ + '{"component":"@moodle/lms/mod_book/viewer","props":{"tags":["php","moodle",]}}', + ['data-react-component="@moodle/lms/mod_book/viewer"', '"tags":["php","moodle"]'], + [], + ], + 'plain div without component or props' => [ + '{"id":"wrapper","class":"container"}

Title

', + ['id="wrapper"', 'class="container"', '

Title

'], + ['data-react-component', 'data-react-props'], + ], + 'escaped values with preserved inner content' => [ + '{"id":"wrapper","class":"container","props":{"user":{"name":"J\\\\D"}}}

Title\\Thing{}s

', + [ + 'id="wrapper"', + 'class="container"', + '

Title\\Thing{}s

', + '"user":{"name":"J\\\\D"}', + ], + [], + ], + 'extra closing brace' => [ + '{"id":"wrapper","class":"container","props":{"user":{"name":"J\\\\D"}}}}

Title\\Thing{}s

', + [ + 'id="wrapper"', + 'class="container"', + '

Title\\Thing{}s

', + '"user":{"name":"J\\\\D"}', + ], + [], + ], + 'XSS in attribute value is escaped' => [ + '{"component":"@moodle/lms/mod_book/viewer","class":""}', + ['<script>'], + ['"}', + ['<script>', '"'], + ['