Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.35% covered (warning)
68.35%
95 / 139
71.43% covered (warning)
71.43%
25 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
Element
70.37% covered (warning)
70.37%
95 / 135
71.43% covered (warning)
71.43%
25 / 35
262.20
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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
 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        // Attribute names are flat strings — a colon is just part of
108        // the literal name. Namespace-adjusted foreign attributes
109        // (xlink:* / xml:lang / xml:space / xmlns) go through
110        // setAttributeNode() with a prefix + localName already split
111        // by the tree builder's adjust-foreign-attributes pass. So
112        // setAttribute() always stores the whole name as localName
113        // with prefix=null — works for both HTML and foreign elements.
114        $this->attributes[$key] = new Attr(
115            localName: $existing !== null ? $existing->localName : $name,
116            value: $value,
117            namespaceURI: $existing !== null ? $existing->namespaceURI : Document::HTML_NS,
118            prefix: $existing !== null ? $existing->prefix : null,
119        );
120    }
121
122    public function setAttributeNode(Attr $attr): void
123    {
124        $this->attributes[$attr->qualifiedName()] = $attr;
125    }
126
127    public function removeAttribute(string $name): void
128    {
129        unset($this->attributes[$this->canonicalAttrKey($name)]);
130    }
131
132    /** @return list<Element> direct element children only */
133    public function children(): array
134    {
135        $out = [];
136        for ($n = $this->firstChild; $n !== null; $n = $n->nextSibling) {
137            if ($n instanceof Element) {
138                $out[] = $n;
139            }
140        }
141        return $out;
142    }
143
144    /** @return list<Element> depth-first traversal */
145    public function getElementsByTagName(string $localName): array
146    {
147        $localName = $this->namespaceURI === Document::HTML_NS ? strtolower($localName) : $localName;
148        $out = [];
149        $this->collectByTagName($this, $localName, $out);
150        return $out;
151    }
152
153    /**
154     * Depth-first descendant traversal returning every element under this
155     * node that matches the selector. Per WHATWG `Document::querySelectorAll`
156     * doesn't include the host element itself.
157     *
158     * @return list<Element>
159     */
160    public function querySelectorAll(string $selector): array
161    {
162        $list = SelectorParser::parse($selector);
163        $matcher = new Matcher();
164        $out = [];
165        $stack = $this->children();
166        while ($stack !== []) {
167            $node = array_shift($stack);
168            if ($matcher->listMatches($list, $node)) {
169                $out[] = $node;
170            }
171            foreach ($node->children() as $c) {
172                $stack[] = $c;
173            }
174        }
175        return $out;
176    }
177
178    public function querySelector(string $selector): ?Element
179    {
180        $matches = $this->querySelectorAll($selector);
181        return $matches[0] ?? null;
182    }
183
184    public function matches(string $selector): bool
185    {
186        $list = SelectorParser::parse($selector);
187        $matcher = new Matcher();
188        return $matcher->listMatches($list, $this);
189    }
190
191    public function closest(string $selector): ?Element
192    {
193        $list = SelectorParser::parse($selector);
194        $matcher = new Matcher();
195        for ($n = $this; $n !== null; $n = $n->parentNode) {
196            if ($n instanceof Element && $matcher->listMatches($list, $n)) {
197                return $n;
198            }
199        }
200        return null;
201    }
202
203    // ------------------------------------------------------------------
204    // MatchableElement implementation — adapts the WHATWG DOM to the
205    // structural traversal the CSS selector engine consumes.
206    // ------------------------------------------------------------------
207
208    public function localName(): string
209    {
210        return $this->localName;
211    }
212
213    public function namespaceUri(): ?string
214    {
215        return $this->namespaceURI;
216    }
217
218    public function elementId(): ?string
219    {
220        $id = $this->getAttribute('id');
221        return $id === null || $id === '' ? null : $id;
222    }
223
224    /** @return list<string> */
225    public function classes(): array
226    {
227        return $this->classList->values();
228    }
229
230    public function getAttributeValue(string $name): ?string
231    {
232        return $this->getAttribute($name);
233    }
234
235    /** @return array<string, string> */
236    public function allAttributes(): array
237    {
238        $out = [];
239        foreach ($this->attributes() as $attr) {
240            $out[$attr->qualifiedName()] = $attr->value;
241        }
242        return $out;
243    }
244
245    public function parentElement(): ?MatchableElement
246    {
247        $p = $this->parentNode;
248        return $p instanceof Element ? $p : null;
249    }
250
251    public function previousElementSibling(): ?MatchableElement
252    {
253        for ($n = $this->previousSibling; $n !== null; $n = $n->previousSibling) {
254            if ($n instanceof Element) {
255                return $n;
256            }
257        }
258        return null;
259    }
260
261    public function nextElementSibling(): ?MatchableElement
262    {
263        for ($n = $this->nextSibling; $n !== null; $n = $n->nextSibling) {
264            if ($n instanceof Element) {
265                return $n;
266            }
267        }
268        return null;
269    }
270
271    /** @return list<MatchableElement> */
272    public function elementChildren(): array
273    {
274        return $this->children();
275    }
276
277    public function indexAmongSiblings(): int
278    {
279        $i = 1;
280        for ($n = $this->previousSibling; $n !== null; $n = $n->previousSibling) {
281            if ($n instanceof Element) {
282                $i++;
283            }
284        }
285        return $i;
286    }
287
288    public function indexAmongSiblingsFromEnd(): int
289    {
290        $i = 1;
291        for ($n = $this->nextSibling; $n !== null; $n = $n->nextSibling) {
292            if ($n instanceof Element) {
293                $i++;
294            }
295        }
296        return $i;
297    }
298
299    public function indexAmongTypeSiblings(): int
300    {
301        $i = 1;
302        for ($n = $this->previousSibling; $n !== null; $n = $n->previousSibling) {
303            if ($n instanceof Element
304                && $n->localName === $this->localName
305                && $n->namespaceURI === $this->namespaceURI
306            ) {
307                $i++;
308            }
309        }
310        return $i;
311    }
312
313    public function indexAmongTypeSiblingsFromEnd(): int
314    {
315        $i = 1;
316        for ($n = $this->nextSibling; $n !== null; $n = $n->nextSibling) {
317            if ($n instanceof Element
318                && $n->localName === $this->localName
319                && $n->namespaceURI === $this->namespaceURI
320            ) {
321                $i++;
322            }
323        }
324        return $i;
325    }
326
327    /**
328     * Attach a shadow root to this element. Used by the parser when handling
329     * <template shadowrootmode> (declarative shadow DOM). Available publicly
330     * but rarely needed by user code — DSD is the documented path.
331     *
332     * @throws \LogicException if a shadow root is already attached or this
333     *         element is not shadow-host-eligible.
334     */
335    public function attachShadow(ShadowRootMode $mode, ShadowRootInit $init = new ShadowRootInit()): ShadowRoot
336    {
337        if ($this->shadowRef !== null) {
338            throw new \LogicException(sprintf('Element <%s> already has a shadow root', $this->localName));
339        }
340        if (!$this->isShadowHostEligible()) {
341            throw new \LogicException(
342                sprintf('Element <%s> is not shadow-host-eligible per WHATWG', $this->localName),
343            );
344        }
345        $this->shadowRef = new ShadowRoot($this, $mode, $init);
346        return $this->shadowRef;
347    }
348
349    /**
350     * Per WHATWG: shadow-host-eligible HTML elements are valid custom-element
351     * names plus the explicit allow-list. Foreign elements are not eligible.
352     */
353    public function isShadowHostEligible(): bool
354    {
355        if ($this->namespaceURI !== Document::HTML_NS) {
356            return false;
357        }
358        $allowed = [
359            'article', 'aside', 'blockquote', 'body', 'div', 'footer', 'h1', 'h2', 'h3',
360            'h4', 'h5', 'h6', 'header', 'main', 'nav', 'p', 'section', 'span',
361        ];
362        if (in_array($this->localName, $allowed, true)) {
363            return true;
364        }
365        // Custom-element names: contain a hyphen, start with [a-z], match PCEN.
366        if (str_contains($this->localName, '-') && preg_match('/^[a-z][a-z0-9_.\-]*$/', $this->localName)) {
367            return true;
368        }
369        return false;
370    }
371
372    /** @param list<Element> $out */
373    private function collectByTagName(Node $scope, string $localName, array &$out): void
374    {
375        for ($n = $scope->firstChild; $n !== null; $n = $n->nextSibling) {
376            if ($n instanceof Element && ($localName === '*' || $n->localName === $localName)) {
377                $out[] = $n;
378            }
379            if ($n->hasChildNodes()) {
380                $this->collectByTagName($n, $localName, $out);
381            }
382        }
383    }
384
385    private function canonicalAttrKey(string $name): string
386    {
387        return $this->namespaceURI === Document::HTML_NS ? strtolower($name) : $name;
388    }
389
390    protected function shallowClone(): static
391    {
392        $copy = new static($this->ownerDocument, $this->localName, $this->namespaceURI, $this->prefix);
393        foreach ($this->attributes as $attr) {
394            $copy->setAttributeNode($attr);
395        }
396        if ($this->shadowRef !== null && $this->shadowRef->clonable) {
397            $cloneInit = new ShadowRootInit(
398                delegatesFocus: $this->shadowRef->delegatesFocus,
399                clonable: true,
400                serializable: $this->shadowRef->serializable,
401                slotAssignment: $this->shadowRef->slotAssignment,
402            );
403            $newShadow = $copy->attachShadow($this->shadowRef->mode, $cloneInit);
404            for ($n = $this->shadowRef->firstChild; $n !== null; $n = $n->nextSibling) {
405                $newShadow->appendChild($n->cloneNode(true));
406            }
407        }
408        /** @var static $copy */
409        return $copy;
410    }
411}