Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.06% covered (warning)
59.06%
101 / 171
58.82% covered (warning)
58.82%
20 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMCompat
59.41% covered (warning)
59.41%
101 / 170
58.82% covered (warning)
58.82%
20 / 34
712.23
0.00% covered (danger)
0.00%
0 / 1
 isStandardsMode
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 zestOptions
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 isUsingDodo
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isUsing84Dom
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 newDocument
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getBody
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 getHead
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 getTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setTitle
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getParentElement
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getElementById
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setIdAttribute
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getElementsByTagName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFirstElementChild
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getLastElementChild
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 querySelector
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 querySelectorAll
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getPreviousElementSibling
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getNextElementSibling
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 append
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 appendChild
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 remove
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getInnerHTML
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setInnerHTML
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getOuterHTML
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttribute
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 attributes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getClassList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stripAndCollapseASCIIWhitespace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 stripEmptyTextNodes
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 normalize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 replaceChildren
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 getTemplateElementContent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 encodeCssId
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2// phpcs:disable Universal.Operators.TypeSeparatorSpacing.UnionTypeSpacesAfter
3declare( strict_types = 1 );
4
5namespace Wikimedia\Parsoid\Core;
6
7use DOMCharacterData;
8use DOMDocument;
9use DOMDocumentFragment;
10use DOMElement;
11use Wikimedia\Assert\Assert;
12use Wikimedia\Parsoid\Core\DOMCompatTokenList as TokenList;
13use Wikimedia\Parsoid\DOM\CharacterData;
14use Wikimedia\Parsoid\DOM\Document;
15use Wikimedia\Parsoid\DOM\DocumentFragment;
16use Wikimedia\Parsoid\DOM\DOMParser;
17use Wikimedia\Parsoid\DOM\Element;
18use Wikimedia\Parsoid\DOM\HTMLDocument;
19use Wikimedia\Parsoid\DOM\Node;
20use Wikimedia\Parsoid\DOM\Text;
21use Wikimedia\Parsoid\Utils\DOMUtils;
22use Wikimedia\Parsoid\Wt2Html\TreeBuilder\ParsoidDOMFragmentBuilder;
23use Wikimedia\Parsoid\Wt2Html\XHtmlSerializer;
24use Wikimedia\RemexHtml\HTMLData;
25use Wikimedia\RemexHtml\Tokenizer\Tokenizer;
26use Wikimedia\RemexHtml\TreeBuilder\Dispatcher;
27use Wikimedia\RemexHtml\TreeBuilder\TreeBuilder;
28use Wikimedia\Zest\Zest;
29
30/**
31 * Helper class that provides missing DOM level 3 methods for the PHP DOM classes.
32 * For a DOM method $node->foo( $bar) the equivalent helper is DOMCompat::foo( $node, $bar ).
33 * For a DOM property $node->foo there is a DOMCompat::getFoo( $node ) and
34 * DOMCompat::setFoo( $node, $value ).
35 *
36 * Only implements the methods that are actually used by Parsoid.
37 *
38 * Because this class may be used by code outside Parsoid, it tries to
39 * be relatively tolerant of object types: you can call it either with
40 * PHP's DOM* types or with a "proper" DOM implementation, and it will
41 * attempt to Do The Right Thing regardless. As a result, there are
42 * generally not parameter type hints for DOM object types, and the
43 * return types will be broad enough to accomodate the value a "real"
44 * DOM implementation would return, as well as the values our
45 * thunk will return. (For instance, we can't create a "real" NodeList
46 * in our compatibility thunk.)
47 *
48 * Exception to the above: ::nodeName method is not so much a DOM compatibility
49 * method in the sense above, but a proxy to let us support multiple DOM libraries
50 * against the Parsoid codebase that expects lower-case names. In this specific
51 * instance the default behavior is tailored for performance vs. being
52 * HTML-standards-compliant.
53 */
54class DOMCompat {
55    /**
56     * Tab, LF, FF, CR, space
57     * @see https://infra.spec.whatwg.org/#ascii-whitespace
58     */
59    private const ASCII_WHITESPACE = "\t\r\f\n ";
60
61    /**
62     * @param Node|null $node If present, we'll use the type of the given node
63     *  to determine whether to use standards mode.
64     * @return bool When false, we'll use DOMDocument workarounds.
65     */
66    public static function isStandardsMode( $node = null ): bool {
67        if ( $node !== null ) {
68            return !( $node instanceof \DOMNode );
69        }
70        return self::isUsingDodo() || self::isUsing84Dom();
71    }
72
73    private static function zestOptions(): array {
74        if ( self::isUsing84Dom() ) {
75            return [
76                // Speed up getElementsById calls; this should use upstream
77                // getElementsById once these two bugs are fixed:
78                // https://github.com/php/php-src/issues/20281
79                // https://github.com/php/php-src/issues/20282
80                'getElementsById' => static function ( $context, $id ) {
81                    if ( is_a( $context, '\Dom\Document', false ) ) {
82                        '@phan-var Document $context';
83                        return [ $context->getElementById( $id ) ];
84                    }
85                    return iterator_to_array(
86                        $context->querySelectorAll( '#' . self::encodeCssId( $id ) )
87                    );
88                },
89            ];
90        } elseif ( self::isUsingDodo() ) {
91            return [ 'standardsMode' => true, ];
92        } else {
93            return [];
94        }
95    }
96
97    /**
98     * @param Node|null $node If present, we'll use the type of the given node
99     *   to determine whether we're using Dodo.
100     * @return bool When true, we're using the Dodo DOM implementation.
101     * @internal
102     */
103    public static function isUsingDodo( $node = null ): bool {
104        if ( $node !== null ) {
105            return is_a( $node, '\Wikimedia\Dodo\Node', false );
106        }
107        // Change this to switch to using Dodo for Parsoid.
108        return false;
109    }
110
111    /**
112     * @param Node|null $node If present, we'll use the type of the given node
113     *   to determine whether we're using the PHP 8.4 DOM implementation.
114     * @return bool When true, we're using the PHP 8.4 DOM implementation.
115     * @internal
116     */
117    public static function isUsing84Dom( $node = null ): bool {
118        if ( $node !== null ) {
119            return is_a( $node, '\Dom\Node', false );
120        }
121        // Defaults to using \Dom\Document on PHP 8.4 (unless we're using Dodo)
122        return !self::isUsingDodo() && class_exists( '\Dom\Document' );
123    }
124
125    /**
126     * Create a new empty HTML document using the preferred DOM
127     * implementation.
128     * @param bool $isHtml (optional) Should always be true.
129     * @return Document
130     */
131    public static function newDocument( bool $isHtml = true ): Document {
132        Assert::invariant( $isHtml, "only HTML documents are supported" );
133        if ( self::isUsingDodo() ) {
134            $doc = ( new DOMParser() )->parseFromString(
135                '<div></div>', 'text/html'
136            );
137        } elseif ( self::isUsing84Dom() ) {
138            $doc = HTMLDocument::createEmpty( "UTF-8" );
139        } else {
140            // @phan-suppress-next-line PhanParamTooMany,PhanTypeInstantiateInterface
141            $doc = new Document( "1.0", "UTF-8" );
142        }
143        '@phan-var Document $doc';
144        // Remove doctype, head, body, etc for compat w/ PHP
145        while ( $doc->firstChild !== null ) {
146            $doc->removeChild( $doc->firstChild );
147        }
148        return $doc;
149    }
150
151    /**
152     * Get document body.
153     * Unlike the spec we return it as a native PHP DOM object.
154     * @param Document $document
155     * @return Element|null
156     * @see https://html.spec.whatwg.org/multipage/dom.html#dom-document-body
157     */
158    public static function getBody( $document ) {
159        if ( self::isStandardsMode( $document ) ) {
160            return $document->body;
161        }
162        // Use an undeclared dynamic property as a cache.
163        // WARNING: this will not be updated if (for some reason) the
164        // document body changes.
165        if ( $document->body !== null ) {
166            return $document->body;
167        }
168        if ( $document->documentElement === null ) {
169            return null;
170        }
171        foreach ( DOMUtils::childNodes( $document->documentElement ) as $element ) {
172            /** @var Element $element */
173            $nodeName = DOMUtils::nodeName( $element );
174            if ( $nodeName === 'body' || $nodeName === 'frameset' ) {
175                // Caching!
176                // @phan-suppress-next-line PhanTypeMismatchProperty
177                $document->body = $element;
178                // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
179                return $element;
180            }
181        }
182        return null;
183    }
184
185    /**
186     * Get document head.
187     * Unlike the spec we return it as a native PHP DOM object.
188     * @param Document $document
189     * @return Element|null
190     * @see https://html.spec.whatwg.org/multipage/dom.html#dom-document-head
191     */
192    public static function getHead( $document ) {
193        if ( self::isStandardsMode( $document ) ) {
194            return $document->head;
195        }
196        // Use an undeclared dynamic property as a cache.
197        // WARNING: this will not be updated if (for some reason) the
198        // document head changes.
199        if ( isset( $document->head ) ) {
200            return $document->head;
201        }
202        if ( $document->documentElement === null ) {
203            return null;
204        }
205        foreach ( DOMUtils::childNodes( $document->documentElement ) as $element ) {
206            /** @var Element $element */
207            if ( DOMUtils::nodeName( $element ) === 'head' ) {
208                // @phan-suppress-next-line PhanTypeMismatchProperty
209                $document->head = $element; // Caching!
210                // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
211                return $element;
212            }
213        }
214        return null;
215    }
216
217    /**
218     * Get document title.
219     * @param Document $document
220     * @return string
221     * @see https://html.spec.whatwg.org/multipage/dom.html#document.title
222     */
223    public static function getTitle( $document ): string {
224        $titleElement = self::querySelector( $document, 'title' );
225        return $titleElement ? self::stripAndCollapseASCIIWhitespace( $titleElement->textContent ) : '';
226    }
227
228    /**
229     * Set document title.
230     * @param Document $document
231     * @param string $title
232     * @see https://html.spec.whatwg.org/multipage/dom.html#document.title
233     */
234    public static function setTitle( $document, string $title ): void {
235        $titleElement = self::querySelector( $document, 'title' );
236        if ( !$titleElement ) {
237            $headElement = self::getHead( $document );
238            if ( $headElement ) {
239                $titleElement = DOMUtils::appendToHead( $document, 'title' );
240            }
241        }
242        if ( $titleElement ) {
243            $titleElement->textContent = $title;
244        }
245    }
246
247    /**
248     * Return the parent element, or null if the parent is not an element.
249     * @param Node $node
250     * @return Element|null
251     * @see https://dom.spec.whatwg.org/#dom-node-parentelement
252     */
253    public static function getParentElement( $node ) {
254        $parent = $node->parentNode;
255        if ( $parent && $parent->nodeType === XML_ELEMENT_NODE ) {
256            /** @var Element $parent */
257            // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
258            return $parent;
259        }
260        return null;
261    }
262
263    /**
264     * Return the descendant with the specified ID.
265     * Workaround for https://bugs.php.net/bug.php?id=77686 and other issues related to
266     * inconsistent indexing behavior.
267     * XXX: 77686 is fixed in php 8.1.21
268     * @param Document|DocumentFragment $node
269     * @param string $id
270     * @return Element|null
271     * @see https://dom.spec.whatwg.org/#dom-nonelementparentnode-getelementbyid
272     */
273    public static function getElementById(
274        // For compatibility with code which might call this from
275        // outside Parsoid.
276        DOMDocument|DOMDocumentFragment|
277        Document|DocumentFragment $node,
278        string $id
279    ) {
280        // @phan-suppress-next-line PhanTypeMismatchArgument Zest is declared to take DOMDocument\DOMElement
281        $elements = Zest::getElementsById( $node, $id, self::zestOptions() );
282        // @phan-suppress-next-line PhanTypeMismatchReturn
283        return $elements[0] ?? null;
284    }
285
286    /**
287     * Workaround bug in PHP's Document::getElementById() which doesn't
288     * actually index the 'id' attribute unless you use the non-standard
289     * `Element::setIdAttribute` method after the attribute is set;
290     * see https://www.php.net/manual/en/domdocument.getelementbyid.php
291     * for more details.
292     *
293     * @param Element $element
294     * @param string $id The desired value for the `id` attribute on $element.
295     * @see https://phabricator.wikimedia.org/T232390
296     */
297    public static function setIdAttribute( $element, string $id ): void {
298        $element->setAttribute( 'id', $id );
299        $element->setIdAttribute( 'id', true );// phab:T232390
300    }
301
302    /**
303     * Return all descendants with the specified tag name.
304     * Workaround for PHP's getElementsByTagName being inexplicably slow in some situations
305     * and the lack of Element::getElementsByTagName().
306     * @param Document|Element $node
307     * @param string $tagName
308     * @return (iterable<Element>&\Countable)|array<Element> Either an array or an HTMLCollection object
309     * @see https://dom.spec.whatwg.org/#dom-document-getelementsbytagname
310     * @see https://dom.spec.whatwg.org/#dom-element-getelementsbytagname
311     * @note Note that unlike the spec this method is not guaranteed to return a NodeList
312     *   (which cannot be freely constructed in PHP), just a traversable containing Elements.
313     */
314    public static function getElementsByTagName(
315        // For compatibility with code which might call this from
316        // outside Parsoid.
317        DOMDocument|DOMElement|
318        Document|Element $node, string $tagName
319    ): iterable {
320        // @phan-suppress-next-line PhanTypeMismatchArgument Zest is declared to take DOMDocument\DOMElement
321        $result = Zest::getElementsByTagName( $node, $tagName, self::zestOptions() );
322        '@phan-var array<Element> $result'; // @var array<Element> $result
323        return $result;
324    }
325
326    /**
327     * Return the first child of the node that is an Element, or null
328     * otherwise.
329     * @param Document|DocumentFragment|Element $node
330     * @return Element|null
331     * @see https://dom.spec.whatwg.org/#dom-parentnode-firstelementchild
332     * @note This property was added to PHP in 8.0.0, and won't be needed
333     *  once our minimum required version >= 8.0.0
334     */
335    public static function getFirstElementChild(
336        // For compatibility with code which might call this from
337        // outside Parsoid.
338        DOMDocument|DOMDocumentFragment|DOMElement|
339        Document|DocumentFragment|Element $node
340    ) {
341        $firstChild = $node->firstChild;
342        while ( $firstChild && $firstChild->nodeType !== XML_ELEMENT_NODE ) {
343            $firstChild = $firstChild->nextSibling;
344        }
345        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
346        return $firstChild;
347    }
348
349    /**
350     * Return the last child of the node that is an Element, or null otherwise.
351     * @param Document|DocumentFragment|Element $node
352     * @return Element|null
353     * @see https://dom.spec.whatwg.org/#dom-parentnode-lastelementchild
354     * @note This property was added to PHP in 8.0.0, and won't be needed
355     *  once our minimum required version >= 8.0.0
356     */
357    public static function getLastElementChild(
358        // For compatibility with code which might call this from
359        // outside Parsoid.
360        DOMDocument|DOMDocumentFragment|DOMElement|
361        Document|DocumentFragment|Element $node
362    ) {
363        $lastChild = $node->lastChild;
364        while ( $lastChild && $lastChild->nodeType !== XML_ELEMENT_NODE ) {
365            $lastChild = $lastChild->previousSibling;
366        }
367        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
368        return $lastChild;
369    }
370
371    /**
372     * @param Document|DocumentFragment|Element $node
373     * @param string $selector
374     * @return Element|null
375     * @see https://dom.spec.whatwg.org/#dom-parentnode-queryselector
376     */
377    public static function querySelector( $node, string $selector ) {
378        if ( self::isUsingDodo( $node ) ) {
379            return $node->querySelector( $selector );
380        }
381        foreach ( self::querySelectorAll( $node, $selector ) as $el ) {
382            return $el;
383        }
384        return null;
385    }
386
387    /**
388     * @param Document|DocumentFragment|Element $node
389     * @param string $selector
390     * @return (iterable<Element>&\Countable)|array<Element> Either a NodeList or an array
391     * @see https://dom.spec.whatwg.org/#dom-parentnode-queryselectorall
392     * @note Note that unlike the spec this method is not guaranteed to return a NodeList
393     *   (which cannot be freely constructed in PHP), just a traversable containing Elements.
394     */
395    public static function querySelectorAll(
396        // For compatibility with code which might call this from
397        // outside Parsoid.
398        DOMDocument|DOMDocumentFragment|DOMElement|
399        Document|DocumentFragment|Element $node,
400        string $selector
401    ): iterable {
402        if ( self::isUsingDodo( $node ) ) {
403            return $node->querySelectorAll( $selector );
404        }
405        // @phan-suppress-next-line PhanTypeMismatchArgument DOMNode
406        return Zest::find( $selector, $node, self::zestOptions() );
407    }
408
409    /**
410     * Return the last preceding sibling of the node that is an element, or null otherwise.
411     * @param Element|CharacterData $node
412     * @return Element|null
413     * @see https://dom.spec.whatwg.org/#dom-nondocumenttypechildnode-previouselementsibling
414     */
415    public static function getPreviousElementSibling(
416        // For compatibility with code which might call this from
417        // outside Parsoid.
418        DOMElement|DOMCharacterData|
419        Element|CharacterData $node
420    ) {
421        $previousSibling = $node->previousSibling;
422        while ( $previousSibling && $previousSibling->nodeType !== XML_ELEMENT_NODE ) {
423            $previousSibling = $previousSibling->previousSibling;
424        }
425        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
426        return $previousSibling;
427    }
428
429    /**
430     * Return the first following sibling of the node that is an element, or null otherwise.
431     * @param Element|CharacterData $node
432     * @return Element|null
433     * @see https://dom.spec.whatwg.org/#dom-nondocumenttypechildnode-nextelementsibling
434     */
435    public static function getNextElementSibling(
436        // For compatibility with code which might call this from
437        // outside Parsoid.
438        DOMElement|DOMCharacterData|
439        Element|CharacterData $node
440    ) {
441        $nextSibling = $node->nextSibling;
442        while ( $nextSibling && $nextSibling->nodeType !== XML_ELEMENT_NODE ) {
443            $nextSibling = $nextSibling->nextSibling;
444        }
445        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
446        return $nextSibling;
447    }
448
449    /**
450     * Append the node to the parent node.
451     * @param Document|DocumentFragment|Element $parentNode
452     * @param Node|string ...$nodes
453     * @note This method was added in PHP 8.0.0
454     */
455    public static function append(
456        // For compatibility with code which might call this from
457        // outside Parsoid.
458        DOMDocument|DOMDocumentFragment|DOMElement|
459        Document|DocumentFragment|Element $parentNode,
460        DOMNode|
461        Node|string ...$nodes
462    ): void {
463        foreach ( $nodes as $node ) {
464            if ( is_string( $node ) ) {
465                $node = $parentNode->ownerDocument->createTextNode( $node );
466            }
467            self::appendChild( $parentNode, $node );
468        }
469    }
470
471    /**
472     * Append a child node to the parent node.
473     * @param Document|DocumentFragment|Element $parentNode
474     * @param Node $node
475     * @return Node
476     * @note From T411228 et al, appending an empty Document Fragment results
477     * in a PHP warning.  No longer necessary when isUsing84Dom is true.
478     */
479    public static function appendChild(
480        // For compatibility with code which might call this from
481        // outside Parsoid.
482        DOMDocument|DOMDocumentFragment|DOMElement|
483        Document|DocumentFragment|Element $parentNode,
484        DOMNode|
485        Node $node
486    ) {
487        if ( !( $node->nodeType === XML_DOCUMENT_FRAG_NODE ) || $node->hasChildNodes() ) {
488            $parentNode->appendChild( $node );
489        }
490        return $node;
491    }
492
493    /**
494     * Removes the node from the document.
495     * @param Element|CharacterData $node
496     * @see https://dom.spec.whatwg.org/#dom-childnode-remove
497     */
498    public static function remove(
499        // For compatibility with code which might call this from
500        // outside Parsoid.
501        DOMElement|DOMCharacterData|
502        Element|CharacterData $node
503    ): void {
504        if ( $node->parentNode ) {
505            $node->parentNode->removeChild( $node );
506        }
507    }
508
509    /**
510     * Get innerHTML.
511     * @see DOMUtils::getFragmentInnerHTML() for the fragment version
512     * @param Element $element
513     * @return string
514     * @see https://w3c.github.io/DOM-Parsing/#dom-innerhtml-innerhtml
515     */
516    public static function getInnerHTML( $element ): string {
517        // Always use Parsoid's serializer even in standards mode,
518        // since the "standard" DOM spec isn't quite the same as Parsoid
519        // expects w/r/t quoting etc.
520        return XHtmlSerializer::serialize( $element, [ 'innerXML' => true ] )['html'];
521    }
522
523    /**
524     * Set innerHTML.
525     * @see https://w3c.github.io/DOM-Parsing/#dom-innerhtml-innerhtml
526     * @see DOMUtils::setFragmentInnerHTML() for the fragment version
527     * @param Element $element
528     * @param string $html
529     */
530    public static function setInnerHTML( $element, string $html ): void {
531        // Always use Remex for parsing, even in standards mode.
532        $domBuilder = new ParsoidDOMFragmentBuilder( $element->ownerDocument );
533        $treeBuilder = new TreeBuilder( $domBuilder );
534        $dispatcher = new Dispatcher( $treeBuilder );
535        $tokenizer = new Tokenizer( $dispatcher, $html, [ 'ignoreErrors' => true ] );
536
537        $tokenizer->execute( [
538            'fragmentNamespace' => HTMLData::NS_HTML,
539            // Note that fragmentName *should* be lowercase.
540            'fragmentName' => DOMUtils::nodeName( $element ),
541        ] );
542
543        // Empty the element
544        self::replaceChildren( $element );
545
546        $frag = $domBuilder->getFragment();
547        '@phan-var DocumentFragment $frag'; // @var DocumentFragment $frag
548        DOMUtils::migrateChildren(
549            $frag, $element
550        );
551    }
552
553    /**
554     * Get outerHTML.
555     * @param Element $element
556     * @return string
557     * @see https://w3c.github.io/DOM-Parsing/#dom-element-outerhtml
558     */
559    public static function getOuterHTML( $element ): string {
560        return XHtmlSerializer::serialize( $element, [ 'addDoctype' => false ] )['html'];
561    }
562
563    /**
564     * Return the value of an element attribute.
565     *
566     * Unlike PHP's version, this is spec-compliant and returns `null` if
567     * the attribute is not present, allowing the caller to distinguish
568     * between "the attribute exists but has the empty string as its value"
569     * and "the attribute does not exist".
570     *
571     * @param Element $element
572     * @param string $attributeName
573     * @return ?string The attribute value, or `null` if the attribute does
574     *   not exist on the element.
575     * @see https://dom.spec.whatwg.org/#dom-element-getattribute
576     */
577    public static function getAttribute( $element, string $attributeName ): ?string {
578        if ( !$element->hasAttribute( $attributeName ) ) {
579            return null;
580        }
581        return $element->getAttribute( $attributeName );
582    }
583
584    /**
585     * Get an associative array of attributes, suitable for serialization.
586     *
587     * Add the xmlns attribute if available, to workaround PHP's surprising
588     * behavior with the xmlns attribute: HTML is *not* an XML document,
589     * but various parts of PHP pretend that it is, sort of.
590     *
591     * @param Element $element
592     * @return array<string,string>
593     * @see https://phabricator.wikimedia.org/T235295
594     * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attributes
595     * @note Note that unlike the spec this method returns an associative
596     *  array, not a NamedNodeMap, and as such is not an exact replacement
597     *  for the DOM `attributes` property.
598     */
599    public static function attributes( Element $element ): array {
600        $result = [];
601        if ( !self::isStandardsMode( $element ) ) {
602            // The 'xmlns' attribute is "invisible" T235295
603            $xmlns = self::getAttribute( $element, 'xmlns' );
604            if ( $xmlns !== null ) {
605                $result['xmlns'] = $xmlns;
606            }
607        }
608        foreach ( $element->attributes as $attr ) {
609            $result[$attr->name] = $attr->value;
610        }
611        return $result;
612    }
613
614    /**
615     * Return the class list of this element.
616     * @param Element $node
617     * @return TokenList
618     * @see https://dom.spec.whatwg.org/#dom-element-classlist
619     */
620    public static function getClassList( $node ): TokenList {
621        return new TokenList( $node );
622    }
623
624    /**
625     * @param string $text
626     * @return string
627     * @see https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace
628     */
629    private static function stripAndCollapseASCIIWhitespace( string $text ): string {
630        $ws = self::ASCII_WHITESPACE;
631        return preg_replace( "/[$ws]+/", ' ', trim( $text, $ws ) );
632    }
633
634    /**
635     * @param Element|DocumentFragment $e
636     */
637    private static function stripEmptyTextNodes( $e ): void {
638        $c = $e->firstChild;
639        while ( $c ) {
640            $next = $c->nextSibling;
641            if ( $c instanceof Text ) {
642                if ( $c->nodeValue === '' ) {
643                    $e->removeChild( $c );
644                }
645            } elseif ( $c instanceof Element ) {
646                self::stripEmptyTextNodes( $c );
647            }
648            $c = $next;
649        }
650    }
651
652    /**
653     * @param Element|DocumentFragment $elt root of the DOM tree that
654     *   needs to be normalized
655     */
656    public static function normalize( $elt ): void {
657        $elt->normalize();
658
659        // Now traverse the tree rooted at $elt and remove any stray empty text nodes
660        // Unlike what https://www.w3.org/TR/DOM-Level-2-Core/core.html#ID-normalize says,
661        // the PHP DOM's normalization leaves behind up to 1 empty text node.
662        // See https://bugs.php.net/bug.php?id=78221
663        self::stripEmptyTextNodes( $elt );
664    }
665
666    /**
667     * ParentNode.replaceChildren()
668     * https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/replaceChildren
669     *
670     * @param Document|DocumentFragment|Element $parentNode
671     * @param Node|string ...$nodes
672     */
673    public static function replaceChildren(
674        // For compatibility with code which might call this from
675        // outside Parsoid
676        DOMDocument|DOMDocumentFragment|DOMElement|
677        Document|DocumentFragment|Element $parentNode,
678        DOMNode|
679        Node|string ...$nodes
680    ): void {
681        while ( $parentNode->firstChild ) {
682            $parentNode->removeChild( $parentNode->firstChild );
683        }
684        foreach ( $nodes as $node ) {
685            if ( is_string( $node ) ) {
686                $node = $parentNode->ownerDocument->createTextNode( $node );
687            }
688            if ( $node->nodeType === XML_DOCUMENT_FRAG_NODE && !$node->hasChildNodes() ) {
689                continue; // (work around bug in PHP 8.3)
690            }
691            $parentNode->insertBefore( $node, null );
692        }
693    }
694
695    /**
696     * Return HTMLTemplateElement#content
697     *
698     * In the PHP DOM, <template> elements do not have a dedicated
699     * DocumentFragment and children are stored directly under the
700     * Element.  In the HTML5 spec, the contents are stored in a
701     * DocumentFragment with a unique owner document.
702     *
703     * Bridge this gap by returning the <template> element for
704     * PHP's DOM, or the DocumentFragment for an HTML5-compliant DOM.
705     *
706     * @param Element $node A <template> element
707     * @return Element|DocumentFragment Either the element (for PHP compat)
708     *  or the DocumentFragment which is the template's "content"
709     */
710    public static function getTemplateElementContent( $node ) {
711        if ( isset( $node->content ) ) {
712            // @phan-suppress-next-line PhanUndeclaredProperty only in IDLeDOM
713            return $node->content;
714        }
715        return $node;
716    }
717
718    /**
719     * Escape an identifier for CSS.
720     * This is equivalent to CSS.escape
721     * (https://drafts.csswg.org/cssom/#the-css.escape()-method)
722     * and is the opposite of self::decodeid().
723     * @note Borrowed from zest.php
724     */
725    private static function encodeCssId( string $str ): string {
726        // phpcs:ignore Generic.Files.LineLength.TooLong
727        return preg_replace_callback( '/(\\x00)|([\\x01-\\x1F\\x7F])|(^[0-9])|(^-[0-9])|(^-$)|([^-A-Za-z0-9_\\x{80}-\\x{10FFFF}])/u', static function ( array $matches ) {
728            if ( isset( $matches[1] ) ) {
729                return "\u{FFFD}";
730            } elseif ( isset( $matches[2] ) || isset( $matches[3] ) ) {
731                $cp = mb_ord( $matches[0], "UTF-8" );
732                return '\\' . dechex( $cp ) . ' ';
733            } elseif ( isset( $matches[4] ) ) {
734                $cp = mb_ord( $matches[0][1], "UTF-8" );
735                return '-\\' . dechex( $cp ) . ' ';
736            } else {
737                return '\\' . $matches[0];
738            }
739        }, $str, -1, $ignore, PREG_UNMATCHED_AS_NULL );
740    }
741}
742// @deprecated since 0.23; moved from Utils to Core
743class_alias( DOMCompat::class, 'Wikimedia\\Parsoid\\Utils\\DOMCompat' );