diff --git a/Documentation/Extending/ViewHelpers.rst b/Documentation/Extending/ViewHelpers.rst index e5f5fbeef..75d8ada37 100644 --- a/Documentation/Extending/ViewHelpers.rst +++ b/Documentation/Extending/ViewHelpers.rst @@ -89,6 +89,17 @@ ViewHelpers might be ones that implement `strip_tags`, `nl2br` or other string-manipulating PHP functions). And data ViewHelpers may return any type, but must be used a bit more carefully. +Tag-based ViewHelpers can now also return the :php:`TagBuilder` instance +directly instead of rendering it to a string immediately. This is a special +case compared to arbitrary string-compatible objects: :php:`TagBuilder` +implements :php:`UnsafeHTML`, so Fluid treats the returned value as HTML output +that must not be escaped again. + +This keeps the structured tag object reusable after the ViewHelper itself has +finished rendering. For example, an image ViewHelper can return its +:php:`TagBuilder`, and another layer can still add or remove an attribute afterwards +before the final output is converted to a string. + In other words: be careful what data types your ViewHelper returns. Non-string-compatible values may cause problems if you use the ViewHelper in ways that were not intended. Like in PHP, data types must either match or be diff --git a/src/Core/ViewHelper/AbstractTagBasedViewHelper.php b/src/Core/ViewHelper/AbstractTagBasedViewHelper.php index dccf6ee0f..fa489d3da 100644 --- a/src/Core/ViewHelper/AbstractTagBasedViewHelper.php +++ b/src/Core/ViewHelper/AbstractTagBasedViewHelper.php @@ -127,8 +127,8 @@ public function validateAdditionalArguments(array $arguments): void // Skip validation of additional arguments since we want to pass all arguments to the tag } - public function render(): string + public function render(): string|TagBuilder { - return $this->tag->render(); + return $this->tag; } } diff --git a/src/Core/ViewHelper/TagBuilder.php b/src/Core/ViewHelper/TagBuilder.php index 7fed3c26f..63722c4c1 100644 --- a/src/Core/ViewHelper/TagBuilder.php +++ b/src/Core/ViewHelper/TagBuilder.php @@ -9,12 +9,14 @@ namespace TYPO3Fluid\Fluid\Core\ViewHelper; +use TYPO3Fluid\Fluid\Core\Parser\UnsafeHTML; + /** * Tag builder. Can be easily accessed in AbstractTagBasedViewHelper * * @api */ -class TagBuilder +class TagBuilder implements UnsafeHTML { /** * Name of the Tag to be rendered @@ -298,4 +300,9 @@ public function render(): string } return $output; } + + public function __toString(): string + { + return $this->render(); + } } diff --git a/tests/Functional/Core/ViewHelper/TagBuilderChainingTest.php b/tests/Functional/Core/ViewHelper/TagBuilderChainingTest.php new file mode 100644 index 000000000..3a8e2ff39 --- /dev/null +++ b/tests/Functional/Core/ViewHelper/TagBuilderChainingTest.php @@ -0,0 +1,53 @@ + [ + 'content', + ['second' => 'two'], + '
content
', + ], + 'inline syntax' => [ + '{test:tagBasedTest(data: {first: \'one\'}) -> test:tagMutation(attributeValue: second)}', + ['second' => 'two'], + '
', + ], + ]; + } + + #[DataProvider('chainedTagBuilderCanBeMutatedDataProvider')] + #[Test] + public function chainedTagBuilderCanBeMutated(string $source, array $variables, string $expected): void + { + $view = new TemplateView(); + $view->assignMultiple($variables); + $view->getRenderingContext()->setCache(self::$cache); + $view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source); + $view->getRenderingContext()->getViewHelperResolver()->addNamespace('test', 'TYPO3Fluid\\Fluid\\Tests\\Functional\\Fixtures\\ViewHelpers'); + self::assertSame($expected, $view->render(), 'uncached'); + + $view = new TemplateView(); + $view->assignMultiple($variables); + $view->getRenderingContext()->setCache(self::$cache); + $view->getRenderingContext()->getTemplatePaths()->setTemplateSource($source); + $view->getRenderingContext()->getViewHelperResolver()->addNamespace('test', 'TYPO3Fluid\\Fluid\\Tests\\Functional\\Fixtures\\ViewHelpers'); + self::assertSame($expected, $view->render(), 'cached'); + } +} diff --git a/tests/Functional/Core/ViewHelper/ViewHelperEscapingTest.php b/tests/Functional/Core/ViewHelper/ViewHelperEscapingTest.php index d8705c644..ec39d084d 100644 --- a/tests/Functional/Core/ViewHelper/ViewHelperEscapingTest.php +++ b/tests/Functional/Core/ViewHelper/ViewHelperEscapingTest.php @@ -50,7 +50,7 @@ private function renderCode(ViewHelperInterface $mutableViewHelper, string $flui $view = new TemplateView($context); $view->getRenderingContext()->getTemplatePaths()->setTemplateSource($fluidCode); - return $view->render(); + return (string)$view->render(); } /* diff --git a/tests/Functional/Fixtures/ViewHelpers/TagBasedTestViewHelper.php b/tests/Functional/Fixtures/ViewHelpers/TagBasedTestViewHelper.php index d29602189..b9f923ecf 100644 --- a/tests/Functional/Fixtures/ViewHelpers/TagBasedTestViewHelper.php +++ b/tests/Functional/Fixtures/ViewHelpers/TagBasedTestViewHelper.php @@ -10,6 +10,7 @@ namespace TYPO3Fluid\Fluid\Tests\Functional\Fixtures\ViewHelpers; use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper; +use TYPO3Fluid\Fluid\Core\ViewHelper\TagBuilder; final class TagBasedTestViewHelper extends AbstractTagBasedViewHelper { @@ -20,10 +21,10 @@ public function initializeArguments(): void $this->registerArgument('registeredBooleanArgument', 'boolean', 'boolean argument', false, false); } - public function render(): string + public function render(): TagBuilder { $this->tag->addAttribute('registeredBooleanArgument', $this->arguments['registeredBooleanArgument']); $this->tag->setContent($this->renderChildren()); - return $this->tag->render(); + return $this->tag; } } diff --git a/tests/Functional/Fixtures/ViewHelpers/TagMutationViewHelper.php b/tests/Functional/Fixtures/ViewHelpers/TagMutationViewHelper.php new file mode 100644 index 000000000..ae6a2550e --- /dev/null +++ b/tests/Functional/Fixtures/ViewHelpers/TagMutationViewHelper.php @@ -0,0 +1,38 @@ +registerArgument('value', 'mixed', 'Tag to mutate'); + $this->registerArgument('attributeValue', 'string', 'Value of the added data-second attribute', true); + } + + public function getContentArgumentName(): string + { + return 'value'; + } + + public function render(): TagBuilder + { + $tag = $this->renderChildren(); + if (!$tag instanceof TagBuilder) { + throw new InvalidArgumentValueException('TagMutationViewHelper expects a TagBuilder as input.', 1745483101); + } + $tag->addAttribute('data-second', $this->arguments['attributeValue']); + return $tag; + } +} diff --git a/tests/Unit/Core/ViewHelper/AbstractTagBasedViewHelperTest.php b/tests/Unit/Core/ViewHelper/AbstractTagBasedViewHelperTest.php index e04dfa6e1..5f4ddbc47 100644 --- a/tests/Unit/Core/ViewHelper/AbstractTagBasedViewHelperTest.php +++ b/tests/Unit/Core/ViewHelper/AbstractTagBasedViewHelperTest.php @@ -20,9 +20,8 @@ final class AbstractTagBasedViewHelperTest extends TestCase public function renderCallsRenderOnTagBuilder(): void { $tagBuilder = $this->createMock(TagBuilder::class); - $tagBuilder->expects(self::once())->method('render')->willReturn('foobar'); $subject = new AbstractTagBasedViewHelperTestFixture(); $subject->setTagBuilder($tagBuilder); - self::assertEquals('foobar', $subject->render()); + self::assertEquals($tagBuilder, $subject->render()); } }