Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
70.50% |
98 / 139 |
|
70.27% |
26 / 37 |
CRAP | |
0.00% |
0 / 1 |
| Element | |
70.50% |
98 / 139 |
|
70.27% |
26 / 37 |
281.24 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| nodeType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| nodeName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| attributes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasAttribute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAttribute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAttributeNode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setAttribute | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| setAttributeNode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| removeAttribute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| children | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| getElementsByTagName | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| querySelectorAll | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
| querySelector | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| matches | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| closest | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| localName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| namespaceUri | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| elementId | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| classes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAttributeValue | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| allAttributes | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| parentElement | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| previousElementSibling | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| nextElementSibling | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| elementChildren | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| indexAmongSiblings | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| indexAmongSiblingsFromEnd | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| indexAmongTypeSiblings | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
| indexAmongTypeSiblingsFromEnd | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
| attachShadow | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| isShadowHostEligible | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
| collectByTagName | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
6 | |||
| canonicalAttrKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| splitPrefix | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| splitLocalName | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| shallowClone | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Html\Dom; |
| 6 | |
| 7 | use Phpdftk\Css\Selector\MatchableElement; |
| 8 | use Phpdftk\Css\Selector\Matcher; |
| 9 | use 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 | */ |
| 26 | class 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 | } |