diff --git a/Classes/Exception/InvalidConfigurationException.php b/Classes/Exception/InvalidConfigurationException.php
new file mode 100644
index 00000000..ab0fec61
--- /dev/null
+++ b/Classes/Exception/InvalidConfigurationException.php
@@ -0,0 +1,38 @@
+
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\Exception;
+
+/**
+ * InvalidConfigurationException
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ */
+final class InvalidConfigurationException extends \Exception
+{
+ public static function create(string $path): self
+ {
+ return new self(sprintf('The configuration for path "%s" is missing or invalid.', $path), 1631118231);
+ }
+}
diff --git a/Classes/Renderer/Helper/RenderHelper.php b/Classes/Renderer/Helper/RenderHelper.php
new file mode 100644
index 00000000..1ebfccd4
--- /dev/null
+++ b/Classes/Renderer/Helper/RenderHelper.php
@@ -0,0 +1,123 @@
+
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\Renderer\Helper;
+
+use Fr\Typo3Handlebars\DataProcessing;
+use Fr\Typo3Handlebars\Exception;
+use Fr\Typo3Handlebars\Renderer;
+use LightnCandy\SafeString;
+use TYPO3\CMS\Core;
+use TYPO3\CMS\Frontend;
+
+/**
+ * RenderHelper
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ * @see https://github.com/frctl/fractal/blob/main/packages/handlebars/src/helpers/render.js
+ */
+class RenderHelper implements HelperInterface
+{
+ public function __construct(
+ protected readonly Renderer\RendererInterface $renderer,
+ protected readonly Core\TypoScript\TypoScriptService $typoScriptService,
+ protected readonly Frontend\ContentObject\ContentObjectRenderer $contentObjectRenderer,
+ ) {}
+
+ /**
+ * @throws Exception\InvalidConfigurationException
+ */
+ public function evaluate(string $name): SafeString
+ {
+ // Get helper options
+ $arguments = \func_get_args();
+ array_shift($arguments);
+ $options = array_pop($arguments);
+
+ // Resolve data
+ $rootData = $options['data']['root'];
+ $merge = (bool)($options['hash']['merge'] ?? false);
+ $renderUncached = (bool)($options['hash']['uncached'] ?? false);
+
+ // Fetch custom context
+ // ====================
+ // Custom contexts can be defined as helper argument, e.g.
+ // {{render '@foo' customContext}}
+ $context = reset($arguments);
+ if (!\is_array($context)) {
+ $context = [];
+ }
+
+ // Fetch default context
+ // =====================
+ // Default contexts can be defined by using the template name when rendering a
+ // specific template, e.g. if $name = '@foo' then $rootData['@foo'] is requested
+ $defaultContext = $rootData[$name] ?? [];
+
+ // Resolve context
+ // ===============
+ // Use default context as new context if no custom context is given, otherwise
+ // merge both contexts in case merge=true is passed as helper option, e.g.
+ // {{render '@foo' customContext merge=true}}
+ if ($context === []) {
+ $context = $defaultContext;
+ } elseif ($merge) {
+ Core\Utility\ArrayUtility::mergeRecursiveWithOverrule($defaultContext, $context);
+ $context = $defaultContext;
+ }
+
+ if ($renderUncached) {
+ $content = $this->registerUncachedTemplateBlock($name, $context);
+ } else {
+ $content = $this->renderer->render($name, $context);
+ }
+
+ return new SafeString($content);
+ }
+
+ /**
+ * @param array $context
+ * @throws Exception\InvalidConfigurationException
+ */
+ protected function registerUncachedTemplateBlock(string $templateName, array $context): string
+ {
+ $processorClass = $context['_processor'] ?? null;
+
+ // Check whether the required data processor is valid
+ if (!\is_string($processorClass) || !\in_array(DataProcessing\DataProcessorInterface::class, class_implements($processorClass) ?: [])) {
+ throw Exception\InvalidConfigurationException::create('_processor');
+ }
+
+ // Do not pass data processor reference as context to requested data processor
+ unset($context['_processor']);
+
+ return $this->contentObjectRenderer->cObjGetSingle('USER_INT', [
+ 'userFunc' => $processorClass . '->process',
+ 'userFunc.' => [
+ 'templatePath' => $templateName,
+ 'context.' => $this->typoScriptService->convertPlainArrayToTypoScriptArray($context),
+ ],
+ ]);
+ }
+}
diff --git a/Tests/Functional/Fixtures/test_extension/Classes/DefaultContextAwareConfigurationTrait.php b/Tests/Functional/Fixtures/test_extension/Classes/DefaultContextAwareConfigurationTrait.php
new file mode 100644
index 00000000..1887a6c7
--- /dev/null
+++ b/Tests/Functional/Fixtures/test_extension/Classes/DefaultContextAwareConfigurationTrait.php
@@ -0,0 +1,49 @@
+
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\TestExtension;
+
+use TYPO3\CMS\Core;
+
+/**
+ * DefaultContextAwareConfigurationTrait
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ */
+trait DefaultContextAwareConfigurationTrait
+{
+ /**
+ * @return array
+ */
+ protected function getDefaultContextFromConfiguration(): array
+ {
+ if (!isset($this->configuration['userFunc.']['context.'])) {
+ return [];
+ }
+
+ return (new Core\TypoScript\TypoScriptService())->convertTypoScriptArrayToPlainArray(
+ $this->configuration['userFunc.']['context.'],
+ );
+ }
+}
diff --git a/Tests/Functional/Fixtures/test_extension/Classes/DummyNonCacheableProcessor.php b/Tests/Functional/Fixtures/test_extension/Classes/DummyNonCacheableProcessor.php
new file mode 100644
index 00000000..51145d23
--- /dev/null
+++ b/Tests/Functional/Fixtures/test_extension/Classes/DummyNonCacheableProcessor.php
@@ -0,0 +1,46 @@
+
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\TestExtension;
+
+use Fr\Typo3Handlebars\DataProcessing;
+
+/**
+ * DummyNonCacheableProcessor
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ */
+final class DummyNonCacheableProcessor extends DataProcessing\AbstractDataProcessor
+{
+ use DefaultContextAwareConfigurationTrait;
+ use TemplatePathAwareConfigurationTrait;
+
+ protected function render(): string
+ {
+ return json_encode([
+ 'templatePath' => $this->getTemplatePathFromConfiguration(),
+ 'context' => $this->getDefaultContextFromConfiguration(),
+ ], JSON_THROW_ON_ERROR);
+ }
+}
diff --git a/Tests/Functional/Fixtures/test_extension/Classes/TemplatePathAwareConfigurationTrait.php b/Tests/Functional/Fixtures/test_extension/Classes/TemplatePathAwareConfigurationTrait.php
new file mode 100644
index 00000000..70a8bbfa
--- /dev/null
+++ b/Tests/Functional/Fixtures/test_extension/Classes/TemplatePathAwareConfigurationTrait.php
@@ -0,0 +1,47 @@
+
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\TestExtension;
+
+use Fr\Typo3Handlebars\Exception;
+
+/**
+ * TemplatePathAwareConfigurationTrait
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ */
+trait TemplatePathAwareConfigurationTrait
+{
+ protected function getTemplatePathFromConfiguration(): string
+ {
+ if (!isset($this->configuration['userFunc.']['templatePath'])) {
+ throw new Exception\InvalidTemplateFileException(
+ 'Missing or invalid template path in configuration array.',
+ 1641990786
+ );
+ }
+
+ return (string)$this->configuration['userFunc.']['templatePath'];
+ }
+}
diff --git a/Tests/Functional/Renderer/Helper/RenderHelperTest.php b/Tests/Functional/Renderer/Helper/RenderHelperTest.php
new file mode 100644
index 00000000..67b140aa
--- /dev/null
+++ b/Tests/Functional/Renderer/Helper/RenderHelperTest.php
@@ -0,0 +1,158 @@
+
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+namespace Fr\Typo3Handlebars\Tests\Functional\Renderer\Helper;
+
+use Fr\Typo3Handlebars as Src;
+use Fr\Typo3Handlebars\TestExtension;
+use Fr\Typo3Handlebars\Tests;
+use PHPUnit\Framework;
+use Psr\Log;
+use Symfony\Component\EventDispatcher;
+use TYPO3\CMS\Core;
+use TYPO3\CMS\Frontend;
+use TYPO3\TestingFramework;
+
+/**
+ * RenderHelperTest
+ *
+ * @author Elias Häußler
+ * @license GPL-2.0-or-later
+ */
+#[Framework\Attributes\CoversClass(Src\Renderer\Helper\RenderHelper::class)]
+final class RenderHelperTest extends TestingFramework\Core\Functional\FunctionalTestCase
+{
+ use Tests\Unit\HandlebarsTemplateResolverTrait;
+
+ protected array $testExtensionsToLoad = [
+ 'handlebars',
+ 'test_extension',
+ ];
+
+ protected bool $initializeDatabase = false;
+
+ protected Src\Renderer\HandlebarsRenderer $renderer;
+ protected Frontend\ContentObject\ContentObjectRenderer $contentObjectRenderer;
+ protected Src\Renderer\Helper\RenderHelper $subject;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->templateResolver = new Src\Renderer\Template\FlatTemplateResolver($this->getTemplatePaths());
+ $this->renderer = new Src\Renderer\HandlebarsRenderer(
+ new Src\Cache\NullCache(),
+ new EventDispatcher\EventDispatcher(),
+ new Log\NullLogger(),
+ $this->templateResolver,
+ );
+ $this->contentObjectRenderer = new Frontend\ContentObject\ContentObjectRenderer();
+ $this->contentObjectRenderer->start([]);
+ $this->contentObjectRenderer->setRequest(new Core\Http\ServerRequest());
+ $this->subject = new Src\Renderer\Helper\RenderHelper(
+ $this->renderer,
+ new Core\TypoScript\TypoScriptService(),
+ $this->contentObjectRenderer,
+ );
+ $this->renderer->registerHelper('render', [$this->subject, 'evaluate']);
+ }
+
+ #[Framework\Attributes\Test]
+ public function helperCanBeCalledWithDefaultContext(): void
+ {
+ $actual = $this->renderer->render('@render-default-context', [
+ '@foo' => [
+ 'renderedContent' => 'Hello world!',
+ ],
+ ]);
+
+ self::assertSame('Hello world!', trim($actual));
+ }
+
+ #[Framework\Attributes\Test]
+ public function helperCanBeCalledWithCustomContext(): void
+ {
+ $actual = $this->renderer->render('@render-custom-context', [
+ 'renderData' => [
+ 'renderedContent' => 'Hello world!',
+ ],
+ ]);
+
+ self::assertSame('Hello world!', trim($actual));
+ }
+
+ #[Framework\Attributes\Test]
+ public function helperCanBeCalledWithMergedContext(): void
+ {
+ $actual = $this->renderer->render('@render-merged-context', [
+ '@foo' => [
+ 'renderedContent' => 'Hello world!',
+ ],
+ 'renderData' => [
+ 'renderedContent' => 'Lorem ipsum',
+ ],
+ ]);
+
+ self::assertSame('Lorem ipsum', trim($actual));
+ }
+
+ #[Framework\Attributes\Test]
+ public function helperCanBeCalledToRenderANonCacheableTemplate(): void
+ {
+ $GLOBALS['TSFE'] = new Frontend\Controller\TypoScriptFrontendController(
+ new Core\Context\Context(),
+ new Core\Site\Entity\Site('foo', 1, []),
+ new Core\Site\Entity\SiteLanguage(1, 'en', new Core\Http\Uri(), []),
+ new Core\Routing\PageArguments(1, 'foo', []),
+ new Frontend\Authentication\FrontendUserAuthentication(),
+ );
+ $GLOBALS['TSFE']->cObj = $this->contentObjectRenderer;
+
+ $actual = $GLOBALS['TSFE']->content = $this->renderer->render('@render-uncached', [
+ 'renderData' => [
+ '_processor' => TestExtension\DummyNonCacheableProcessor::class,
+ 'foo' => 'baz',
+ ],
+ ]);
+
+ self::assertMatchesRegularExpression('#^$#', trim($actual));
+
+ $GLOBALS['TSFE']->INTincScript(new Core\Http\ServerRequest());
+ $content = $GLOBALS['TSFE']->content;
+
+ $expected = [
+ 'templatePath' => '@foo',
+ 'context' => [
+ 'foo' => 'baz',
+ ],
+ ];
+
+ self::assertJson($content);
+ self::assertSame($expected, json_decode($content, true));
+ }
+
+ public function getTemplateRootPath(): string
+ {
+ return 'EXT:test_extension/Resources/Templates/';
+ }
+}