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

tempestphp / tempest-framework / 14279044006

05 Apr 2025 05:59AM UTC coverage: 81.139% (+0.2%) from 80.906%
14279044006

Pull #1115

github

web-flow
Merge f5e63ae2b into 90e820853
Pull Request #1115: refactor(view): implement custom html parser

275 of 292 new or added lines in 13 files covered. (94.18%)

10 existing lines in 5 files now uncovered.

11344 of 13981 relevant lines covered (81.14%)

104.8 hits per line

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

97.73
/src/Tempest/View/src/Elements/ViewComponentElement.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Tempest\View\Elements;
6

7
use Dom\HTMLDocument;
8
use Tempest\Core\Environment;
9
use Tempest\View\Element;
10
use Tempest\View\Renderers\TempestViewCompiler;
11
use Tempest\View\Slot;
12
use Tempest\View\ViewComponent;
13

14
use function Tempest\Support\arr;
15
use function Tempest\Support\str;
16

17
use const Dom\HTML_NO_DEFAULT_NS;
18

19
final class ViewComponentElement implements Element
20
{
21
    use IsElement;
22

23
    private array $dataAttributes;
24

25
    public function __construct(
71✔
26
        private readonly Environment $environment,
27
        private readonly TempestViewCompiler $compiler,
28
        private readonly ViewComponent $viewComponent,
29
        array $attributes,
30
    ) {
31
        $this->attributes = $attributes;
71✔
32
        $this->dataAttributes = arr($attributes)
71✔
33
            ->filter(fn ($_, $key) => ! str_starts_with($key, ':'))
71✔
34
            // Attributes are converted to camelCase by default for PHP variable usage, but in the context of data attributes, kebab case is good
71✔
35
            ->mapWithKeys(fn ($value, $key) => yield str($key)->kebab()->toString() => $value)
71✔
36
            ->toArray();
71✔
37
    }
38

39
    public function getViewComponent(): ViewComponent
×
40
    {
UNCOV
41
        return $this->viewComponent;
×
42
    }
43

44
    /** @return Element[] */
45
    public function getSlots(): array
70✔
46
    {
47
        $slots = [];
70✔
48

49
        foreach ($this->getChildren() as $child) {
70✔
50
            if (! ($child instanceof SlotElement)) {
31✔
51
                continue;
29✔
52
            }
53

54
            $slots[] = $child;
6✔
55
        }
56

57
        return $slots;
70✔
58
    }
59

60
    public function getSlot(string $name = 'slot'): ?Element
34✔
61
    {
62
        foreach ($this->getChildren() as $child) {
34✔
63
            if (! ($child instanceof SlotElement)) {
30✔
64
                continue;
29✔
65
            }
66

67
            if ($child->matches($name)) {
5✔
68
                return $child;
3✔
69
            }
70
        }
71

72
        if ($name === 'slot') {
33✔
73
            $elements = [];
31✔
74

75
            foreach ($this->getChildren() as $child) {
31✔
76
                if ($child instanceof SlotElement) {
29✔
77
                    continue;
4✔
78
                }
79

80
                $elements[] = $child;
29✔
81
            }
82

83
            return new CollectionElement($elements);
31✔
84
        }
85

86
        return null;
2✔
87
    }
88

89
    public function compile(): string
70✔
90
    {
91
        /** @var Slot[] $slots */
92
        $slots = arr($this->getSlots())
70✔
93
            ->mapWithKeys(fn (SlotElement $element) => yield $element->name => Slot::fromElement($element))
70✔
94
            ->toArray();
70✔
95

96
        $compiled = str($this->viewComponent->compile($this))
70✔
97
            // Fallthrough attributes
70✔
98
            ->replaceRegex(
70✔
99
                regex: '/^<(?<tag>[\w-]+)(.*?["\s])?>/', // Match the very first opening tag, this will never fail.
70✔
100
                replace: function ($matches) {
70✔
101
                    $closingTag = '</' . $matches['tag'] . '>';
35✔
102

103
                    $html = $matches[0] . $closingTag;
35✔
104

105
                    // TODO refactor to own parser
106
                    $dom = HTMLDocument::createFromString($html, LIBXML_HTML_NOIMPLIED | LIBXML_NOERROR | HTML_NO_DEFAULT_NS);
35✔
107

108
                    /** @var \Dom\HTMLElement $element */
109
                    $element = $dom->childNodes[0];
35✔
110

111
                    foreach (['class', 'style', 'id'] as $attributeName) {
35✔
112
                        if (! isset($this->dataAttributes[$attributeName])) {
35✔
113
                            continue;
33✔
114
                        }
115

116
                        if ($attributeName === 'id') {
3✔
117
                            $value = $this->dataAttributes[$attributeName];
2✔
118
                        } else {
119
                            $value = arr([
3✔
120
                                $element->getAttribute($attributeName),
3✔
121
                                $this->dataAttributes[$attributeName],
3✔
122
                            ])
3✔
123
                                ->filter()
3✔
124
                                ->implode(' ')
3✔
125
                                ->toString();
3✔
126
                        }
127

128
                        $element->setAttribute(
3✔
129
                            qualifiedName: $attributeName,
3✔
130
                            value: $value,
3✔
131
                        );
3✔
132
                    }
133

134
                    return str($element->ownerDocument->saveHTML($element))->replaceLast($closingTag, '');
35✔
135
                },
70✔
136
            )
70✔
137
            ->prepend(
70✔
138
                // Add attributes to the current scope
139
                '<?php $_previousAttributes = $attributes ?? null; ?>',
70✔
140
                sprintf('<?php $attributes = \Tempest\Support\arr(%s); ?>', var_export($this->dataAttributes, true)), // @mago-expect best-practices/no-debug-symbols Set the new value of $attributes for this view component
70✔
141

142
                // Add dynamic slots to the current scope
143
                '<?php $_previousSlots = $slots ?? null; ?>', // Store previous slots in temporary variable to keep scope
70✔
144
                sprintf('<?php $slots = \Tempest\Support\arr(%s); ?>', var_export($slots, true)), // @mago-expect best-practices/no-debug-symbols Set the new value of $slots for this view component
70✔
145
            )
70✔
146
            ->append(
70✔
147
                // Restore previous slots
148
                '<?php unset($slots); ?>',
70✔
149
                '<?php $slots = $_previousSlots ?? null; ?>',
70✔
150
                '<?php unset($_previousSlots); ?>',
70✔
151

152
                // Restore previous attributes
153
                '<?php unset($attributes); ?>',
70✔
154
                '<?php $attributes = $_previousAttributes ?? null; ?>',
70✔
155
                '<?php unset($_previousAttributes); ?>',
70✔
156
            )
70✔
157
            // Compile slots
70✔
158
            ->replaceRegex(
70✔
159
                regex: '/<x-slot\s*(name="(?<name>\w+)")?((\s*\/>)|><\/x-slot>)/',
70✔
160
                replace: function ($matches) {
70✔
161
                    $name = $matches['name'] ?: 'slot';
34✔
162

163
                    $slot = $this->getSlot($name);
34✔
164

165
                    if ($slot === null) {
34✔
166
                        // A slot doesn't have any content, so we'll comment it out.
167
                        // This is to prevent DOM parsing errors (slots in <head> tags is one example, see #937)
168
                        return $this->environment->isProduction() ? '' : ('<!--' . $matches[0] . '-->');
2✔
169
                    }
170

171
                    return $slot->compile();
32✔
172
                },
70✔
173
            );
70✔
174

175
        return $this->compiler->compile($compiled->toString());
70✔
176
    }
177
}
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