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 <[email protected]>
This commit is contained in:
meirzamoodle
2026-03-11 12:55:34 +08:00
committed by Adrian Greeve
co-authored by Andrew Nicols
parent bd404404a5
commit ca96f7b127
6 changed files with 532 additions and 2 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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);
@@ -0,0 +1,204 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core\output;
use Mustache\LambdaHelper;
/**
* Mustache helper for rendering React component mount points.
*
* Generates a `<div>` 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/<component>/<module>`,
* 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",
* }
* <p>Loading...</p>
* {{/react}}
* ```
*
* @package core
* @copyright Meirza <[email protected]>
* @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 ? '<div>' . $content . '</div>' : '';
}
$attrs = $this->get_attributes($config);
return '<div' . $attrs . '>' . $content . '</div>';
}
/**
* 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;
}
}
@@ -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([
@@ -0,0 +1,245 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core\output;
use Mustache\LambdaHelper;
/**
* Unit tests for mustache_react_helper.
*
* @package core
* @copyright Meirza <[email protected]>
* @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="[&quot;a&quot;,&quot;b&quot;]"'],
[],
],
'inner content is preserved' => [
'{"component":"@moodle/lms/mod_book/toc"}<p>Loading...</p>',
['<p>Loading...</p>', 'data-react-component="@moodle/lms/mod_book/toc"'],
[],
],
'multiline JSON with content' => [
'{
"component": "@moodle/lms/mod_book/viewer",
"props": {
"title": "Confirm"
}
}
<div class="skeleton"></div>',
['data-react-component="@moodle/lms/mod_book/viewer"', '<div class="skeleton"></div>'],
[],
],
'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"}<h1>Title</h1>',
['id="wrapper"', 'class="container"', '<h1>Title</h1>'],
['data-react-component', 'data-react-props'],
],
'escaped values with preserved inner content' => [
'{"id":"wrapper","class":"container","props":{"user":{"name":"J\\\\D"}}}<h1>Title\\Thing{}s</h1>',
[
'id="wrapper"',
'class="container"',
'<h1>Title\\Thing{}s</h1>',
'"user":{"name":"J\\\\D"}',
],
[],
],
'extra closing brace' => [
'{"id":"wrapper","class":"container","props":{"user":{"name":"J\\\\D"}}}}<h1>Title\\Thing{}s</h1>',
[
'id="wrapper"',
'class="container"',
'<h1>Title\\Thing{}s</h1>',
'"user":{"name":"J\\\\D"}',
],
[],
],
'XSS in attribute value is escaped' => [
'{"component":"@moodle/lms/mod_book/viewer","class":"<script>alert(1)</script>"}',
['&lt;script&gt;'],
['<script>'],
],
'XSS in component name is escaped' => [
'{"component":"@moodle/lms/mod_book/viewer\"><script>alert(1)</script>"}',
['&lt;script&gt;', '&quot;'],
['<script>'],
],
'single quote in prop value is encoded for single-quoted attribute' => [
'{"component":"@moodle/lms/mod_book/viewer","props":{"label":"it\'s fine"}}',
["\u0027s fine"],
["data-react-props='it's"],
],
'null and empty string attribute values are omitted' => [
'{"component":"@moodle/lms/mod_book/viewer","data-x":null,"data-y":""}',
['data-react-component="@moodle/lms/mod_book/viewer"'],
['data-x', 'data-y'],
],
'integer attribute value is cast to string' => [
'{"component":"@moodle/lms/mod_book/viewer","data-count":42}',
['data-count="42"'],
[],
],
'non-array props is silently ignored' => [
'{"component":"@moodle/lms/mod_book/viewer","props":"invalid"}',
['data-react-component="@moodle/lms/mod_book/viewer"'],
['data-react-props'],
],
];
}
/**
* Test that react() produces correct HTML for a variety of valid inputs.
*
* @param string $input JSON config and optional inner content.
* @param string[] $contains Substrings that must appear in the output.
* @param string[] $notcontains Substrings that must not appear in the output.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('react_output_provider')]
public function test_react_output(string $input, array $contains, array $notcontains): void {
$output = $this->helper->react($input, $this->lambdahelper);
foreach ($contains as $str) {
$this->assertStringContainsString($str, $output);
}
foreach ($notcontains as $str) {
$this->assertStringNotContainsString($str, $output);
}
}
/**
* Test that empty input returns an empty string.
*/
public function test_empty_input(): void {
$output = $this->helper->react('', $this->lambdahelper);
$this->assertSame('', $output);
}
/**
* Data provider for test_invalid_json.
*
* @return array[]
*/
public static function invalid_json_provider(): array {
return [
'invalid JSON with content falls back to plain div' => [
'{invalid json}<p>Content</p>',
'<div><p>Content</p></div>',
],
'invalid JSON without content returns empty string' => [
'{invalid json}',
'',
],
];
}
/**
* Test that invalid JSON triggers a debugging notice and returns the expected fallback.
*
* @param string $input Malformed JSON input.
* @param string $expected Expected output string.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('invalid_json_provider')]
public function test_invalid_json(string $input, string $expected): void {
$output = $this->helper->react($input, $this->lambdahelper);
$this->assertSame($expected, $output);
$this->assertDebuggingCalled();
}
}