Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.50% covered (warning)
70.50%
98 / 139
70.27% covered (warning)
70.27%
26 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
Element
70.50% covered (warning)
70.50%
98 / 139
70.27% covered (warning)
70.27%
26 / 37
281.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 nodeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nodeName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttributeNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setAttribute
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 setAttributeNode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 children
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getElementsByTagName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 querySelectorAll
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 querySelector
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 matches
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 closest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 localName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 namespaceUri
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 elementId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 classes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttributeValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allAttributes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 parentElement
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 previousElementSibling
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 nextElementSibling
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 elementChildren
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 indexAmongSiblings
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 indexAmongSiblingsFromEnd
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 indexAmongTypeSiblings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 indexAmongTypeSiblingsFromEnd
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 attachShadow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 isShadowHostEligible
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 collectByTagName
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 canonicalAttrKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 splitPrefix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 splitLocalName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 shallowClone
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Html\Dom;
6
7use Phpdftk\Css\Selector\MatchableElement;
8use Phpdftk\Css\Selector\Matcher;
9use Phpdftk\Css\Selector\SelectorParser;
10
11/**
12 * An HTML, SVG, or MathML element.
13 *
14 * Per Q1, the mutation surface (setAttribute, appendChild, attachShadow, ...)
15 * is public by design — used both by the parser during tree construction and
16 * by author code performing post-parse transformations.
17 *
18 * Tag names are normalised: HTML elements expose lower-case `localName` and
19 * upper-case `tagName` (matching WHATWG); foreign-namespace elements keep
20 * the case the parser saw them in.
21 *
22 * @phpstan-consistent-constructor HTMLSlotElement (the only subclass) keeps the
23 *   constructor signature compatible. Required so {@see Element::shallowClone()}
24 *   can safely call `new static()`.
25 */
26class Element extends Node implements MatchableElement
27{
28    public readonly string $localName;
29    public readonly string $namespaceURI;
30    public readonly ?string $prefix;
31
32    /** @var array<string, Attr> keyed by qualified name (prefix:localName or localName) */
33    private array $attributes = [];
34
35    private ?ClassList $classListInstance = null;
36    private ?ShadowRoot $shadowRef = null;
37
38    public string $tagName {
39        get {
40            $name = $this->prefix !== null
41                ? $this->prefix . ':' . $this->localName
42                : $this->localName;
43            return $this->namespaceURI === Document::HTML_NS ? strtoupper($name) : $name;
44        }
45    }
46
47    public ?string $id {
48        get => $this->getAttribute('id');
49    }
50
51    public ClassList $classList {
52        get => $this->classListInstance ??= new ClassList($this);
53    }
54
55    public ?ShadowRoot $shadowRoot {
56        get => $this->shadowRef;
57    }
58
59    public function __construct(
60        Document $ownerDocument,
61        string $localName,
62        string $namespaceURI = Document::HTML_NS,
63        ?string $prefix = null,
64    ) {
65        parent::__construct($ownerDocument);
66        $this->localName = $localName;
67        $this->namespaceURI = $namespaceURI;
68        $this->prefix = $prefix;
69    }
70
71    public function nodeType(): NodeType
72    {
73        return NodeType::Element;
74    }
75
76    public function nodeName(): string
77    {
78        return $this->tagName;
79    }
80
81    /** @return list<Attr> */
82    public function attributes(): array
83    {
84        return array_values($this->attributes);
85    }
86
87    public function hasAttribute(string $name): bool
88    {
89        return isset($this->attributes[$this->canonicalAttrKey($name)]);
90    }
91
92    public function getAttribute(string $name): ?string
93    {
94        return $this->attributes[$this->canonicalAttrKey($name)]->value ?? null;
95    }
96
97    public function getAttributeNode(string $name): ?Attr
98    {
99        return $this->attributes[$this->canonicalAttrKey($name)] ?? null;
100    }
101
102    public function setAttribute(string $name, string $value): void
103    {
104        $key = $this->canonicalAttrKey($name);
105        // Preserve namespace/prefix of existing attribute if present.
106        $existing = $this->attributes[$key] ?? null;
107        $this->attributes[$key] = new Attr(
108            localName: $existing !== null ? $existing->localName : $this->splitLocalName($name),
109            value: $value,
110            namespaceURI: $existing !== null ? $existing->namespaceURI : Document::HTML_NS,
111            prefix: $existing !== null ? $existing->prefix : $this->splitPrefix($name),
112        );
113    }
114
115    public function setAttributeNode(Attr $attr): void
116    {
117        $this->attributes[$attr->qualifiedName()] = $attr;
118    }
119
120    public function removeAttribute(string $name): void
121    {
122        unset($this->attributes[$this->canonicalAttrKey($name)]);
123    }
124
125    /** @return list<Element> direct element children only */
126    public function children(): array
127    {
128        $out = [];
129        for ($n = $this->firstChild; $n !== null; $n = $n->nextSibling) {
130            if ($n instanceof Element) {
131                $out[] = $n;
132            }
133        }
134        return $out;
135    }
136
137    /** @return list<Element> depth-first traversal */
138    public function getElementsByTagName(string $localName): array
139    {
140        $localName = $this->namespaceURI === Document::HTML_NS ? strtolower($localName) : $localName;
141        $out = [];
142        $this->collectByTagName($this, $localName, $out);
143        return $out;
144    }
145
146    /**
147     * Depth-first descendant traversal returning every element under this
148     * node that matches the selector. Per WHATWG `Document::querySelectorAll`
149     * doesn't include the host element itself.
150     *
151     * @return list<Element>
152     */
153    public function querySelectorAll(string $selector): array
154    {
155        $list = SelectorParser::parse($selector);
156        $matcher = new Matcher();
157        $out = [];
158        $stack = $this->children();
159        while ($stack !== []) {
160            $node = array_shift($stack);
161            if ($matcher->listMatches($list, $node)) {
162                $out[] = $node;
163            }
164            foreach ($node->children() as $c) {
165                $stack[] = $c;
166            }
167        }
168        return $out;
169    }
170
171    public function querySelector(string $selector): ?Element
172    {
173        $matches = $this->querySelectorAll($selector);
174        return $matches[0] ?? null;
175    }
176
177    public function matches(string $selector): bool
178    {
179        $list = SelectorParser::parse($selector);
180        $matcher = new Matcher();
181        return $matcher->listMatches($list, $this);
182    }
183
184    public function closest(string $selector): ?Element
185    {
186        $list = SelectorParser::parse($selector);
187        $matcher = new Matcher();
188        for ($n = $this; $n !== null; $n = $n->parentNode) {
189            if ($n instanceof Element && $matcher->listMatches($list, $n)) {
190                return $n;
191            }
192        }
193        return null;
194    }
195
196    // ------------------------------------------------------------------
197    // MatchableElement implementation — adapts the WHATWG DOM to the
198    // structural traversal the CSS selector engine consumes.
199    // ------------------------------------------------------------------
200
201    public function localName(): string
202    {
203        return $this->localName;
204    }
205
206    public function namespaceUri(): ?string
207    {
208        return $this->namespaceURI;
209    }
210
211    public function elementId(): ?string
212    {
213        $id = $this->getAttribute('id');
214        return $id === null || $id === '' ? null : $id;
215    }
216
217    /** @return list<string> */
218    public function classes(): array
219    {
220        return $this->classList->values();
221    }
222
223    public function getAttributeValue(string $name): ?string
224    {
225        return $this->getAttribute($name);
226    }
227
228    /** @return array<string, string> */
229    public function allAttributes(): array
230    {
231        $out = [];
232        foreach ($this->attributes() as $attr) {
233            $out[$attr->qualifiedName()] = $attr->value;
234        }
235        return $out;
236    }
237
238    public function parentElement(): ?MatchableElement
239    {
240        $p = $this->parentNode;
241        return $p instanceof Element ? $p : null;
242    }
243
244    public function previousElementSibling(): ?MatchableElement
245    {
246        for ($n = $this->previousSibling; $n !== null; $n = $n->previousSibling) {
247            if ($n instanceof Element) {
248                return $n;
249            }
250        }
251        return null;
252    }
253
254    public function nextElementSibling(): ?MatchableElement
255    {
256        for ($n = $this->nextSibling; $n !== null; $n = $n->nextSibling) {
257            if ($n instanceof Element) {
258                return $n;
259            }
260        }
261        return null;
262    }
263
264    /** @return list<MatchableElement> */
265    public function elementChildren(): array
266    {
267        return $this->children();
268    }
269
270    public function indexAmongSiblings(): int
271    {
272        $i = 1;
273        for ($n = $this->previousSibling; $n !== null; $n = $n->previousSibling) {
274            if ($n instanceof Element) {
275                $i++;
276            }
277        }
278        return $i;
279    }
280
281    public function indexAmongSiblingsFromEnd(): int
282    {
283        $i = 1;
284        for ($n = $this->nextSibling; $n !== null; $n = $n->nextSibling) {
285            if ($n instanceof Element) {
286                $i++;
287            }
288        }
289        return $i;
290    }
291
292    public function indexAmongTypeSiblings(): int
293    {
294        $i = 1;
295        for ($n = $this->previousSibling; $n !== null; $n = $n->previousSibling) {
296            if ($n instanceof Element
297                && $n->localName === $this->localName
298                && $n->namespaceURI === $this->namespaceURI
299            ) {
300                $i++;
301            }
302        }
303        return $i;
304    }
305
306    public function indexAmongTypeSiblingsFromEnd(): int
307    {
308        $i = 1;
309        for ($n = $this->nextSibling; $n !== null; $n = $n->nextSibling) {
310            if ($n instanceof Element
311                && $n->localName === $this->localName
312                && $n->namespaceURI === $this->namespaceURI
313            ) {
314                $i++;
315            }
316        }
317        return $i;
318    }
319
320    /**
321     * Attach a shadow root to this element. Used by the parser when handling
322     * <template shadowrootmode> (declarative shadow DOM). Available publicly
323     * but rarely needed by user code — DSD is the documented path.
324     *
325     * @throws \LogicException if a shadow root is already attached or this
326     *         element is not shadow-host-eligible.
327     */
328    public function attachShadow(ShadowRootMode $mode, ShadowRootInit $init = new ShadowRootInit()): ShadowRoot
329    {
330        if ($this->shadowRef !== null) {
331            throw new \LogicException(sprintf('Element <%s> already has a shadow root', $this->localName));
332        }
333        if (!$this->isShadowHostEligible()) {
334            throw new \LogicException(
335                sprintf('Element <%s> is not shadow-host-eligible per WHATWG', $this->localName),
336            );
337        }
338        $this->shadowRef = new ShadowRoot($this, $mode, $init);
339        return $this->shadowRef;
340    }
341
342    /**
343     * Per WHATWG: shadow-host-eligible HTML elements are valid custom-element
344     * names plus the explicit allow-list. Foreign elements are not eligible.
345     */
346    public function isShadowHostEligible(): bool
347    {
348        if ($this->namespaceURI !== Document::HTML_NS) {
349            return false;
350        }
351        $allowed = [
352            'article', 'aside', 'blockquote', 'body', 'div', 'footer', 'h1', 'h2', 'h3',
353            'h4', 'h5', 'h6', 'header', 'main', 'nav', 'p', 'section', 'span',
354        ];
355        if (in_array($this->localName, $allowed, true)) {
356            return true;
357        }
358        // Custom-element names: contain a hyphen, start with [a-z], match PCEN.
359        if (str_contains($this->localName, '-') && preg_match('/^[a-z][a-z0-9_.\-]*$/', $this->localName)) {
360            return true;
361        }
362        return false;
363    }
364
365    /** @param list<Element> $out */
366    private function collectByTagName(Node $scope, string $localName, array &$out): void
367    {
368        for ($n = $scope->firstChild; $n !== null; $n = $n->nextSibling) {
369            if ($n instanceof Element && ($localName === '*' || $n->localName === $localName)) {
370                $out[] = $n;
371            }
372            if ($n->hasChildNodes()) {
373                $this->collectByTagName($n, $localName, $out);
374            }
375        }
376    }
377
378    private function canonicalAttrKey(string $name): string
379    {
380        return $this->namespaceURI === Document::HTML_NS ? strtolower($name) : $name;
381    }
382
383    private function splitPrefix(string $qualified): ?string
384    {
385        $i = strpos($qualified, ':');
386        return $i === false ? null : substr($qualified, 0, $i);
387    }
388
389    private function splitLocalName(string $qualified): string
390    {
391        $i = strpos($qualified, ':');
392        return $i === false ? $qualified : substr($qualified, $i + 1);
393    }
394
395    protected function shallowClone(): static
396    {
397        $copy = new static($this->ownerDocument, $this->localName, $this->namespaceURI, $this->prefix);
398        foreach ($this->attributes as $attr) {
399            $copy->setAttributeNode($attr);
400        }
401        if ($this->shadowRef !== null && $this->shadowRef->clonable) {
402            $cloneInit = new ShadowRootInit(
403                delegatesFocus: $this->shadowRef->delegatesFocus,
404                clonable: true,
405                serializable: $this->shadowRef->serializable,
406                slotAssignment: $this->shadowRef->slotAssignment,
407            );
408            $newShadow = $copy->attachShadow($this->shadowRef->mode, $cloneInit);
409            for ($n = $this->shadowRef->firstChild; $n !== null; $n = $n->nextSibling) {
410                $newShadow->appendChild($n->cloneNode(true));
411            }
412        }
413        /** @var static $copy */
414        return $copy;
415    }
416}