Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.15% covered (warning)
74.15%
109 / 147
65.38% covered (warning)
65.38%
17 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMCompat
74.15% covered (warning)
74.15%
109 / 147
65.38% covered (warning)
65.38%
17 / 26
107.25
0.00% covered (danger)
0.00%
0 / 1
 newDocument
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 nodeName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBody
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 getHead
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 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%
8 / 8
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 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getLastElementChild
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 querySelector
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 querySelectorAll
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getPreviousElementSibling
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getNextElementSibling
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 remove
100.00% covered (success)
100.00%
8 / 8
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%
18 / 18
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
 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
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 or
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Parsoid\DOM\CharacterData;
8use Wikimedia\Parsoid\DOM\Document;
9use Wikimedia\Parsoid\DOM\DocumentFragment;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\DOM\Node;
12use Wikimedia\Parsoid\DOM\Text;
13use Wikimedia\Parsoid\Utils\DOMCompat\TokenList;
14use Wikimedia\Parsoid\Wt2Html\XMLSerializer;
15use Wikimedia\RemexHtml\DOM\DOMBuilder;
16use Wikimedia\RemexHtml\HTMLData;
17use Wikimedia\RemexHtml\Tokenizer\Tokenizer;
18use Wikimedia\RemexHtml\TreeBuilder\Dispatcher;
19use Wikimedia\RemexHtml\TreeBuilder\TreeBuilder;
20use Wikimedia\Zest\Zest;
21
22/**
23 * Helper class that provides missing DOM level 3 methods for the PHP DOM classes.
24 * For a DOM method $node->foo( $bar) the equivalent helper is DOMCompat::foo( $node, $bar ).
25 * For a DOM property $node->foo there is a DOMCompat::getFoo( $node ) and
26 * DOMCompat::setFoo( $node, $value ).
27 *
28 * Only implements the methods that are actually used by Parsoid.
29 *
30 * Because this class may be used by code outside Parsoid it tries to
31 * be relatively tolerant of object types: you can call it either with
32 * PHP's DOM* types or with a "proper" DOM implementation, and it will
33 * attempt to Do The Right Thing regardless.  As a result there are
34 * generally not parameter type hints for DOM object types, and the
35 * return types will be broad enough to accomodate the value a "real"
36 * DOM implementation would return, as well as the values our
37 * thunk will return. (For instance, we can't create a "real" NodeList
38 * in our compatibility thunk.)
39 */
40class DOMCompat {
41
42    /**
43     * Tab, LF, FF, CR, space
44     * @see https://infra.spec.whatwg.org/#ascii-whitespace
45     */
46    private const ASCII_WHITESPACE = "\t\r\f\n ";
47
48    /**
49     * Create a new empty document.
50     * This is abstracted because the process is a little different depending
51     * on whether we're using Dodo or DOMDocument, and phan gets a little
52     * confused by this.
53     * @param bool $isHtml
54     * @return Document
55     */
56    public static function newDocument( bool $isHtml ) {
57        // @phan-suppress-next-line PhanParamTooMany,PhanTypeInstantiateInterface
58        return new Document( "1.0", "UTF-8" );
59    }
60
61    /**
62     * Return the lower-case version of the node name (HTML says this should
63     * be capitalized).
64     * @param Node $node
65     * @return string
66     */
67    public static function nodeName( Node $node ): string {
68        return strtolower( $node->nodeName );
69    }
70
71    /**
72     * Get document body.
73     * Unlike the spec we return it as a native PHP DOM object.
74     * @param Document $document
75     * @return Element|null
76     * @see https://html.spec.whatwg.org/multipage/dom.html#dom-document-body
77     */
78    public static function getBody( $document ) {
79        // WARNING: this will not be updated if (for some reason) the
80        // document body changes.
81        if ( $document->body !== null ) {
82            return $document->body;
83        }
84        foreach ( $document->documentElement->childNodes as $element ) {
85            /** @var Element $element */
86            $nodeName = self::nodeName( $element );
87            if ( $nodeName === 'body' || $nodeName === 'frameset' ) {
88                // Caching!
89                $document->body = $element;
90                // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
91                return $element;
92            }
93        }
94        return null;
95    }
96
97    /**
98     * Get document head.
99     * Unlike the spec we return it as a native PHP DOM object.
100     * @param Document $document
101     * @return Element|null
102     * @see https://html.spec.whatwg.org/multipage/dom.html#dom-document-head
103     */
104    public static function getHead( $document ) {
105        // Use an undeclared dynamic property as a cache.
106        // WARNING: this will not be updated if (for some reason) the
107        // document head changes.
108        if ( isset( $document->head ) ) {
109            return $document->head;
110        }
111        foreach ( $document->documentElement->childNodes as $element ) {
112            /** @var Element $element */
113            if ( self::nodeName( $element ) === 'head' ) {
114                $document->head = $element; // Caching!
115                // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
116                return $element;
117            }
118        }
119        return null;
120    }
121
122    /**
123     * Get document title.
124     * @param Document $document
125     * @return string
126     * @see https://html.spec.whatwg.org/multipage/dom.html#document.title
127     */
128    public static function getTitle( $document ): string {
129        $titleElement = self::querySelector( $document, 'title' );
130        return $titleElement ? self::stripAndCollapseASCIIWhitespace( $titleElement->textContent ) : '';
131    }
132
133    /**
134     * Set document title.
135     * @param Document $document
136     * @param string $title
137     * @see https://html.spec.whatwg.org/multipage/dom.html#document.title
138     */
139    public static function setTitle( $document, string $title ): void {
140        $titleElement = self::querySelector( $document, 'title' );
141        if ( !$titleElement ) {
142            $headElement = self::getHead( $document );
143            if ( $headElement ) {
144                $titleElement = DOMUtils::appendToHead( $document, 'title' );
145            }
146        }
147        if ( $titleElement ) {
148            $titleElement->textContent = $title;
149        }
150    }
151
152    /**
153     * Return the parent element, or null if the parent is not an element.
154     * @param Node $node
155     * @return Element|null
156     * @see https://dom.spec.whatwg.org/#dom-node-parentelement
157     */
158    public static function getParentElement( $node ) {
159        $parent = $node->parentNode;
160        if ( $parent && $parent->nodeType === XML_ELEMENT_NODE ) {
161            /** @var Element $parent */
162            // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
163            return $parent;
164        }
165        return null;
166    }
167
168    /**
169     * Return the descendant with the specified ID.
170     * Workaround for https://bugs.php.net/bug.php?id=77686 and other issues related to
171     * inconsistent indexing behavior.
172     * XXX: 77686 is fixed in php 8.1.21
173     * @param Document|DocumentFragment $node
174     * @param string $id
175     * @return Element|null
176     * @see https://dom.spec.whatwg.org/#dom-nonelementparentnode-getelementbyid
177     */
178    public static function getElementById( $node, string $id ) {
179        Assert::parameterType(
180            self::or(
181                Document::class, DocumentFragment::class,
182                // For compatibility with code which might call this from
183                // outside Parsoid.
184                \DOMDocument::class, \DOMDocumentFragment::class
185            ),
186            $node, '$node' );
187        // @phan-suppress-next-line PhanTypeMismatchArgument Zest is declared to take DOMDocument\DOMElement
188        $elements = Zest::getElementsById( $node, $id );
189        // @phan-suppress-next-line PhanTypeMismatchReturn
190        return $elements[0] ?? null;
191    }
192
193    /**
194     * Workaround bug in PHP's Document::getElementById() which doesn't
195     * actually index the 'id' attribute unless you use the non-standard
196     * `Element::setIdAttribute` method after the attribute is set;
197     * see https://www.php.net/manual/en/domdocument.getelementbyid.php
198     * for more details.
199     *
200     * @param Element $element
201     * @param string $id The desired value for the `id` attribute on $element.
202     * @see https://phabricator.wikimedia.org/T232390
203     */
204    public static function setIdAttribute( $element, string $id ): void {
205        $element->setAttribute( 'id', $id );
206        $element->setIdAttribute( 'id', true );// phab:T232390
207    }
208
209    /**
210     * Return all descendants with the specified tag name.
211     * Workaround for PHP's getElementsByTagName being inexplicably slow in some situations
212     * and the lack of Element::getElementsByTagName().
213     * @param Document|Element $node
214     * @param string $tagName
215     * @return (iterable<Element>&\Countable)|array<Element> Either an array or an HTMLCollection object
216     * @see https://dom.spec.whatwg.org/#dom-document-getelementsbytagname
217     * @see https://dom.spec.whatwg.org/#dom-element-getelementsbytagname
218     * @note Note that unlike the spec this method is not guaranteed to return a NodeList
219     *   (which cannot be freely constructed in PHP), just a traversable containing Elements.
220     */
221    public static function getElementsByTagName( $node, string $tagName ): iterable {
222        Assert::parameterType(
223            self::or(
224                Document::class, Element::class,
225                // For compatibility with code which might call this from
226                // outside Parsoid.
227                \DOMDocument::class, \DOMElement::class
228            ),
229            $node, '$node' );
230        // @phan-suppress-next-line PhanTypeMismatchArgument Zest is declared to take DOMDocument\DOMElement
231        $result = Zest::getElementsByTagName( $node, $tagName );
232        '@phan-var array<Element> $result'; // @var array<Element> $result
233        return $result;
234    }
235
236    /**
237     * Return the last child of the node that is an Element, or null otherwise.
238     * @param Document|DocumentFragment|Element $node
239     * @return Element|null
240     * @see https://dom.spec.whatwg.org/#dom-parentnode-lastelementchild
241     */
242    public static function getLastElementChild( $node ) {
243        Assert::parameterType(
244            self::or(
245                Document::class, DocumentFragment::class, Element::class,
246                // For compatibility with code which might call this from
247                // outside Parsoid.
248                \DOMDocument::class, \DOMDocumentFragment::class, \DOMElement::class
249            ),
250            $node, '$node' );
251        $lastChild = $node->lastChild;
252        while ( $lastChild && $lastChild->nodeType !== XML_ELEMENT_NODE ) {
253            $lastChild = $lastChild->previousSibling;
254        }
255        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
256        return $lastChild;
257    }
258
259    /**
260     * @param Document|DocumentFragment|Element $node
261     * @param string $selector
262     * @return Element|null
263     * @see https://dom.spec.whatwg.org/#dom-parentnode-queryselector
264     */
265    public static function querySelector( $node, string $selector ) {
266        foreach ( self::querySelectorAll( $node, $selector ) as $el ) {
267            return $el;
268        }
269        return null;
270    }
271
272    /**
273     * @param Document|DocumentFragment|Element $node
274     * @param string $selector
275     * @return (iterable<Element>&\Countable)|array<Element> Either a NodeList or an array
276     * @see https://dom.spec.whatwg.org/#dom-parentnode-queryselectorall
277     * @note Note that unlike the spec this method is not guaranteed to return a NodeList
278     *   (which cannot be freely constructed in PHP), just a traversable containing Elements.
279     */
280    public static function querySelectorAll( $node, string $selector ): iterable {
281        Assert::parameterType(
282            self::or(
283                Document::class, DocumentFragment::class, Element::class,
284                // For compatibility with code which might call this from
285                // outside Parsoid.
286                \DOMDocument::class, \DOMDocumentFragment::class, \DOMElement::class
287            ),
288            $node, '$node' );
289        // @phan-suppress-next-line PhanTypeMismatchArgument DOMNode
290        return Zest::find( $selector, $node );
291    }
292
293    /**
294     * Return the last preceding sibling of the node that is an element, or null otherwise.
295     * @param Node $node
296     * @return Element|null
297     * @see https://dom.spec.whatwg.org/#dom-nondocumenttypechildnode-previouselementsibling
298     */
299    public static function getPreviousElementSibling( $node ) {
300        Assert::parameterType(
301            self::or(
302                Element::class, CharacterData::class,
303                // For compatibility with code which might call this from
304                // outside Parsoid.
305                \DOMElement::class, \DOMCharacterData::class
306            ),
307            $node, '$node' );
308        $previousSibling = $node->previousSibling;
309        while ( $previousSibling && $previousSibling->nodeType !== XML_ELEMENT_NODE ) {
310            $previousSibling = $previousSibling->previousSibling;
311        }
312        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
313        return $previousSibling;
314    }
315
316    /**
317     * Return the first following sibling of the node that is an element, or null otherwise.
318     * @param Node $node
319     * @return Element|null
320     * @see https://dom.spec.whatwg.org/#dom-nondocumenttypechildnode-nextelementsibling
321     */
322    public static function getNextElementSibling( $node ) {
323        Assert::parameterType(
324            self::or(
325                Element::class, CharacterData::class,
326                // For compatibility with code which might call this from
327                // outside Parsoid.
328                \DOMElement::class, \DOMCharacterData::class
329            ),
330            $node, '$node' );
331        $nextSibling = $node->nextSibling;
332        while ( $nextSibling && $nextSibling->nodeType !== XML_ELEMENT_NODE ) {
333            $nextSibling = $nextSibling->nextSibling;
334        }
335        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
336        return $nextSibling;
337    }
338
339    /**
340     * Removes the node from the document.
341     * @param Element|CharacterData $node
342     * @see https://dom.spec.whatwg.org/#dom-childnode-remove
343     */
344    public static function remove( $node ): void {
345        Assert::parameterType(
346            self::or(
347                Element::class, CharacterData::class,
348                // For compatibility with code which might call this from
349                // outside Parsoid.
350                \DOMElement::class, \DOMCharacterData::class
351            ),
352            $node, '$node' );
353        if ( $node->parentNode ) {
354            $node->parentNode->removeChild( $node );
355        }
356    }
357
358    /**
359     * Get innerHTML.
360     * @see DOMUtils::getFragmentInnerHTML() for the fragment version
361     * @param Element $element
362     * @return string
363     * @see https://w3c.github.io/DOM-Parsing/#dom-innerhtml-innerhtml
364     */
365    public static function getInnerHTML( $element ): string {
366        return XMLSerializer::serialize( $element, [ 'innerXML' => true ] )['html'];
367    }
368
369    /**
370     * Set innerHTML.
371     * @see https://w3c.github.io/DOM-Parsing/#dom-innerhtml-innerhtml
372     * @see DOMUtils::setFragmentInnerHTML() for the fragment version
373     * @param Element $element
374     * @param string $html
375     */
376    public static function setInnerHTML( $element, string $html ): void {
377        $domBuilder = new class( [
378            'suppressHtmlNamespace' => true,
379        ] ) extends DOMBuilder
380        {
381            /** @inheritDoc */
382            protected function createDocument(
383                string $doctypeName = null,
384                string $public = null,
385                string $system = null
386            ) {
387                // @phan-suppress-next-line PhanTypeMismatchReturn
388                return DOMCompat::newDocument( $doctypeName === 'html' );
389            }
390        };
391        $treeBuilder = new TreeBuilder( $domBuilder );
392        $dispatcher = new Dispatcher( $treeBuilder );
393        $tokenizer = new Tokenizer( $dispatcher, $html, [ 'ignoreErrors' => true ] );
394
395        $tokenizer->execute( [
396            'fragmentNamespace' => HTMLData::NS_HTML,
397            'fragmentName' => self::nodeName( $element ),
398        ] );
399
400        // Empty the element
401        self::replaceChildren( $element );
402
403        $frag = $domBuilder->getFragment();
404        '@phan-var Node $frag'; // @var Node $frag
405        DOMUtils::migrateChildrenBetweenDocs(
406            $frag, $element
407        );
408    }
409
410    /**
411     * Get outerHTML.
412     * @param Element $element
413     * @return string
414     * @see https://w3c.github.io/DOM-Parsing/#dom-element-outerhtml
415     */
416    public static function getOuterHTML( $element ): string {
417        return XMLSerializer::serialize( $element, [ 'addDoctype' => false ] )['html'];
418    }
419
420    /**
421     * Return the value of an element attribute.
422     *
423     * Unlike PHP's version, this is spec-compliant and returns `null` if
424     * the attribute is not present, allowing the caller to distinguish
425     * between "the attribute exists but has the empty string as its value"
426     * and "the attribute does not exist".
427     *
428     * @param Element $element
429     * @param string $attributeName
430     * @return ?string The attribute value, or `null` if the attribute does
431     *   not exist on the element.
432     * @see https://dom.spec.whatwg.org/#dom-element-getattribute
433     */
434    public static function getAttribute( $element, string $attributeName ): ?string {
435        if ( !$element->hasAttribute( $attributeName ) ) {
436            return null;
437        }
438        return $element->getAttribute( $attributeName );
439    }
440
441    /**
442     * Return the class list of this element.
443     * @param Element $node
444     * @return TokenList
445     * @see https://dom.spec.whatwg.org/#dom-element-classlist
446     */
447    public static function getClassList( $node ): TokenList {
448        return new TokenList( $node );
449    }
450
451    /**
452     * @param string $text
453     * @return string
454     * @see https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace
455     */
456    private static function stripAndCollapseASCIIWhitespace( string $text ): string {
457        $ws = self::ASCII_WHITESPACE;
458        return preg_replace( "/[$ws]+/", ' ', trim( $text, $ws ) );
459    }
460
461    /**
462     * @param Element|DocumentFragment $e
463     */
464    private static function stripEmptyTextNodes( $e ): void {
465        $c = $e->firstChild;
466        while ( $c ) {
467            $next = $c->nextSibling;
468            if ( $c instanceof Text ) {
469                if ( $c->nodeValue === '' ) {
470                    $e->removeChild( $c );
471                }
472            } elseif ( $c instanceof Element ) {
473                self::stripEmptyTextNodes( $c );
474            }
475            $c = $next;
476        }
477    }
478
479    /**
480     * @param Element|DocumentFragment $elt root of the DOM tree that
481     *   needs to be normalized
482     */
483    public static function normalize( $elt ): void {
484        $elt->normalize();
485
486        // Now traverse the tree rooted at $elt and remove any stray empty text nodes
487        // Unlike what https://www.w3.org/TR/DOM-Level-2-Core/core.html#ID-normalize says,
488        // the PHP DOM's normalization leaves behind up to 1 empty text node.
489        // See https://bugs.php.net/bug.php?id=78221
490        self::stripEmptyTextNodes( $elt );
491    }
492
493    /**
494     * ParentNode.replaceChildren()
495     * https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/replaceChildren
496     *
497     * @param Document|DocumentFragment|Element $parentNode
498     * @param string|Node ...$nodes
499     */
500    public static function replaceChildren(
501        $parentNode, ...$nodes
502    ): void {
503        Assert::parameterType(
504            self::or(
505                Document::class, DocumentFragment::class, Element::class,
506                // For compatibility with code which might call this from
507                // outside Parsoid.
508                \DOMDocument::class, \DOMDocumentFragment::class, \DOMElement::class
509            ),
510            $parentNode, '$parentNode'
511        );
512        while ( $parentNode->firstChild ) {
513            $parentNode->removeChild( $parentNode->firstChild );
514        }
515        foreach ( $nodes as $node ) {
516            if ( is_string( $node ) ) {
517                $node = $parentNode->ownerDocument->createTextNode( $node );
518            }
519            $parentNode->insertBefore( $node, null );
520        }
521    }
522
523    /**
524     * Join class names together in a form suitable for Assert::parameterType.
525     * @param class-string ...$args
526     * @return string
527     */
528    private static function or( ...$args ) {
529        return implode( '|', $args );
530    }
531}