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/'; + } +}