diff --git a/Classes/Renderer/Component/Layout/HandlebarsLayout.php b/Classes/Renderer/Component/Layout/HandlebarsLayout.php index 27d96e16..eb1fc205 100644 --- a/Classes/Renderer/Component/Layout/HandlebarsLayout.php +++ b/Classes/Renderer/Component/Layout/HandlebarsLayout.php @@ -33,19 +33,22 @@ class HandlebarsLayout { protected bool $parsed = false; + /** + * @var array> + */ + protected array $actions = []; + /** * @param callable $parseFunction - * @param array $actions */ public function __construct( protected $parseFunction, - protected array $actions = [], ) {} public function parse(): void { - ($this->parseFunction)(); $this->parsed = true; + ($this->parseFunction)(); } public function addAction(string $name, HandlebarsLayoutAction $action): void @@ -57,7 +60,7 @@ public function addAction(string $name, HandlebarsLayoutAction $action): void } /** - * @return array|HandlebarsLayoutAction[] + * @return ($name is null ? array : HandlebarsLayoutAction[]) */ public function getActions(string $name = null): array { @@ -77,10 +80,4 @@ public function isParsed(): bool { return $this->parsed; } - - public function setParsed(bool $parsed): self - { - $this->parsed = $parsed; - return $this; - } } diff --git a/Classes/Renderer/Component/Layout/HandlebarsLayoutAction.php b/Classes/Renderer/Component/Layout/HandlebarsLayoutAction.php index fdae5525..4ddba0bc 100644 --- a/Classes/Renderer/Component/Layout/HandlebarsLayoutAction.php +++ b/Classes/Renderer/Component/Layout/HandlebarsLayoutAction.php @@ -24,6 +24,7 @@ namespace Fr\Typo3Handlebars\Renderer\Component\Layout; use Fr\Typo3Handlebars\Exception; +use Fr\Typo3Handlebars\Renderer; /** * HandlebarsLayoutAction @@ -40,13 +41,10 @@ class HandlebarsLayoutAction protected readonly string $mode; /** - * @param array $data - * @param callable $renderFunction * @throws Exception\UnsupportedTypeException */ public function __construct( - protected readonly array $data, - protected $renderFunction, + protected readonly Renderer\Helper\Context\HelperContext $context, string $mode = self::REPLACE, ) { $this->mode = strtolower($mode); @@ -60,7 +58,7 @@ public function __construct( */ public function render(string $value): string { - $renderResult = ($this->renderFunction)($this->data); + $renderResult = $this->context->renderChildren($this->context->renderingContext); return match ($this->mode) { self::APPEND => $value . $renderResult, diff --git a/Classes/Renderer/Helper/BlockHelper.php b/Classes/Renderer/Helper/BlockHelper.php index b3a56798..5f11754f 100644 --- a/Classes/Renderer/Helper/BlockHelper.php +++ b/Classes/Renderer/Helper/BlockHelper.php @@ -34,18 +34,18 @@ * @license GPL-2.0-or-later * @see https://github.com/shannonmoeller/handlebars-layouts#block-name */ +#[Attribute\AsHelper('block')] final readonly class BlockHelper implements HelperInterface { /** - * @param array $options * @throws Exception\UnsupportedTypeException */ - #[Attribute\AsHelper('block')] - public function evaluate(string $name, array $options): string + public function render(Context\HelperContext $context): string { - $data = $options['_this']; - $actions = $data['_layoutActions'] ?? []; - $stack = $data['_layoutStack'] ?? []; + $name = $context[0]; + $renderingContext = $context->renderingContext; + $actions = $renderingContext['_layoutActions'] ?? []; + $stack = $renderingContext['_layoutStack'] ?? []; // Parse layouts and fetch all parsed layout actions for the requested block while (!empty($stack)) { @@ -58,12 +58,10 @@ public function evaluate(string $name, array $options): string } // Walk through layout actions and apply them to the rendered block - $fn = $options['fn'] ?? static fn() => ''; - return array_reduce( $actions, static fn(string $value, Renderer\Component\Layout\HandlebarsLayoutAction $action): string => $action->render($value), - $fn($data), + $context->renderChildren($renderingContext) ?? '', ); } } diff --git a/Classes/Renderer/Helper/ContentHelper.php b/Classes/Renderer/Helper/ContentHelper.php index 971bea46..210503fa 100644 --- a/Classes/Renderer/Helper/ContentHelper.php +++ b/Classes/Renderer/Helper/ContentHelper.php @@ -34,34 +34,34 @@ * @license GPL-2.0-or-later * @see https://github.com/shannonmoeller/handlebars-layouts#content-name-modeappendprependreplace */ +#[Attribute\AsHelper('content')] final readonly class ContentHelper implements HelperInterface { public function __construct( private Log\LoggerInterface $logger, ) {} - /** - * @param array $options - * @return string|bool - */ - #[Attribute\AsHelper('content')] - public function evaluate(string $name, array $options) + public function render(Context\HelperContext $context): ?bool { - $data = $options['_this']; - $mode = $options['hash']['mode'] ?? Renderer\Component\Layout\HandlebarsLayoutAction::REPLACE; - $layoutStack = $this->getLayoutStack($options); + $name = $context[0]; + $mode = $context['mode'] ?? Renderer\Component\Layout\HandlebarsLayoutAction::REPLACE; + $layoutStack = $this->getLayoutStack($context); // Early return if "content" helper is requested outside of an "extend" helper block if (empty($layoutStack)) { - $this->logger->error('Handlebars layout helper "content" can only be used within an "extend" helper block!', ['name' => $name]); - return ''; + $this->logger->error( + 'Handlebars layout helper "content" can only be used within an "extend" helper block!', + ['name' => $name], + ); + + return $context->isBlockHelper() ? null : false; } // Get upper layout from stack $layout = end($layoutStack); // Usage in conditional context: Test whether given required block is registered - if (!\is_callable($options['fn'] ?? '')) { + if (!$context->isBlockHelper()) { if (!$layout->isParsed()) { $layout->parse(); } @@ -70,34 +70,32 @@ public function evaluate(string $name, array $options) } // Add concrete action for the requested block - $action = new Renderer\Component\Layout\HandlebarsLayoutAction($data, $options['fn'], $mode); + $action = new Renderer\Component\Layout\HandlebarsLayoutAction($context, $mode); $layout->addAction($name, $action); // This helper does not return any content, it's just here to register layout actions - return ''; + return null; } /** - * @param array $options * @return Renderer\Component\Layout\HandlebarsLayout[] */ - protected function getLayoutStack(array $options): array + protected function getLayoutStack(Context\HelperContext $context): array { - // Fetch layout stack from current context - if (isset($options['_this']['_layoutStack'])) { - return $options['_this']['_layoutStack']; - } + $renderingContext = $context->renderingContext; + $contextStack = $context->contextStack; - // Early return if only context is currently processed - if (!isset($options['contexts'])) { - return []; + // Fetch layout stack from current context + if (isset($renderingContext['_layoutStack'])) { + return $renderingContext['_layoutStack']; } // Fetch layout stack from previous contexts - while (!empty($options['contexts'])) { - $context = array_pop($options['contexts']); - if (isset($context['_layoutStack'])) { - return $context['_layoutStack']; + while (!$contextStack->isEmpty()) { + $currentContext = $contextStack->pop(); + + if (isset($currentContext['_layoutStack'])) { + return $currentContext['_layoutStack']; } } diff --git a/Classes/Renderer/Helper/Context/HelperContext.php b/Classes/Renderer/Helper/Context/HelperContext.php new file mode 100644 index 00000000..b89b2f14 --- /dev/null +++ b/Classes/Renderer/Helper/Context/HelperContext.php @@ -0,0 +1,132 @@ + + * + * 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\Context; + +/** + * HelperContext + * + * @author Elias Häußler + * @license GPL-2.0-or-later + * + * @implements \ArrayAccess + */ +final class HelperContext implements \ArrayAccess +{ + /** + * @param list $arguments + * @param array $hash + * @param array $renderingContext + * @param array<'root'|int, array> $data + * @param callable|null $childrenClosure + * @param callable|null $inverseClosure + */ + public function __construct( + public readonly array $arguments, // 1...n-1 + public readonly array $hash, // n['hash'] + public readonly RenderingContextStack $contextStack, // n['contexts'] + public array &$renderingContext, // n['_this'] + public array &$data, // n['data'] => 'root', ... + private $childrenClosure = null, // n['fn'] + private $inverseClosure = null, // n['inverse'] + ) {} + + /** + * @param list $options + */ + public static function fromRuntimeCall(array &$options): self + { + $context = array_pop($options); + + $arguments = $options; + $hash = $context['hash']; + $contextStack = RenderingContextStack::fromRuntimeCall($context['contexts']); + $renderingContext = &$context['_this']; + $data = &$context['data']; + $childrenClosure = $context['fn'] ?? null; + $inverseClosure = $context['inverse'] ?? null; + + return new self( + $arguments, + $hash, + $contextStack, + $renderingContext, + $data, + $childrenClosure, + $inverseClosure, + ); + } + + public function isBlockHelper(): bool + { + return $this->childrenClosure !== null; + } + + public function renderChildren(): mixed + { + if ($this->childrenClosure === null) { + return null; + } + + return ($this->childrenClosure)(...\func_get_args()); + } + + public function renderInverse(): mixed + { + if ($this->inverseClosure === null) { + return null; + } + + return ($this->inverseClosure)(...\func_get_args()); + } + + public function offsetExists(mixed $offset): bool + { + if (is_numeric($offset)) { + return isset($this->arguments[$offset]); + } + + return isset($this->hash[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + if (is_numeric($offset)) { + return $this->arguments[$offset] + ?? throw new \OutOfBoundsException('Argument "' . $offset . '" does not exist.', 1736235839); + } + + return $this->hash[$offset] + ?? throw new \OutOfBoundsException('Hash "' . $offset . '" does not exist.', 1736235851); + } + + public function offsetSet(mixed $offset, mixed $value): never + { + throw new \LogicException('Helper context is locked and cannot be modified.', 1734434746); + } + + public function offsetUnset(mixed $offset): never + { + throw new \LogicException('Helper context is locked and cannot be modified.', 1734434780); + } +} diff --git a/Classes/Renderer/Helper/Context/RenderingContextStack.php b/Classes/Renderer/Helper/Context/RenderingContextStack.php new file mode 100644 index 00000000..2a722d6b --- /dev/null +++ b/Classes/Renderer/Helper/Context/RenderingContextStack.php @@ -0,0 +1,115 @@ + + * + * 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\Context; + +/** + * RenderingContextStack + * + * @author Elias Häußler + * @license GPL-2.0-or-later + * + * @implements \IteratorAggregate> + */ +final class RenderingContextStack implements \IteratorAggregate +{ + /** + * @param list> $stack + */ + public function __construct( + private array &$stack, + ) { + $this->reset(); + } + + /** + * @param array{null}|list> $contexts + */ + public static function fromRuntimeCall(array &$contexts): self + { + if ($contexts === [null]) { + $stack = []; + } else { + $stack = &$contexts; + } + + return new self($stack); + } + + /** + * @return array|null + * + * @impure + */ + public function pop(): ?array + { + $current = \current($this->stack); + + // Go to previous context in stack + prev($this->stack); + + if ($current === false || !is_array($current)) { + return null; + } + + return $current; + } + + /** + * @return array|null + */ + public function first(): ?array + { + $firstKey = \array_key_first($this->stack); + + if ($firstKey !== null) { + return $this->stack[$firstKey]; + } + + return null; + } + + /** + * @impure + */ + public function reset(): void + { + end($this->stack); + } + + /** + * @impure + */ + public function isEmpty(): bool + { + return \current($this->stack) === false; + } + + /** + * @return \ArrayIterator> + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator(array_reverse($this->stack)); + } +} diff --git a/Classes/Renderer/Helper/ExtendHelper.php b/Classes/Renderer/Helper/ExtendHelper.php index 0b9de3e0..f02a1815 100644 --- a/Classes/Renderer/Helper/ExtendHelper.php +++ b/Classes/Renderer/Helper/ExtendHelper.php @@ -33,19 +33,18 @@ * @license GPL-2.0-or-later * @see https://github.com/shannonmoeller/handlebars-layouts#extend-partial-context-keyvalue- */ +#[Attribute\AsHelper('extend')] final readonly class ExtendHelper implements HelperInterface { public function __construct( private Renderer\RendererInterface $renderer, ) {} - #[Attribute\AsHelper('extend')] - public function evaluate(string $name): string + public function render(Context\HelperContext $context): string { - // Get helper options - $arguments = \func_get_args(); + $name = $context[0]; + $arguments = $context->arguments; array_shift($arguments); - $options = array_pop($arguments); // Custom context is optional $customContext = []; @@ -54,18 +53,16 @@ public function evaluate(string $name): string } // Create new handlebars layout item - $fn = \is_callable($options['fn'] ?? '') ? $options['fn'] : static fn(): string => ''; + $fn = static fn(): string => $context->renderChildren() ?? ''; $handlebarsLayout = new Renderer\Component\Layout\HandlebarsLayout($fn); // Add layout to layout stack - $data = &$options['_this']; - if (!isset($data['_layoutStack'])) { - $data['_layoutStack'] = []; - } - $data['_layoutStack'][] = $handlebarsLayout; + $renderingContext = &$context->renderingContext; + $renderingContext['_layoutStack'] ??= []; + $renderingContext['_layoutStack'][] = $handlebarsLayout; // Merge data with supplied data - $renderData = array_replace_recursive($options['_this'], $customContext, $options['hash']); + $renderData = array_replace_recursive($renderingContext, $customContext, $context->hash); // Render layout with merged data return $this->renderer->render($name, $renderData); diff --git a/Classes/Renderer/Helper/HelperInterface.php b/Classes/Renderer/Helper/HelperInterface.php index c49c1ea9..99e17f0c 100644 --- a/Classes/Renderer/Helper/HelperInterface.php +++ b/Classes/Renderer/Helper/HelperInterface.php @@ -29,4 +29,7 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ -interface HelperInterface {} +interface HelperInterface +{ + public function render(Context\HelperContext $context): mixed; +} diff --git a/Classes/Renderer/Helper/RenderHelper.php b/Classes/Renderer/Helper/RenderHelper.php index d4d5c164..88601e73 100644 --- a/Classes/Renderer/Helper/RenderHelper.php +++ b/Classes/Renderer/Helper/RenderHelper.php @@ -38,6 +38,7 @@ * @license GPL-2.0-or-later * @see https://github.com/frctl/fractal/blob/main/packages/handlebars/src/helpers/render.js */ +#[Attribute\AsHelper('render')] final readonly class RenderHelper implements HelperInterface { public function __construct( @@ -46,29 +47,24 @@ public function __construct( private Frontend\ContentObject\ContentObjectRenderer $contentObjectRenderer, ) {} - /** - * @throws Exception\InvalidConfigurationException - */ - #[Attribute\AsHelper('render')] - public function evaluate(string $name): SafeString + public function render(Context\HelperContext $context): SafeString { - // Get helper options - $arguments = \func_get_args(); + $name = $context[0]; + $arguments = $context->arguments; 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); + $rootData = $context->data['root']; + $merge = (bool)($context['merge'] ?? false); + $renderUncached = (bool)($context['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 = []; + $subContext = reset($arguments); + if (!\is_array($subContext)) { + $subContext = []; } // Fetch default context @@ -82,17 +78,17 @@ public function evaluate(string $name): SafeString // 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; + if ($subContext === []) { + $subContext = $defaultContext; } elseif ($merge) { - Core\Utility\ArrayUtility::mergeRecursiveWithOverrule($defaultContext, $context); - $context = $defaultContext; + Core\Utility\ArrayUtility::mergeRecursiveWithOverrule($defaultContext, $subContext); + $subContext = $defaultContext; } if ($renderUncached) { - $content = $this->registerUncachedTemplateBlock($name, $context); + $content = $this->registerUncachedTemplateBlock($name, $subContext); } else { - $content = $this->renderer->render($name, $context); + $content = $this->renderer->render($name, $subContext); } return new SafeString($content); @@ -107,7 +103,7 @@ protected function registerUncachedTemplateBlock(string $templateName, array $co $processorClass = $context['_processor'] ?? null; // Check whether the required data processor is valid - if (!\is_string($processorClass) || !\in_array(DataProcessing\DataProcessorInterface::class, class_implements($processorClass) ?: [])) { + if (!\is_string($processorClass) || !\is_a($processorClass, DataProcessing\DataProcessorInterface::class, true)) { throw Exception\InvalidConfigurationException::create('_processor'); } diff --git a/Classes/Renderer/Helper/VarDumpHelper.php b/Classes/Renderer/Helper/VarDumpHelper.php index abd60f41..172148a4 100644 --- a/Classes/Renderer/Helper/VarDumpHelper.php +++ b/Classes/Renderer/Helper/VarDumpHelper.php @@ -32,17 +32,14 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ +#[Attribute\AsHelper('varDump')] final readonly class VarDumpHelper implements HelperInterface { - /** - * @param array $context - */ - #[Attribute\AsHelper('varDump')] - public static function evaluate(array $context): string + public function render(Context\HelperContext $context): string { \ob_start(); - Core\Utility\DebugUtility::debug($context['_this']); + Core\Utility\DebugUtility::debug($context->renderingContext); return (string)\ob_get_clean(); } diff --git a/Classes/Traits/HandlebarsHelperTrait.php b/Classes/Traits/HandlebarsHelperTrait.php index a6ba1c92..9573e962 100644 --- a/Classes/Traits/HandlebarsHelperTrait.php +++ b/Classes/Traits/HandlebarsHelperTrait.php @@ -23,8 +23,9 @@ namespace Fr\Typo3Handlebars\Traits; -use Fr\Typo3Handlebars\Exception\InvalidHelperException; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use Fr\Typo3Handlebars\Exception; +use Fr\Typo3Handlebars\Renderer; +use TYPO3\CMS\Core; /** * HandlebarsHelperTrait @@ -35,24 +36,30 @@ trait HandlebarsHelperTrait { /** - * @var array + * @var array */ protected array $helpers = []; public function registerHelper(string $name, mixed $function): void { try { - $this->helpers[$name] = $this->resolveHelperFunction($function); - } catch (InvalidHelperException | \ReflectionException $exception) { + $this->helpers[$name] = $this->decorateHelperFunction( + $this->resolveHelperFunction($function), + ); + } catch (Exception\InvalidHelperException | \ReflectionException $exception) { $this->logger->critical( 'Error while registering Handlebars helper "' . $name . '".', - ['name' => $name, 'function' => $function, 'exception' => $exception] + [ + 'name' => $name, + 'function' => $function, + 'exception' => $exception, + ], ); } } /** - * @return array + * @return array */ public function getHelpers(): array { @@ -60,7 +67,7 @@ public function getHelpers(): array } /** - * @throws InvalidHelperException + * @throws Exception\InvalidHelperException * @throws \ReflectionException */ protected function resolveHelperFunction(mixed $function): callable @@ -68,10 +75,15 @@ protected function resolveHelperFunction(mixed $function): callable // Try to resolve the Helper function in this order: // // 1. callable + // ├─ a. as string + // └─ b. as closure or first class callable syntax // 2. invokable class // ├─ a. as string (class-name) // └─ b. as object - // 3. class method + // 3. class implementing Helper interface + // ├─ a. as string (class-name) + // └─ b. as object + // 4. class method // ├─ a. as string => class-name::method-name // ├─ b. as array => [class-name, method-name] // └─ c. as initialized array => [object, method-name] @@ -80,43 +92,60 @@ protected function resolveHelperFunction(mixed $function): callable $methodName = null; if (\is_string($function) && !str_contains($function, '::')) { - // 1. callable + // 1a. callable as string if (\is_callable($function)) { return $function; } // 2a. invokable class as string - if (class_exists($function) && \is_callable($callable = GeneralUtility::makeInstance($function))) { + if (class_exists($function) && \is_callable($callable = Core\Utility\GeneralUtility::makeInstance($function))) { return $callable; } + + // 3a. class implementing Helper interface as string + if (class_exists($function) && \is_a($function, Renderer\Helper\HelperInterface::class, true)) { + return Core\Utility\GeneralUtility::makeInstance($function)->render(...); + } } - // 2b. invokable class as object - if (\is_object($function) && \is_callable($function)) { + if (\is_callable($function)) { + // 1b. callable as closure or first class callable syntax return $function; } - // 3a. class method as string + if (\is_object($function)) { + // 2b. invokable class as object + if (\is_callable($function)) { + return $function; + } + + // 3b. class implementing Helper interface as object + if ($function instanceof Renderer\Helper\HelperInterface) { + return $function->render(...); + } + } + + // 4a. class method as string if (\is_string($function) && str_contains($function, '::')) { [$className, $methodName] = explode('::', $function, 2); } - // 3b. class method as array - // 3c. class method as initialized array + // 4b. class method as array + // 4c. class method as initialized array if (\is_array($function) && \count($function) === 2) { [$className, $methodName] = $function; } // Early return if either class name or method name cannot be resolved if ($className === null || $methodName === null) { - throw InvalidHelperException::forUnsupportedType($function); + throw Exception\InvalidHelperException::forUnsupportedType($function); } // Early return if method is not public $reflectionClass = new \ReflectionClass($className); $reflectionMethod = $reflectionClass->getMethod($methodName); if (!$reflectionMethod->isPublic()) { - throw InvalidHelperException::forFunction($className . '::' . $methodName); + throw Exception\InvalidHelperException::forFunction($className . '::' . $methodName); } // Check if method can be called statically @@ -128,7 +157,7 @@ protected function resolveHelperFunction(mixed $function): callable // Instantiate class if not done yet /** @var class-string $className */ if (\is_string($className)) { - $className = GeneralUtility::makeInstance($className); + $className = Core\Utility\GeneralUtility::makeInstance($className); } $callable = [$className, $methodName]; @@ -136,7 +165,20 @@ protected function resolveHelperFunction(mixed $function): callable return $callable; } - throw InvalidHelperException::forInvalidCallable($callable); + throw Exception\InvalidHelperException::forInvalidCallable($callable); + } + + /** + * @return callable(\Fr\Typo3Handlebars\Renderer\Helper\Context\HelperContext): mixed + */ + protected function decorateHelperFunction(callable $function): callable + { + return static function () use ($function) { + $arguments = \func_get_args(); + $context = Renderer\Helper\Context\HelperContext::fromRuntimeCall($arguments); + + return $function($context); + }; } /** @@ -150,14 +192,14 @@ protected function isValidHelper(mixed $helperFunction): bool 'The method "%s" is deprecated and will be removed with 0.9.0. ' . 'Use "%s::resolveHelperFunction()" instead and check for thrown exceptions.', __METHOD__, - __TRAIT__ + __TRAIT__, ), - E_USER_DEPRECATED + E_USER_DEPRECATED, ); try { return (bool)$this->resolveHelperFunction($helperFunction); - } catch (InvalidHelperException | \ReflectionException) { + } catch (Exception\InvalidHelperException | \ReflectionException) { return false; } } diff --git a/Configuration/Services.php b/Configuration/Services.php index f55ab794..0102cfec 100644 --- a/Configuration/Services.php +++ b/Configuration/Services.php @@ -25,6 +25,7 @@ use Fr\Typo3Handlebars\Attribute\AsHelper; use Fr\Typo3Handlebars\DependencyInjection\Extension\HandlebarsExtension; +use Fr\Typo3Handlebars\Renderer\Helper\HelperInterface; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -42,7 +43,15 @@ static function (ChildDefinition $definition, AsHelper $attribute, \Reflector $r AsHelper::TAG_NAME, [ 'identifier' => $attribute->identifier, - 'method' => $attribute->method ?? ($reflector instanceof \ReflectionMethod ? $reflector->getName() : '__invoke'), + 'method' => $attribute->method ?? ( + $reflector instanceof \ReflectionMethod + ? $reflector->getName() + : ( + $reflector instanceof \ReflectionClass && $reflector->implementsInterface(HelperInterface::class) + ? 'render' + : '__invoke' + ) + ), ], ); }, diff --git a/Tests/Functional/Fixtures/test_extension/Classes/JsonHelper.php b/Tests/Functional/Fixtures/test_extension/Classes/JsonHelper.php index 4aabe29f..11c44a96 100644 --- a/Tests/Functional/Fixtures/test_extension/Classes/JsonHelper.php +++ b/Tests/Functional/Fixtures/test_extension/Classes/JsonHelper.php @@ -33,14 +33,11 @@ * @author Elias Häußler * @license GPL-2.0-or-later */ +#[Attribute\AsHelper('jsonEncode')] final class JsonHelper implements Renderer\Helper\HelperInterface { - /** - * @param array $context - */ - #[Attribute\AsHelper('jsonEncode')] - public function encode(array $context): SafeString + public function render(Renderer\Helper\Context\HelperContext $context): SafeString { - return new SafeString(json_encode($context['_this'], JSON_THROW_ON_ERROR)); + return new SafeString(json_encode($context->renderingContext, JSON_THROW_ON_ERROR)); } } diff --git a/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout-extended-with-fifth-block.hbs b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout-extended-with-fifth-block.hbs new file mode 100644 index 00000000..67a3b692 --- /dev/null +++ b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout-extended-with-fifth-block.hbs @@ -0,0 +1,10 @@ +{{#extend templateName}} + {{#content "fourth" mode="append"}} + + {{#block "fifth"}} + this is the fifth block: + + fifth block + {{/block}} + {{/content}} +{{/extend}} diff --git a/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout-extended-with-fifth-content.hbs b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout-extended-with-fifth-content.hbs new file mode 100644 index 00000000..f4ea3be7 --- /dev/null +++ b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout-extended-with-fifth-content.hbs @@ -0,0 +1,5 @@ +{{#extend "@main-layout-extended-with-fifth-block"}} + {{#content "fifth" mode="append"}} + injected + {{/content}} +{{/extend}} diff --git a/Tests/Functional/Renderer/Helper/BlockHelperTest.php b/Tests/Functional/Renderer/Helper/BlockHelperTest.php new file mode 100644 index 00000000..8c971c6a --- /dev/null +++ b/Tests/Functional/Renderer/Helper/BlockHelperTest.php @@ -0,0 +1,131 @@ + + * + * 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\Tests; +use PHPUnit\Framework; +use Psr\Log; +use Symfony\Component\EventDispatcher; +use TYPO3\TestingFramework; + +/** + * BlockHelperTest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +#[Framework\Attributes\CoversClass(Src\Renderer\Helper\BlockHelper::class)] +final class BlockHelperTest extends TestingFramework\Core\Functional\FunctionalTestCase +{ + use Tests\HandlebarsTemplateResolverTrait; + + protected bool $initializeDatabase = false; + + protected array $testExtensionsToLoad = [ + 'test_extension', + ]; + + private Src\Renderer\HandlebarsRenderer $renderer; + private Log\Test\TestLogger $logger; + + protected function setUp(): void + { + parent::setUp(); + + $this->templateRootPath = 'EXT:test_extension/Resources/Templates/'; + $this->logger = new Log\Test\TestLogger(); + $this->templateResolver = new Src\Renderer\Template\FlatTemplateResolver($this->getTemplatePaths()); + $this->renderer = new Src\Renderer\HandlebarsRenderer( + new Src\Cache\NullCache(), + new EventDispatcher\EventDispatcher(), + $this->logger, + $this->templateResolver, + ); + $this->renderer->registerHelper('extend', new Src\Renderer\Helper\ExtendHelper($this->renderer)); + $this->renderer->registerHelper('content', new Src\Renderer\Helper\ContentHelper($this->logger)); + $this->renderer->registerHelper('block', new Src\Renderer\Helper\BlockHelper()); + } + + #[Framework\Attributes\Test] + public function helperCanBeCalledFromMainLayout(): void + { + $actual = trim($this->renderer->render('@main-layout')); + $expected = implode(PHP_EOL, [ + 'this is the main block:', + '', + '[ ]+main block', + '', + 'this is the second block:', + '', + '[ ]+second block', + '', + 'this is the third block:', + '', + '[ ]+third block', + '', + 'this is the fourth block:', + '', + '[ ]+fourth block', + '', + 'this is the end. bye bye', + ]); + + self::assertMatchesRegularExpression('/^' . $expected . '$/', $actual); + } + + #[Framework\Attributes\Test] + public function helperCanBeCalledFromExtendedLayout(): void + { + $actual = trim($this->renderer->render('@main-layout-extended-with-fifth-content', [ + 'templateName' => '@main-layout', + ])); + $expected = implode(PHP_EOL, [ + 'this is the main block:', + '', + '[ ]+main block', + '', + 'this is the second block:', + '', + '[ ]+second block', + '', + 'this is the third block:', + '', + '[ ]+third block', + '', + 'this is the fourth block:', + '', + '[ ]+fourth block', + '', + '[ ]+this is the fifth block:', + '', + '[ ]+fifth block', + '[ ]+injected', + '', + 'this is the end. bye bye', + ]); + + self::assertMatchesRegularExpression('/^' . $expected . '$/', $actual); + } +} diff --git a/Tests/Functional/Renderer/Helper/ContentHelperTest.php b/Tests/Functional/Renderer/Helper/ContentHelperTest.php index 6fb3d373..ce01aacf 100644 --- a/Tests/Functional/Renderer/Helper/ContentHelperTest.php +++ b/Tests/Functional/Renderer/Helper/ContentHelperTest.php @@ -63,9 +63,9 @@ protected function setUp(): void $this->logger, $this->templateResolver, ); - $this->renderer->registerHelper('extend', [new Src\Renderer\Helper\ExtendHelper($this->renderer), 'evaluate']); - $this->renderer->registerHelper('content', [new Src\Renderer\Helper\ContentHelper($this->logger), 'evaluate']); - $this->renderer->registerHelper('block', [new Src\Renderer\Helper\BlockHelper(), 'evaluate']); + $this->renderer->registerHelper('extend', new Src\Renderer\Helper\ExtendHelper($this->renderer)); + $this->renderer->registerHelper('content', new Src\Renderer\Helper\ContentHelper($this->logger)); + $this->renderer->registerHelper('block', new Src\Renderer\Helper\BlockHelper()); } #[Framework\Attributes\Test] diff --git a/Tests/Functional/Renderer/Helper/ExtendHelperTest.php b/Tests/Functional/Renderer/Helper/ExtendHelperTest.php index da07fea2..b5857b9e 100644 --- a/Tests/Functional/Renderer/Helper/ExtendHelperTest.php +++ b/Tests/Functional/Renderer/Helper/ExtendHelperTest.php @@ -62,8 +62,8 @@ protected function setUp(): void new Log\NullLogger(), $this->templateResolver, ); - $this->renderer->registerHelper('extend', [new Src\Renderer\Helper\ExtendHelper($this->renderer), 'evaluate']); - $this->renderer->registerHelper('jsonEncode', [new TestExtension\JsonHelper(), 'encode']); + $this->renderer->registerHelper('extend', new Src\Renderer\Helper\ExtendHelper($this->renderer)); + $this->renderer->registerHelper('jsonEncode', new TestExtension\JsonHelper()); } #[Framework\Attributes\Test] diff --git a/Tests/Functional/Renderer/Helper/RenderHelperTest.php b/Tests/Functional/Renderer/Helper/RenderHelperTest.php index 5001a38e..6620713c 100644 --- a/Tests/Functional/Renderer/Helper/RenderHelperTest.php +++ b/Tests/Functional/Renderer/Helper/RenderHelperTest.php @@ -75,7 +75,7 @@ protected function setUp(): void $this->contentObjectRenderer, ); - $this->renderer->registerHelper('render', [$subject, 'evaluate']); + $this->renderer->registerHelper('render', $subject); } #[Framework\Attributes\Test] diff --git a/Tests/Unit/DataProcessing/SimpleProcessorTest.php b/Tests/Unit/DataProcessing/SimpleProcessorTest.php index 1c32285e..b06fc72f 100644 --- a/Tests/Unit/DataProcessing/SimpleProcessorTest.php +++ b/Tests/Unit/DataProcessing/SimpleProcessorTest.php @@ -88,7 +88,7 @@ public function processThrowsExceptionIfTemplatePathIsNotConfigured(array $confi #[Framework\Attributes\Test] public function processReturnsRenderedTemplate(): void { - $this->renderer->registerHelper('varDump', Src\Renderer\Helper\VarDumpHelper::class . '::evaluate'); + $this->renderer->registerHelper('varDump', Src\Renderer\Helper\VarDumpHelper::class); $this->contentObjectRendererMock->data = [ 'uid' => 1, diff --git a/Tests/Unit/Fixtures/Classes/Renderer/Helper/DummyHelper.php b/Tests/Unit/Fixtures/Classes/Renderer/Helper/DummyHelper.php index 7bf12d05..2ac29b35 100644 --- a/Tests/Unit/Fixtures/Classes/Renderer/Helper/DummyHelper.php +++ b/Tests/Unit/Fixtures/Classes/Renderer/Helper/DummyHelper.php @@ -34,6 +34,11 @@ */ final readonly class DummyHelper implements Renderer\Helper\HelperInterface { + public function render(Renderer\Helper\Context\HelperContext $context): string + { + return 'foo'; + } + public function __invoke(): string { return 'foo'; diff --git a/Tests/Unit/Renderer/Component/Layout/HandlebarsLayoutActionTest.php b/Tests/Unit/Renderer/Component/Layout/HandlebarsLayoutActionTest.php new file mode 100644 index 00000000..49c6c705 --- /dev/null +++ b/Tests/Unit/Renderer/Component/Layout/HandlebarsLayoutActionTest.php @@ -0,0 +1,80 @@ + + * + * 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\Unit\Renderer\Component\Layout; + +use Fr\Typo3Handlebars as Src; +use PHPUnit\Framework; +use TYPO3\TestingFramework; + +/** + * HandlebarsLayoutActionTest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +#[Framework\Attributes\CoversClass(Src\Renderer\Component\Layout\HandlebarsLayoutAction::class)] +final class HandlebarsLayoutActionTest extends TestingFramework\Core\Unit\UnitTestCase +{ + private Src\Renderer\Helper\Context\HelperContext $context; + + public function setUp(): void + { + parent::setUp(); + + $renderingContext = []; + $data = []; + $stack = []; + + $this->context = new Src\Renderer\Helper\Context\HelperContext( + [], + [], + new Src\Renderer\Helper\Context\RenderingContextStack($stack), + $renderingContext, + $data, + static fn() => 'baz' + ); + } + + /** + * @param Src\Renderer\Component\Layout\HandlebarsLayoutAction::* $mode + */ + #[Framework\Attributes\Test] + #[Framework\Attributes\DataProvider('renderReturnsProcessedValueDataProvider')] + public function renderReturnsProcessedValue(string $mode, string $expected): void + { + $subject = new Src\Renderer\Component\Layout\HandlebarsLayoutAction($this->context, $mode); + + self::assertSame($expected, $subject->render('foo')); + } + + /** + * @return \Generator + */ + public static function renderReturnsProcessedValueDataProvider(): \Generator + { + yield 'replace' => [Src\Renderer\Component\Layout\HandlebarsLayoutAction::REPLACE, 'baz']; + yield 'append' => [Src\Renderer\Component\Layout\HandlebarsLayoutAction::APPEND, 'foobaz']; + yield 'prepend' => [Src\Renderer\Component\Layout\HandlebarsLayoutAction::PREPEND, 'bazfoo']; + } +} diff --git a/Tests/Unit/Renderer/Component/Layout/HandlebarsLayoutTest.php b/Tests/Unit/Renderer/Component/Layout/HandlebarsLayoutTest.php new file mode 100644 index 00000000..bd807756 --- /dev/null +++ b/Tests/Unit/Renderer/Component/Layout/HandlebarsLayoutTest.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\Tests\Unit\Renderer\Component\Layout; + +use Fr\Typo3Handlebars as Src; +use PHPUnit\Framework; +use TYPO3\TestingFramework; + +/** + * HandlebarsLayoutTest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +#[Framework\Attributes\CoversClass(Src\Renderer\Component\Layout\HandlebarsLayout::class)] +final class HandlebarsLayoutTest extends TestingFramework\Core\Unit\UnitTestCase +{ + private Src\Renderer\Component\Layout\HandlebarsLayout $subject; + private Src\Renderer\Component\Layout\HandlebarsLayoutAction $action; + + private bool $parseFunctionInvoked = false; + + public function setUp(): void + { + parent::setUp(); + + $stack = []; + $renderingContext = []; + $data = []; + + $this->subject = new Src\Renderer\Component\Layout\HandlebarsLayout( + fn() => $this->parseFunctionInvoked = true, + ); + $this->action = new Src\Renderer\Component\Layout\HandlebarsLayoutAction( + new Src\Renderer\Helper\Context\HelperContext( + [], + [], + new Src\Renderer\Helper\Context\RenderingContextStack($stack), + $renderingContext, + $data, + ), + ); + } + + #[Framework\Attributes\Test] + public function parseInvokesParseFunctionAndMarksComponentAsParsed(): void + { + self::assertFalse($this->parseFunctionInvoked); + self::assertFalse($this->subject->isParsed()); + + $this->subject->parse(); + + self::assertTrue($this->parseFunctionInvoked); + self::assertTrue($this->subject->isParsed()); + } + + #[Framework\Attributes\Test] + public function addActionRegistersGivenAction(): void + { + self::assertSame([], $this->subject->getActions()); + + $this->subject->addAction('foo', $this->action); + + self::assertSame( + [ + 'foo' => [ + $this->action, + ], + ], + $this->subject->getActions(), + ); + } + + #[Framework\Attributes\Test] + public function getActionsReturnsAllRegisteredActions(): void + { + $this->subject->addAction('foo', $this->action); + $this->subject->addAction('baz', $this->action); + + self::assertSame(['foo', 'baz'], \array_keys($this->subject->getActions())); + } + + #[Framework\Attributes\Test] + public function getActionsReturnsRegisteredActionsByGivenName(): void + { + $this->subject->addAction('foo', $this->action); + $this->subject->addAction('baz', $this->action); + + self::assertSame([$this->action], $this->subject->getActions('foo')); + self::assertSame([], $this->subject->getActions('missing')); + } + + #[Framework\Attributes\Test] + public function hasActionReturnsTrueIfActionOfGivenNameWasRegistered(): void + { + self::assertFalse($this->subject->hasAction('foo')); + + $this->subject->addAction('foo', $this->action); + + self::assertTrue($this->subject->hasAction('foo')); + } +} diff --git a/Tests/Unit/Renderer/HandlebarsRendererTest.php b/Tests/Unit/Renderer/HandlebarsRendererTest.php index 7d3688f1..dbe0629d 100644 --- a/Tests/Unit/Renderer/HandlebarsRendererTest.php +++ b/Tests/Unit/Renderer/HandlebarsRendererTest.php @@ -106,7 +106,7 @@ public function renderReturnsEmptyStringIfGivenTemplateIsEmpty(): void #[Framework\Attributes\Test] public function renderMergesDefaultDataWithGivenData(): void { - $this->subject->registerHelper('varDump', Src\Renderer\Helper\VarDumpHelper::class . '::evaluate'); + $this->subject->registerHelper('varDump', Src\Renderer\Helper\VarDumpHelper::class); $this->subject->setDefaultData([ 'foo' => 'baz', ]); diff --git a/Tests/Unit/Renderer/Helper/Context/HelperContextTest.php b/Tests/Unit/Renderer/Helper/Context/HelperContextTest.php new file mode 100644 index 00000000..066fb2df --- /dev/null +++ b/Tests/Unit/Renderer/Helper/Context/HelperContextTest.php @@ -0,0 +1,302 @@ + + * + * 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\Unit\Renderer\Helper\Context; + +use Fr\Typo3Handlebars as Src; +use PHPUnit\Framework; +use TYPO3\TestingFramework; + +/** + * HelperContextTest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +#[Framework\Attributes\CoversClass(Src\Renderer\Helper\Context\HelperContext::class)] +final class HelperContextTest extends TestingFramework\Core\Unit\UnitTestCase +{ + private Src\Renderer\Helper\Context\HelperContext $subject; + + /** + * @var list> + */ + private array $stack = [ + ['foo' => 'baz'], + ['baz' => 'foo'], + ]; + + /** + * @var array + */ + private array $renderingContext = [ + 'foo' => 'baz', + ]; + + /** + * @var array<'root'|int, array> + */ + private array $data = [ + 'root' => [ + 'foo' => 'baz', + ], + [ + 'foo' => 'baz', + ], + ]; + + public function setUp(): void + { + parent::setUp(); + + $this->subject = new Src\Renderer\Helper\Context\HelperContext( + [ + 'foo', + 'baz', + ], + [ + 'foo' => 'baz', + ], + new Src\Renderer\Helper\Context\RenderingContextStack($this->stack), + $this->renderingContext, + $this->data, + static fn() => 'foo', + static fn() => 'baz', + ); + } + + #[Framework\Attributes\Test] + public function fromRuntimeCallReturnsConstructedContext(): void + { + $options = $this->getRuntimeOptions(); + + $actual = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + self::assertEquals($this->subject, $actual); + } + + #[Framework\Attributes\Test] + public function fromRuntimeCallPassesContextStackAsReference(): void + { + $options = $this->getRuntimeOptions(); + + $actual = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + $this->stack[] = ['boo' => 'faz']; + + self::assertSame( + [ + ['boo' => 'faz'], + ['baz' => 'foo'], + ['foo' => 'baz'], + ], + \iterator_to_array($actual->contextStack), + ); + } + + #[Framework\Attributes\Test] + public function fromRuntimeCallPassesRenderingContextAsReference(): void + { + $options = $this->getRuntimeOptions(); + + $actual = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + $this->renderingContext['baz'] = 'foo'; + + self::assertSame( + [ + 'foo' => 'baz', + 'baz' => 'foo', + ], + $actual->renderingContext, + ); + } + + #[Framework\Attributes\Test] + public function fromRuntimeCallPassesDataAsReference(): void + { + $options = $this->getRuntimeOptions(); + + $actual = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + $this->data[] = ['baz' => 'foo']; + + self::assertSame( + [ + 'root' => [ + 'foo' => 'baz', + ], + [ + 'foo' => 'baz', + ], + [ + 'baz' => 'foo', + ], + ], + $actual->data, + ); + } + + #[Framework\Attributes\Test] + public function isBlockHelperReturnsTrueIfChildrenClosureExists(): void + { + self::assertTrue($this->subject->isBlockHelper()); + + $options = $this->getRuntimeOptions(false); + $subject = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + self::assertFalse($subject->isBlockHelper()); + } + + #[Framework\Attributes\Test] + public function renderChildrenReturnsNullIfNoChildrenClosureExists(): void + { + $options = $this->getRuntimeOptions(false); + $subject = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + self::assertNull($subject->renderChildren()); + } + + #[Framework\Attributes\Test] + public function renderChildrenInvokesChildrenClosureWithGivenArguments(): void + { + $options = $this->getRuntimeOptions(); + $subject = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + self::assertSame('fn: foo', $subject->renderChildren('foo')); + } + + #[Framework\Attributes\Test] + public function renderInverseReturnsNullIfNoInverseClosureExists(): void + { + $options = $this->getRuntimeOptions(true, false); + $subject = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + self::assertNull($subject->renderInverse()); + } + + #[Framework\Attributes\Test] + public function renderInverseInvokesInverseClosureWithGivenArguments(): void + { + $options = $this->getRuntimeOptions(); + $subject = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($options); + + self::assertSame('inverse: foo', $subject->renderInverse('foo')); + } + + #[Framework\Attributes\Test] + public function objectCanBeAccessedAsReadOnlyArray(): void + { + // offsetExists + self::assertTrue(isset($this->subject[0])); + self::assertTrue(isset($this->subject[1])); + self::assertFalse(isset($this->subject[2])); + self::assertTrue(isset($this->subject['foo'])); + self::assertFalse(isset($this->subject['baz'])); + + // offsetGet + self::assertSame('foo', $this->subject[0]); + self::assertSame('baz', $this->subject[1]); + self::assertSame('baz', $this->subject['foo']); + } + + #[Framework\Attributes\Test] + public function offsetGetThrowsExceptionIfGivenArgumentDoesNotExist(): void + { + $this->expectExceptionObject( + new \OutOfBoundsException('Argument "99" does not exist.', 1736235839), + ); + + $x = $this->subject[99]; + } + + #[Framework\Attributes\Test] + public function offsetGetThrowsExceptionIfGivenHashDoesNotExist(): void + { + $this->expectExceptionObject( + new \OutOfBoundsException('Hash "missing" does not exist.', 1736235851), + ); + + $x = $this->subject['missing']; + } + + #[Framework\Attributes\Test] + public function offsetSetThrowsLogicException(): void + { + $this->expectExceptionObject( + new \LogicException('Helper context is locked and cannot be modified.', 1734434746), + ); + + $this->subject['baz'] = 'foo'; + } + + #[Framework\Attributes\Test] + public function offsetUnsetThrowsLogicException(): void + { + $this->expectExceptionObject( + new \LogicException('Helper context is locked and cannot be modified.', 1734434780), + ); + + unset($this->subject['foo']); + } + + /** + * @return array{ + * string, + * string, + * array{ + * hash: array, + * contexts: list>, + * _this: array, + * data: array<'root'|int, array>, + * fn?: callable(string): string, + * inverse?: callable(string): string, + * }, + * } + */ + private function getRuntimeOptions(bool $includeChildrenClosure = true, bool $includeInverseClosure = true): array + { + $options = [ + 'foo', + 'baz', + [ + 'hash' => [ + 'foo' => 'baz', + ], + 'contexts' => &$this->stack, + '_this' => &$this->renderingContext, + 'data' => &$this->data, + ], + ]; + + if ($includeChildrenClosure) { + $options[2]['fn'] = static fn(string $input) => 'fn: ' . $input; + } + + if ($includeInverseClosure) { + $options[2]['inverse'] = static fn(string $input) => 'inverse: ' . $input; + } + + return $options; + } +} diff --git a/Tests/Unit/Renderer/Helper/Context/RenderingContextStackTest.php b/Tests/Unit/Renderer/Helper/Context/RenderingContextStackTest.php new file mode 100644 index 00000000..246b0b0e --- /dev/null +++ b/Tests/Unit/Renderer/Helper/Context/RenderingContextStackTest.php @@ -0,0 +1,168 @@ + + * + * 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\Unit\Renderer\Helper\Context; + +use Fr\Typo3Handlebars as Src; +use PHPUnit\Framework; +use TYPO3\TestingFramework; + +/** + * RenderingContextStackTest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +#[Framework\Attributes\CoversClass(Src\Renderer\Helper\Context\RenderingContextStack::class)] +final class RenderingContextStackTest extends TestingFramework\Core\Unit\UnitTestCase +{ + private Src\Renderer\Helper\Context\RenderingContextStack $subject; + + /** + * @var list> + */ + private array $stack = [ + ['foo' => 'baz'], + ['baz' => 'foo'], + ]; + + public function setUp(): void + { + parent::setUp(); + + $this->subject = new Src\Renderer\Helper\Context\RenderingContextStack($this->stack); + } + + #[Framework\Attributes\Test] + public function constructorStoresGivenStackByReference(): void + { + $this->stack[] = ['boo' => 'faz']; + + $expected = [ + ['boo' => 'faz'], + ['baz' => 'foo'], + ['foo' => 'baz'], + ]; + + self::assertSame($expected, \iterator_to_array($this->subject)); + } + + #[Framework\Attributes\Test] + public function constructorSetsArrayPointerToEndOfGivenStack(): void + { + $subject = new Src\Renderer\Helper\Context\RenderingContextStack($this->stack); + + self::assertSame(['baz' => 'foo'], $subject->pop()); + } + + #[Framework\Attributes\Test] + public function fromRuntimeCallReturnsObjectWithEmptyStackIfGivenContextsAreEmpty(): void + { + // This is a "special" syntax from LightnCandy + $contexts = [null]; + + $subject = Src\Renderer\Helper\Context\RenderingContextStack::fromRuntimeCall($contexts); + + self::assertSame([], \iterator_to_array($subject)); + } + + #[Framework\Attributes\Test] + public function fromRuntimeCallReturnsObjectWithGivenContextsByReference(): void + { + $subject = Src\Renderer\Helper\Context\RenderingContextStack::fromRuntimeCall($this->stack); + + self::assertSame( + [ + ['baz' => 'foo'], + ['foo' => 'baz'], + ], + \iterator_to_array($subject), + ); + + unset($this->stack[0], $this->stack[1]); + + self::assertSame([], \iterator_to_array($subject)); + } + + #[Framework\Attributes\Test] + public function popReturnsCurrentContextAndSetsInternalArrayPointerToPreviousContextInStack(): void + { + self::assertSame(['baz' => 'foo'], $this->subject->pop()); + self::assertSame(['foo' => 'baz'], $this->subject->pop()); + self::assertTrue($this->subject->isEmpty()); + } + + #[Framework\Attributes\Test] + public function popReturnsNullIfStackIsEmpty(): void + { + $this->stack = []; + + $subject = new Src\Renderer\Helper\Context\RenderingContextStack($this->stack); + + self::assertNull($subject->pop()); + } + + #[Framework\Attributes\Test] + public function popReturnsNullIfStackIsProcessed(): void + { + $this->subject->pop(); + $this->subject->pop(); + + self::assertNull($this->subject->pop()); + } + + #[Framework\Attributes\Test] + public function popReturnsNullIfContextIsInvalid(): void + { + $stack = ['foo']; + $subject = new Src\Renderer\Helper\Context\RenderingContextStack($stack); + + self::assertNull($subject->pop()); + } + + #[Framework\Attributes\Test] + public function firstReturnsFirstElementInStack(): void + { + self::assertSame(['foo' => 'baz'], $this->subject->first()); + } + + #[Framework\Attributes\Test] + public function firstReturnsNullIfStackIsEmpty(): void + { + $stack = []; + $subject = new Src\Renderer\Helper\Context\RenderingContextStack($stack); + + self::assertNull($subject->first()); + } + + #[Framework\Attributes\Test] + public function resetSetsInternalArrayPointerToEndOfStack(): void + { + self::assertSame(['baz' => 'foo'], $this->subject->pop()); + self::assertSame(['foo' => 'baz'], $this->subject->pop()); + + $this->subject->reset(); + + self::assertSame(['baz' => 'foo'], $this->subject->pop()); + } +} diff --git a/Tests/Unit/Renderer/Helper/VarDumpHelperTest.php b/Tests/Unit/Renderer/Helper/VarDumpHelperTest.php index 8b5ba284..97104453 100644 --- a/Tests/Unit/Renderer/Helper/VarDumpHelperTest.php +++ b/Tests/Unit/Renderer/Helper/VarDumpHelperTest.php @@ -37,16 +37,33 @@ #[Framework\Attributes\CoversClass(Src\Renderer\Helper\VarDumpHelper::class)] final class VarDumpHelperTest extends TestingFramework\Core\Unit\UnitTestCase { + private Src\Renderer\Helper\VarDumpHelper $subject; + + protected function setUp(): void + { + parent::setUp(); + + $this->subject = new Src\Renderer\Helper\VarDumpHelper(); + } + #[Framework\Attributes\Test] public function evaluateReturnsDumpedContext(): void { Core\Utility\DebugUtility::useAnsiColor(false); - $context = [ - '_this' => [ - 'foo' => 'baz', - ], + $renderingContext = [ + 'foo' => 'baz', ]; + $data = []; + $stack = []; + + $context = new Src\Renderer\Helper\Context\HelperContext( + [], + [], + new Src\Renderer\Helper\Context\RenderingContextStack($stack), + $renderingContext, + $data, + ); $expected = << "baz" (3 chars) EOF; - self::assertSame($expected, Src\Renderer\Helper\VarDumpHelper::evaluate($context)); + self::assertSame($expected, $this->subject->render($context)); Core\Utility\DebugUtility::useAnsiColor(true); } diff --git a/Tests/Unit/Traits/HandlebarsHelperTraitTest.php b/Tests/Unit/Traits/HandlebarsHelperTraitTest.php index 04a3fec0..fac608f8 100644 --- a/Tests/Unit/Traits/HandlebarsHelperTraitTest.php +++ b/Tests/Unit/Traits/HandlebarsHelperTraitTest.php @@ -65,20 +65,23 @@ public function registerHelperLogsCriticalErrorIfGivenHelperIsInvalid(mixed $fun #[Framework\Attributes\Test] #[Framework\Attributes\DataProvider('registerHelperRegistersHelperCorrectlyDataProvider')] - public function registerHelperRegistersHelperCorrectly(mixed $function, string|callable $expectedCallable): void + public function registerHelperRegistersHelperCorrectly(mixed $function, callable $expectedCallable): void { $this->subject->registerHelper('foo', $function); - self::assertEquals(['foo' => $expectedCallable], $this->subject->getHelpers()); + + $expected = $this->mapExpectedCallable($expectedCallable); + + self::assertEquals(['foo' => $expected], $this->subject->getHelpers()); } #[Framework\Attributes\Test] public function registerHelperOverridesAvailableHelper(): void { $this->subject->registerHelper('foo', 'trim'); - self::assertSame(['foo' => 'trim'], $this->subject->getHelpers()); + self::assertEquals(['foo' => $this->mapExpectedCallable(trim(...))], $this->subject->getHelpers()); $this->subject->registerHelper('foo', 'strtolower'); - self::assertSame(['foo' => 'strtolower'], $this->subject->getHelpers()); + self::assertEquals(['foo' => $this->mapExpectedCallable(strtolower(...))], $this->subject->getHelpers()); } #[Framework\Attributes\Test] @@ -87,7 +90,7 @@ public function getHelpersReturnsRegisteredHelpers(): void self::assertSame([], $this->subject->getHelpers()); $this->subject->registerHelper('foo', 'strtolower'); - self::assertSame(['foo' => 'strtolower'], $this->subject->getHelpers()); + self::assertEquals(['foo' => $this->mapExpectedCallable(strtolower(...))], $this->subject->getHelpers()); } /** @@ -109,7 +112,7 @@ public static function registerHelperRegistersHelperCorrectlyDataProvider(): \Ge { yield 'callable function as string' => [ 'trim', - 'trim', + trim(...), ]; yield 'invokable class as string' => [ Tests\Unit\Fixtures\Classes\Renderer\Helper\DummyHelper::class, @@ -140,4 +143,17 @@ public static function registerHelperRegistersHelperCorrectlyDataProvider(): \Ge [new Tests\Unit\Fixtures\Classes\Renderer\Helper\DummyHelper(), 'execute'], ]; } + + /** + * @return callable(\Fr\Typo3Handlebars\Renderer\Helper\Context\HelperContext): mixed + */ + private function mapExpectedCallable(callable $expectedCallable): callable + { + return static function () use ($expectedCallable) { + $arguments = \func_get_args(); + $context = Src\Renderer\Helper\Context\HelperContext::fromRuntimeCall($arguments); + + return $expectedCallable($context); + }; + } }