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

tempestphp / tempest-framework / 14024978163

23 Mar 2025 05:55PM UTC coverage: 79.391% (-0.05%) from 79.441%
14024978163

push

github

web-flow
feat(view): cache Blade and Twig templates in internal storage (#1061)

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

912 existing lines in 110 files now uncovered.

10478 of 13198 relevant lines covered (79.39%)

91.09 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
    ) {
45
    }
116✔
46

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

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

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

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

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

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

66
        return $compiled;
104✔
67
    }
68

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

178
        return $elements;
110✔
179
    }
180

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

189
        $previous = null;
110✔
190

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

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

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

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

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

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

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

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

217
        return $appliedElements;
110✔
218
    }
219

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

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

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

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

UNCOV
253
                    return sprintf(
×
UNCOV
254
                        '<x-%s></x-%s>',
×
UNCOV
255
                        $match['element'],
×
UNCOV
256
                        $closingTag,
×
UNCOV
257
                    );
×
258
                },
110✔
259
            );
110✔
260
    }
261
}
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