From ca96f7b127a4bf7bffca65206a5c0d9c9892d22f Mon Sep 17 00:00:00 2001 From: meirzamoodle Date: Fri, 6 Mar 2026 16:00:01 +0700 Subject: [PATCH] MDL-87765 core: Add Mustache React helper for mount placeholders Add a new {{#react}} Mustache lambda helper to render React mount containers from JSON config in templates. The helper outputs safe data attributes (including component and props), supports optional inner fallback content, and is registered in renderer_base for global template use. Also add PHPUnit coverage for valid/invalid JSON, attribute output, escaping, and fallback behaviour. Co-authored-by: Andrew Nicols --- .../amd/build/local/templates/renderer.min.js | 2 +- .../build/local/templates/renderer.min.js.map | 2 +- .../lib/amd/src/local/templates/renderer.js | 79 ++++++ .../classes/output/mustache_react_helper.php | 204 +++++++++++++++ public/lib/classes/output/renderer_base.php | 2 + .../output/mustache_react_helper_test.php | 245 ++++++++++++++++++ 6 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 public/lib/classes/output/mustache_react_helper.php create mode 100644 public/lib/tests/output/mustache_react_helper_test.php 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":"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"} \ 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>', '"'], + ['