Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
59.06% |
101 / 171 |
|
58.82% |
20 / 34 |
CRAP | |
0.00% |
0 / 1 |
| DOMCompat | |
59.41% |
101 / 170 |
|
58.82% |
20 / 34 |
712.23 | |
0.00% |
0 / 1 |
| isStandardsMode | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| zestOptions | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
| isUsingDodo | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| isUsing84Dom | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| newDocument | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
| getBody | |
75.00% |
9 / 12 |
|
0.00% |
0 / 1 |
7.77 | |||
| getHead | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
6.73 | |||
| getTitle | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| setTitle | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| getParentElement | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| getElementById | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| setIdAttribute | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getElementsByTagName | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getFirstElementChild | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| getLastElementChild | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| querySelector | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| querySelectorAll | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| getPreviousElementSibling | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| getNextElementSibling | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| append | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| appendChild | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| remove | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| getInnerHTML | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setInnerHTML | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| getOuterHTML | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAttribute | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| attributes | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| getClassList | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| stripAndCollapseASCIIWhitespace | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| stripEmptyTextNodes | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
| normalize | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| replaceChildren | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
| getTemplateElementContent | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| encodeCssId | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
| 1 | <?php |
| 2 | // phpcs:disable Universal.Operators.TypeSeparatorSpacing.UnionTypeSpacesAfter |
| 3 | declare( strict_types = 1 ); |
| 4 | |
| 5 | namespace Wikimedia\Parsoid\Core; |
| 6 | |
| 7 | use DOMCharacterData; |
| 8 | use DOMDocument; |
| 9 | use DOMDocumentFragment; |
| 10 | use DOMElement; |
| 11 | use Wikimedia\Assert\Assert; |
| 12 | use Wikimedia\Parsoid\Core\DOMCompatTokenList as TokenList; |
| 13 | use Wikimedia\Parsoid\DOM\CharacterData; |
| 14 | use Wikimedia\Parsoid\DOM\Document; |
| 15 | use Wikimedia\Parsoid\DOM\DocumentFragment; |
| 16 | use Wikimedia\Parsoid\DOM\DOMParser; |
| 17 | use Wikimedia\Parsoid\DOM\Element; |
| 18 | use Wikimedia\Parsoid\DOM\HTMLDocument; |
| 19 | use Wikimedia\Parsoid\DOM\Node; |
| 20 | use Wikimedia\Parsoid\DOM\Text; |
| 21 | use Wikimedia\Parsoid\Utils\DOMUtils; |
| 22 | use Wikimedia\Parsoid\Wt2Html\TreeBuilder\ParsoidDOMFragmentBuilder; |
| 23 | use Wikimedia\Parsoid\Wt2Html\XHtmlSerializer; |
| 24 | use Wikimedia\RemexHtml\HTMLData; |
| 25 | use Wikimedia\RemexHtml\Tokenizer\Tokenizer; |
| 26 | use Wikimedia\RemexHtml\TreeBuilder\Dispatcher; |
| 27 | use Wikimedia\RemexHtml\TreeBuilder\TreeBuilder; |
| 28 | use 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 | */ |
| 54 | class 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 |
| 743 | class_alias( DOMCompat::class, 'Wikimedia\\Parsoid\\Utils\\DOMCompat' ); |