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

tempestphp / tempest-framework / 14239242351

03 Apr 2025 09:08AM UTC coverage: 81.144% (+0.07%) from 81.07%
14239242351

Pull #1113

github

web-flow
Merge 47a013561 into b96e68dbd
Pull Request #1113: feat(view): improve boolean attributes

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

9 existing lines in 2 files now uncovered.

11163 of 13757 relevant lines covered (81.14%)

104.65 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(
126✔
41
        private ElementFactory $elementFactory,
42
        private AttributeFactory $attributeFactory,
43
        private Kernel $kernel,
44
    ) {}
126✔
45

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

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

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

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

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

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

65
        return $compiled;
114✔
66
    }
67

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

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

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

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

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

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

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

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

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

108
        $template = str($template)
120✔
109
            // Convert self-closing and void tags
120✔
110
            ->replaceRegex(
120✔
111
                regex: '/<(?<element>\w[^<]*?)\/>/',
120✔
112
                replace: function (array $match) {
120✔
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
                },
120✔
127
            );
120✔
128

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

132
        $headTemplate = $template->match('/<head>((.|\n)*?)<\/head>/');
120✔
133

134
        if ($headTemplate) {
120✔
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)
120✔
142
            // Cleanup head, we'll insert it after having parsed the DOM
120✔
143
            ->replaceRegex('/<head>((.|\n)*?)<\/head>/', '<head></head>');
120✔
144

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

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

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

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

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

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

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

177
        return $elements;
120✔
178
    }
179

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

188
        $previous = null;
120✔
189

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

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

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

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

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

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

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

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

216
        return $appliedElements;
120✔
217
    }
218

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

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

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

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

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