• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tempestphp / tempest-framework / 14049246919

24 Mar 2025 09:42PM UTC coverage: 79.353% (-0.04%) from 79.391%
14049246919

push

github

web-flow
feat(support): support array parameters in string manipulations (#1073)

48 of 48 new or added lines in 2 files covered. (100.0%)

735 existing lines in 126 files now uncovered.

10492 of 13222 relevant lines covered (79.35%)

90.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.31
/src/Tempest/View/src/Renderers/TempestViewCompiler.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Tempest\View\Renderers;
6

7
use Dom\HTMLDocument;
8
use Dom\NodeList;
9
use Stringable;
10
use Tempest\Core\Kernel;
11
use Tempest\Discovery\DiscoveryLocation;
12
use Tempest\Mapper\Exceptions\ViewNotFound;
13
use Tempest\Support\Str\ImmutableString;
14
use Tempest\View\Attributes\AttributeFactory;
15
use Tempest\View\Element;
16
use Tempest\View\Elements\ElementFactory;
17
use Tempest\View\View;
18

19
use function Tempest\Support\arr;
20
use function Tempest\Support\Html\is_void_tag;
21
use function Tempest\Support\path;
22
use function Tempest\Support\str;
23

24
use const Dom\HTML_NO_DEFAULT_NS;
25

26
final readonly class TempestViewCompiler
27
{
28
    public const string TOKEN_PHP_OPEN = '<!--TOKEN_PHP_OPEN__';
29

30
    public const string TOKEN_PHP_SHORT_ECHO = '<!--TOKEN_PHP_SHORT_ECHO__';
31

32
    public const string TOKEN_PHP_CLOSE = '__TOKEN_PHP_CLOSE-->';
33

34
    public const array TOKEN_MAPPING = [
35
        '<?php' => self::TOKEN_PHP_OPEN,
36
        '<?=' => self::TOKEN_PHP_SHORT_ECHO,
37
        '?>' => self::TOKEN_PHP_CLOSE,
38
    ];
39

40
    public function __construct(
116✔
41
        private ElementFactory $elementFactory,
42
        private AttributeFactory $attributeFactory,
43
        private Kernel $kernel,
44
    ) {}
116✔
45

46
    public function compile(string|View $view): string
110✔
47
    {
48
        $this->elementFactory->setViewCompiler($this);
110✔
49

50
        // 1. Retrieve template
51
        $template = $this->retrieveTemplate($view);
110✔
52

53
        // 2. Parse as DOM
54
        $dom = $this->parseDom($template);
110✔
55

56
        // 3. Map to elements
57
        $elements = $this->mapToElements($dom);
110✔
58

59
        // 4. Apply attributes
60
        $elements = $this->applyAttributes($elements);
110✔
61

62
        // 5. Compile to PHP
63
        $compiled = $this->compileElements($elements);
104✔
64

65
        return $compiled;
104✔
66
    }
67

68
    private function retrieveTemplate(string|View $view): string
110✔
69
    {
70
        $path = ($view instanceof View) ? $view->path : $view;
110✔
71

72
        if (! str_ends_with($path, '.php')) {
110✔
73
            return $path;
103✔
74
        }
75

76
        $searchPathOptions = [
15✔
77
            $path,
15✔
78
        ];
15✔
79

80
        if ($view instanceof View && $view->relativeRootPath !== null) {
15✔
81
            $searchPathOptions[] = path($view->relativeRootPath, $path)->toString();
12✔
82
        }
83

84
        $searchPathOptions = [
15✔
85
            ...$searchPathOptions,
15✔
86
            ...arr($this->kernel->discoveryLocations)
15✔
87
                ->map(fn (DiscoveryLocation $discoveryLocation) => path($discoveryLocation->path, $path)->toString())
15✔
88
                ->toArray(),
15✔
89
        ];
15✔
90

91
        foreach ($searchPathOptions as $searchPath) {
15✔
92
            if (file_exists($searchPath)) {
15✔
93
                break;
15✔
94
            }
95
        }
96

97
        if (! file_exists($searchPath)) {
15✔
UNCOV
98
            throw new ViewNotFound($path);
×
99
        }
100

101
        return file_get_contents($searchPath);
15✔
102
    }
103

104
    private function parseDom(string $template): NodeList
110✔
105
    {
106
        $parserFlags = LIBXML_HTML_NOIMPLIED | LIBXML_NOERROR | HTML_NO_DEFAULT_NS;
110✔
107

108
        $template = str($template)
110✔
109
            // Convert self-closing and void tags
110✔
110
            ->replaceRegex(
110✔
111
                regex: '/<(?<element>\w[^<]*?)\/>/',
110✔
112
                replace: function (array $match) {
110✔
113
                    $element = str($match['element'])->trim();
26✔
114

115
                    if (is_void_tag($element)) {
26✔
116
                        // Void tags must not have a closing tag
117
                        return sprintf('<%s>', $element->toString());
1✔
118
                    }
119

120
                    // Other self-closing tags must get a proper closing tag
121
                    return sprintf(
25✔
122
                        '<%s></%s>',
25✔
123
                        $match['element'],
25✔
124
                        $element->before(' ')->toString(),
25✔
125
                    );
25✔
126
                },
110✔
127
            );
110✔
128

129
        // Find head nodes, these are parsed separately so that we skip HTML's head-parsing rules
130
        $headNodes = [];
110✔
131

132
        $headTemplate = $template->match('/<head>((.|\n)*?)<\/head>/')[1] ?? null;
110✔
133

134
        if ($headTemplate) {
110✔
135
            $headNodes = HTMLDocument::createFromString(
18✔
136
                source: $this->cleanupTemplate($headTemplate)->toString(),
18✔
137
                options: $parserFlags,
18✔
138
            )->childNodes;
18✔
139
        }
140

141
        $mainTemplate = $this->cleanupTemplate($template)
110✔
142
            // Cleanup head, we'll insert it after having parsed the DOM
110✔
143
            ->replaceRegex('/<head>((.|\n)*?)<\/head>/', '<head></head>');
110✔
144

145
        $dom = HTMLDocument::createFromString(
110✔
146
            source: $mainTemplate->toString(),
110✔
147
            options: $parserFlags,
110✔
148
        );
110✔
149

150
        // If we have head nodes and a head tag, we inject them back
151
        if (($headElement = $dom->getElementsByTagName('head')->item(0)) !== null) {
110✔
152
            foreach ($headNodes as $headNode) {
18✔
153
                $headElement->appendChild($dom->importNode($headNode, deep: true));
17✔
154
            }
155
        }
156

157
        return $dom->childNodes;
110✔
158
    }
159

160
    /**
161
     * @return Element[]
162
     */
163
    private function mapToElements(NodeList $nodes): array
110✔
164
    {
165
        $elements = [];
110✔
166

167
        foreach ($nodes as $node) {
110✔
168
            $element = $this->elementFactory->make($node);
110✔
169

170
            if ($element === null) {
110✔
171
                continue;
32✔
172
            }
173

174
            $elements[] = $element;
110✔
175
        }
176

177
        return $elements;
110✔
178
    }
179

180
    /**
181
     * @param Element[] $elements
182
     * @return Element[]
183
     */
184
    private function applyAttributes(array $elements): array
110✔
185
    {
186
        $appliedElements = [];
110✔
187

188
        $previous = null;
110✔
189

190
        foreach ($elements as $element) {
110✔
191
            $children = $this->applyAttributes($element->getChildren());
110✔
192

193
            $element
110✔
194
                ->setPrevious($previous)
110✔
195
                ->setChildren($children);
110✔
196

197
            foreach ($element->getAttributes() as $name => $value) {
110✔
198
                $attribute = $this->attributeFactory->make($element, $name);
90✔
199

200
                $element = $attribute->apply($element);
90✔
201

202
                if ($element === null) {
85✔
203
                    break;
13✔
204
                }
205
            }
206

207
            if ($element === null) {
110✔
208
                continue;
13✔
209
            }
210

211
            $appliedElements[] = $element;
110✔
212

213
            $previous = $element;
110✔
214
        }
215

216
        return $appliedElements;
110✔
217
    }
218

219
    /** @param \Tempest\View\Element[] $elements */
220
    private function compileElements(array $elements): string
104✔
221
    {
222
        $compiled = arr();
104✔
223

224
        foreach ($elements as $element) {
104✔
225
            $compiled[] = $element->compile();
104✔
226
        }
227

228
        return $compiled
104✔
229
            ->implode(PHP_EOL)
104✔
230
            // Unescape PHP tags
104✔
231
            ->replace(
104✔
232
                array_values(self::TOKEN_MAPPING),
104✔
233
                array_keys(self::TOKEN_MAPPING),
104✔
234
            )
104✔
235
            ->toString();
104✔
236
    }
237

238
    private function cleanupTemplate(string|Stringable $template): ImmutableString
110✔
239
    {
240
        return str($template)
110✔
241
            // Escape PHP tags
110✔
242
            ->replace(
110✔
243
                search: array_keys(self::TOKEN_MAPPING),
110✔
244
                replace: array_values(self::TOKEN_MAPPING),
110✔
245
            )
110✔
246
            // Convert self-closing tags
110✔
247
            ->replaceRegex(
110✔
248
                regex: '/<x-(?<element>.*?)\/>/',
110✔
249
                replace: function (array $match) {
110✔
UNCOV
250
                    $closingTag = str($match['element'])->before(' ')->toString();
×
251

UNCOV
252
                    return sprintf(
×
253
                        '<x-%s></x-%s>',
×
254
                        $match['element'],
×
255
                        $closingTag,
×
256
                    );
×
257
                },
110✔
258
            );
110✔
259
    }
260
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc