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());
}
}