Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.63% covered (warning)
64.63%
53 / 82
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComponentHelperExtension
64.63% covered (warning)
64.63%
53 / 82
64.71% covered (warning)
64.71%
11 / 17
69.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 selectComponent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 deselectComponent
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 apieConstant
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 translate
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
4.59
 safeJsonEncode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilters
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFunctions
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 renderValidationError
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 getCurrentContext
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getCurrentRenderer
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getCurrentComponent
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 assetContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assetUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 property
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 component
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isPrototyped
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2namespace Apie\TwigTemplateLayoutRenderer\Extension;
3
4use Apie\Core\ApieLib;
5use Apie\Core\Context\ApieContext;
6use Apie\Core\Translator\ApieTranslator;
7use Apie\Core\Translator\ApieTranslatorInterface;
8use Apie\Core\Translator\Lists\TranslationStringSet;
9use Apie\Core\Translator\ValueObjects\AbstractTranslation;
10use Apie\Core\ValueObjects\Exceptions\InvalidStringForValueObjectException;
11use Apie\HtmlBuilders\Interfaces\ComponentInterface;
12use Apie\TwigTemplateLayoutRenderer\TwigRenderer;
13use LogicException;
14use ReflectionClass;
15use Symfony\UX\Icons\Twig\UXIconRuntime;
16use Twig\Environment;
17use Twig\Extension\AbstractExtension;
18use Twig\Runtime\EscaperRuntime;
19use Twig\TwigFilter;
20use Twig\TwigFunction;
21
22class ComponentHelperExtension extends AbstractExtension
23{
24
25    /** @var ComponentInterface[] */
26    private array $componentsHandled = [];
27
28    /** @var TwigRenderer[] */
29    private array $renderers = [];
30
31    /** @var ApieContext[] */
32    private array $contexts = [];
33
34    public function __construct(
35        private readonly UXIconRuntime $uxIconExtension
36    ) {
37    }
38
39    public function selectComponent(
40        TwigRenderer $renderer,
41        ComponentInterface $component,
42        ApieContext $apieContext
43    ): void {
44        $this->renderers[] = $renderer;
45        $this->componentsHandled[] = $component;
46        $this->contexts[] = $apieContext;
47    }
48
49    public function deselectComponent(ComponentInterface $component): void
50    {
51        if (end($this->componentsHandled) !== $component) {
52            throw new LogicException('Last component is not the one being deselected');
53        }
54        array_pop($this->componentsHandled);
55        array_pop($this->renderers);
56        array_pop($this->contexts);
57    }
58
59    public function apieConstant(string $constantName): mixed
60    {
61        $refl = new ReflectionClass(ApieLib::class);
62        return $refl->getConstant($constantName);
63    }
64
65    public function translate(string|AbstractTranslation|TranslationStringSet $translation): string
66    {
67        $apieContext = $this->getCurrentContext();
68        $translator = $apieContext->getContext(ApieTranslatorInterface::class, false) ?? ApieTranslator::create();
69        try {
70            return $translator->getGeneralTranslation(
71                $apieContext,
72                is_string($translation)
73                    ? AbstractTranslation::fromNative($translation)
74                    : $translation
75            ) ?? $translation;
76        } catch (InvalidStringForValueObjectException) {
77            if ($translation instanceof TranslationStringSet) {
78                return $translation->first();
79            }
80            return (string) $translation;
81        }
82    }
83
84    public function safeJsonEncode(mixed $data): string
85    {
86        return json_encode($data, JSON_HEX_QUOT|JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS);
87    }
88
89    public function getFilters(): array
90    {
91        return [
92            new TwigFilter('safe_json_encode', [$this, 'safeJsonEncode'], ['is_safe' => ['all']]),
93        ];
94    }
95
96    public function getFunctions(): array
97    {
98        return [
99            ...($this->uxIconExtension
100            ? [new TwigFunction('ux_icon', [$this->uxIconExtension, 'renderIcon'], ['is_safe' => ['html']])]
101            : []),
102            new TwigFunction('component', [$this, 'component'], ['is_safe' => ['all']]),
103            new TwigFunction('isPrototyped', [$this, 'isPrototyped']),
104            new TwigFunction('apieConstant', [$this, 'apieConstant']),
105            new TwigFunction('translate', [$this, 'translate'], []),
106            new TwigFunction('property', [$this, 'property'], []),
107            new TwigFunction('assetUrl', [$this, 'assetUrl'], []),
108            new TwigFunction('assetContent', [$this, 'assetContent'], ['is_safe' => ['all']]),
109            new TwigFunction(
110                'renderValidationError',
111                [$this, 'renderValidationError'],
112                ['needs_environment' => true, 'is_safe' => ['all']]
113            ),
114        ];
115    }
116
117    public function renderValidationError(
118        Environment $env,
119        mixed $value,
120        array|string|null $validationError
121    ): string {
122        if ($validationError === null) {
123            return '';
124        }
125        $escaper = $env->getRuntime(EscaperRuntime::class);
126        $valueAttr = '';
127        $valueScript = '';
128        if ($value !== null) {
129            if (is_string($value)) {
130                $valueAttr = ' exact-match="' . $escaper->escape($value, 'html_attr', null, false) . '"';
131            } else {
132                $valueAttr = ' class="unhandled-constraint"';
133                $valueScript = '<script>
134(function (elm) {
135    elm.classList.remove("unhandled-constraint");
136    elm.value = ' . str_replace('<', '&lt;', json_encode($value)) . ';
137}(document.querySelector(".unhandled-constraint"));
138                </script>';
139            }
140        }
141
142        if (is_string($validationError)) {
143            $escapedValidationError = $escaper->escape($validationError, 'html_attr', null, false);
144            return '<apie-constraint-check-definition message="'
145                . $escapedValidationError
146                . '"'
147                . $valueAttr
148                . '></apie-constraint-check-definition>'
149                . $valueScript;
150        }
151        return '';
152    }
153
154    private function getCurrentContext(): ApieContext
155    {
156        if (empty($this->contexts)) {
157            throw new LogicException('No component is selected');
158        }
159        return end($this->contexts);
160    }
161
162    private function getCurrentRenderer(): TwigRenderer
163    {
164        if (empty($this->renderers)) {
165            throw new LogicException('No component is selected');
166        }
167        return end($this->renderers);
168    }
169
170    private function getCurrentComponent(): ComponentInterface
171    {
172        if (empty($this->componentsHandled)) {
173            throw new LogicException('No component is selected');
174        }
175        return end($this->componentsHandled);
176    }
177
178    public function assetContent(string $filename): string
179    {
180        return $this->getCurrentRenderer()->getAssetContents($filename);
181    }
182
183    public function assetUrl(string $filename): string
184    {
185        return $this->getCurrentRenderer()->getAssetUrl($filename);
186    }
187
188    public function property(string $attributeKey): mixed
189    {
190        return $this->getCurrentComponent()->getAttribute($attributeKey);
191    }
192
193    public function component(string $componentName): string
194    {
195        return $this->getCurrentRenderer()->render(
196            $this->getCurrentComponent()->getComponent($componentName),
197            $this->getCurrentContext()
198        );
199    }
200
201    public function isPrototyped(): bool
202    {
203        $attrs = $this->getCurrentComponent()->getAttribute('additionalAttributes');
204        return is_array($attrs) ? ((bool) ($attrs['prototyped'] ?? false)) : false;
205    }
206}