| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * HTML API: WP_HTML_Tag_Processor class 4 * 5 * Scans through an HTML document to find specific tags, then 6 * transforms those tags by adding, removing, or updating the 7 * values of the HTML attributes within that tag (opener). 8 * 9 * Does not fully parse HTML or _recurse_ into the HTML structure 10 * Instead this scans linearly through a document and only parses 11 * the HTML tag openers. 12 * 13 * ### Possible future direction for this module 14 * 15 * - Prune the whitespace when removing classes/attributes: e.g. "a b c" -> "c" not " c". 16 * This would increase the size of the changes for some operations but leave more 17 * natural-looking output HTML. 18 * 19 * @package WordPress 20 * @subpackage HTML-API 21 * @since 6.2.0 22 */ 23 24 /** 25 * Core class used to modify attributes in an HTML document for tags matching a query. 26 * 27 * ## Usage 28 * 29 * Use of this class requires three steps: 30 * 31 * 1. Create a new class instance with your input HTML document. 32 * 2. Find the tag(s) you are looking for. 33 * 3. Request changes to the attributes in those tag(s). 34 * 35 * Example: 36 * 37 * $tags = new WP_HTML_Tag_Processor( $html ); 38 * if ( $tags->next_tag( 'option' ) ) { 39 * $tags->set_attribute( 'selected', true ); 40 * } 41 * 42 * ### Finding tags 43 * 44 * The `next_tag()` function moves the internal cursor through 45 * your input HTML document until it finds a tag meeting any of 46 * the supplied restrictions in the optional query argument. If 47 * no argument is provided then it will find the next HTML tag, 48 * regardless of what kind it is. 49 * 50 * If you want to _find whatever the next tag is_: 51 * 52 * $tags->next_tag(); 53 * 54 * | Goal | Query | 55 * |-----------------------------------------------------------|---------------------------------------------------------------------------------| 56 * | Find any tag. | `$tags->next_tag();` | 57 * | Find next image tag. | `$tags->next_tag( array( 'tag_name' => 'img' ) );` | 58 * | Find next image tag (without passing the array). | `$tags->next_tag( 'img' );` | 59 * | Find next tag containing the `fullwidth` CSS class. | `$tags->next_tag( array( 'class_name' => 'fullwidth' ) );` | 60 * | Find next image tag containing the `fullwidth` CSS class. | `$tags->next_tag( array( 'tag_name' => 'img', 'class_name' => 'fullwidth' ) );` | 61 * 62 * If a tag was found meeting your criteria then `next_tag()` 63 * will return `true` and you can proceed to modify it. If it 64 * returns `false`, however, it failed to find the tag and 65 * moved the cursor to the end of the file. 66 * 67 * Once the cursor reaches the end of the file the processor 68 * is done and if you want to reach an earlier tag you will 69 * need to recreate the processor and start over, as it's 70 * unable to back up or move in reverse. 71 * 72 * See the section on bookmarks for an exception to this 73 * no-backing-up rule. 74 * 75 * #### Custom queries 76 * 77 * Sometimes it's necessary to further inspect an HTML tag than 78 * the query syntax here permits. In these cases one may further 79 * inspect the search results using the read-only functions 80 * provided by the processor or external state or variables. 81 * 82 * Example: 83 * 84 * // Paint up to the first five DIV or SPAN tags marked with the "jazzy" style. 85 * $remaining_count = 5; 86 * while ( $remaining_count > 0 && $tags->next_tag() ) { 87 * if ( 88 * ( 'DIV' === $tags->get_tag() || 'SPAN' === $tags->get_tag() ) && 89 * 'jazzy' === $tags->get_attribute( 'data-style' ) 90 * ) { 91 * $tags->add_class( 'theme-style-everest-jazz' ); 92 * $remaining_count--; 93 * } 94 * } 95 * 96 * `get_attribute()` will return `null` if the attribute wasn't present 97 * on the tag when it was called. It may return `""` (the empty string) 98 * in cases where the attribute was present but its value was empty. 99 * For boolean attributes, those whose name is present but no value is 100 * given, it will return `true` (the only way to set `false` for an 101 * attribute is to remove it). 102 * 103 * #### When matching fails 104 * 105 * When `next_tag()` returns `false` it could mean different things: 106 * 107 * - The requested tag wasn't found in the input document. 108 * - The input document ended in the middle of an HTML syntax element. 109 * 110 * When a document ends in the middle of a syntax element it will pause 111 * the processor. This is to make it possible in the future to extend the 112 * input document and proceed - an important requirement for chunked 113 * streaming parsing of a document. 114 * 115 * Example: 116 * 117 * $processor = new WP_HTML_Tag_Processor( 'This <div is="a" partial="token' ); 118 * false === $processor->next_tag(); 119 * 120 * If a special element (see next section) is encountered but no closing tag 121 * is found it will count as an incomplete tag. The parser will pause as if 122 * the opening tag were incomplete. 123 * 124 * Example: 125 * 126 * $processor = new WP_HTML_Tag_Processor( '<style>// there could be more styling to come' ); 127 * false === $processor->next_tag(); 128 * 129 * $processor = new WP_HTML_Tag_Processor( '<style>// this is everything</style><div>' ); 130 * true === $processor->next_tag( 'DIV' ); 131 * 132 * #### Special self-contained elements 133 * 134 * Some HTML elements are handled in a special way; their start and end tags 135 * act like a void tag. These are special because their contents can't contain 136 * HTML markup. Everything inside these elements is handled in a special way 137 * and content that _appears_ like HTML tags inside of them isn't. There can 138 * be no nesting in these elements. 139 * 140 * In the following list, "raw text" means that all of the content in the HTML 141 * until the matching closing tag is treated verbatim without any replacements 142 * and without any parsing. 143 * 144 * - IFRAME allows no content but requires a closing tag. 145 * - NOEMBED (deprecated) content is raw text. 146 * - NOFRAMES (deprecated) content is raw text. 147 * - SCRIPT content is plaintext apart from legacy rules allowing `</script>` inside an HTML comment. 148 * - STYLE content is raw text. 149 * - TITLE content is plain text but character references are decoded. 150 * - TEXTAREA content is plain text but character references are decoded. 151 * - XMP (deprecated) content is raw text. 152 * 153 * ### Modifying HTML attributes for a found tag 154 * 155 * Once you've found the start of an opening tag you can modify 156 * any number of the attributes on that tag. You can set a new 157 * value for an attribute, remove the entire attribute, or do 158 * nothing and move on to the next opening tag. 159 * 160 * Example: 161 * 162 * if ( $tags->next_tag( array( 'class_name' => 'wp-group-block' ) ) ) { 163 * $tags->set_attribute( 'title', 'This groups the contained content.' ); 164 * $tags->remove_attribute( 'data-test-id' ); 165 * } 166 * 167 * If `set_attribute()` is called for an existing attribute it will 168 * overwrite the existing value. Similarly, calling `remove_attribute()` 169 * for a non-existing attribute has no effect on the document. Both 170 * of these methods are safe to call without knowing if a given attribute 171 * exists beforehand. 172 * 173 * ### Modifying CSS classes for a found tag 174 * 175 * The tag processor treats the `class` attribute as a special case. 176 * Because it's a common operation to add or remove CSS classes, this 177 * interface adds helper methods to make that easier. 178 * 179 * As with attribute values, adding or removing CSS classes is a safe 180 * operation that doesn't require checking if the attribute or class 181 * exists before making changes. If removing the only class then the 182 * entire `class` attribute will be removed. 183 * 184 * Example: 185 * 186 * // from `<span>Yippee!</span>` 187 * // to `<span class="is-active">Yippee!</span>` 188 * $tags->add_class( 'is-active' ); 189 * 190 * // from `<span class="excited">Yippee!</span>` 191 * // to `<span class="excited is-active">Yippee!</span>` 192 * $tags->add_class( 'is-active' ); 193 * 194 * // from `<span class="is-active heavy-accent">Yippee!</span>` 195 * // to `<span class="is-active heavy-accent">Yippee!</span>` 196 * $tags->add_class( 'is-active' ); 197 * 198 * // from `<input type="text" class="is-active rugby not-disabled" length="24">` 199 * // to `<input type="text" class="is-active not-disabled" length="24"> 200 * $tags->remove_class( 'rugby' ); 201 * 202 * // from `<input type="text" class="rugby" length="24">` 203 * // to `<input type="text" length="24"> 204 * $tags->remove_class( 'rugby' ); 205 * 206 * // from `<input type="text" length="24">` 207 * // to `<input type="text" length="24"> 208 * $tags->remove_class( 'rugby' ); 209 * 210 * When class changes are enqueued but a direct change to `class` is made via 211 * `set_attribute` then the changes to `set_attribute` (or `remove_attribute`) 212 * will take precedence over those made through `add_class` and `remove_class`. 213 * 214 * ### Bookmarks 215 * 216 * While scanning through the input HTML document it's possible to set 217 * a named bookmark when a particular tag is found. Later on, after 218 * continuing to scan other tags, it's possible to `seek` to one of 219 * the set bookmarks and then proceed again from that point forward. 220 * 221 * Because bookmarks create processing overhead one should avoid 222 * creating too many of them. As a rule, create only bookmarks 223 * of known string literal names; avoid creating "mark_{$index}" 224 * and so on. It's fine from a performance standpoint to create a 225 * bookmark and update it frequently, such as within a loop. 226 * 227 * $total_todos = 0; 228 * while ( $p->next_tag( array( 'tag_name' => 'UL', 'class_name' => 'todo' ) ) ) { 229 * $p->set_bookmark( 'list-start' ); 230 * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { 231 * if ( 'UL' === $p->get_tag() && $p->is_tag_closer() ) { 232 * $p->set_bookmark( 'list-end' ); 233 * $p->seek( 'list-start' ); 234 * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); 235 * $total_todos = 0; 236 * $p->seek( 'list-end' ); 237 * break; 238 * } 239 * 240 * if ( 'LI' === $p->get_tag() && ! $p->is_tag_closer() ) { 241 * $total_todos++; 242 * } 243 * } 244 * } 245 * 246 * ## Tokens and finer-grained processing. 247 * 248 * It's possible to scan through every lexical token in the 249 * HTML document using the `next_token()` function. This 250 * alternative form takes no argument and provides no built-in 251 * query syntax. 252 * 253 * Example: 254 * 255 * $title = '(untitled)'; 256 * $text = ''; 257 * while ( $processor->next_token() ) { 258 * switch ( $processor->get_token_name() ) { 259 * case '#text': 260 * $text .= $processor->get_modifiable_text(); 261 * break; 262 * 263 * case 'BR': 264 * $text .= "\n"; 265 * break; 266 * 267 * case 'TITLE': 268 * $title = $processor->get_modifiable_text(); 269 * break; 270 * } 271 * } 272 * return trim( "# {$title}\n\n{$text}" ); 273 * 274 * ### Tokens and _modifiable text_. 275 * 276 * #### Special "atomic" HTML elements. 277 * 278 * Not all HTML elements are able to contain other elements inside of them. 279 * For instance, the contents inside a TITLE element are plaintext (except 280 * that character references like & will be decoded). This means that 281 * if the string `<img>` appears inside a TITLE element, then it's not an 282 * image tag, but rather it's text describing an image tag. Likewise, the 283 * contents of a SCRIPT or STYLE element are handled entirely separately in 284 * a browser than the contents of other elements because they represent a 285 * different language than HTML. 286 * 287 * For these elements the Tag Processor treats the entire sequence as one, 288 * from the opening tag, including its contents, through its closing tag. 289 * This means that it's not possible to match the closing tag for a 290 * SCRIPT element unless it's unexpected; the Tag Processor already matched 291 * it when it found the opening tag. 292 * 293 * The inner contents of these elements are that element's _modifiable text_. 294 * 295 * The special elements are: 296 * - `SCRIPT` whose contents are treated as raw plaintext but supports a legacy 297 * style of including JavaScript inside of HTML comments to avoid accidentally 298 * closing the SCRIPT from inside a JavaScript string. E.g. `console.log( '</script>' )`. 299 * - `TITLE` and `TEXTAREA` whose contents are treated as plaintext and then any 300 * character references are decoded. E.g. `1 < 2 < 3` becomes `1 < 2 < 3`. 301 * - `IFRAME`, `NOEMBED`, `NOFRAMES`, `STYLE`, `XMP` whose contents are treated as 302 * raw plaintext and left as-is. E.g. `1 < 2 < 3` remains `1 < 2 < 3`. 303 * 304 * #### Other tokens with modifiable text. 305 * 306 * There are also non-elements which are void/self-closing in nature and contain 307 * modifiable text that is part of that individual syntax token itself. 308 * 309 * - `#text` nodes, whose entire token _is_ the modifiable text. 310 * - HTML comments and tokens that become comments due to some syntax error. The 311 * text for these tokens is the portion of the comment inside of the syntax. 312 * E.g. for `<!-- comment -->` the text is `" comment "` (note the spaces are included). 313 * - `CDATA` sections, whose text is the content inside of the section itself. E.g. for 314 * `<![CDATA[some content]]>` the text is `"some content"` (with restrictions [1]). 315 * - "Funky comments," which are a special case of invalid closing tags whose name is 316 * invalid. The text for these nodes is the text that a browser would transform into 317 * an HTML comment when parsing. E.g. for `</%post_author>` the text is `%post_author`. 318 * - `DOCTYPE` declarations like `<DOCTYPE html>` which have no closing tag. 319 * - Processing instruction nodes like `<?wp __( "Like" ); ?>` (with restrictions [2]). 320 * - The empty end tag `</>` which is ignored in the browser and DOM. 321 * 322 * [1]: There are no CDATA sections in HTML. When encountering `<![CDATA[`, everything 323 * until the next `>` becomes a bogus HTML comment, meaning there can be no CDATA 324 * section in an HTML document containing `>`. The Tag Processor will first find 325 * all valid and bogus HTML comments, and then if the comment _would_ have been a 326 * CDATA section _were they to exist_, it will indicate this as the type of comment. 327 * 328 * [2]: HTML recognizes processing instructions whose target starts with an ASCII letter 329 * or `_` and continues with ASCII alphanumerics, `-`, or `_`. The reserved `xml` 330 * and `xml-stylesheet` targets, as well as XML-valid targets with characters 331 * outside this set, transform into bogus comments in the DOM instead. Processing 332 * instructions exhibit the same constraint as CDATA sections, in that `>` cannot 333 * exist within the token since the processing instruction ends at the first `>`. 334 * 335 * ## Design and limitations 336 * 337 * The Tag Processor is designed to linearly scan HTML documents and tokenize 338 * HTML tags and their attributes. It's designed to do this as efficiently as 339 * possible without compromising parsing integrity. Therefore it will be 340 * slower than some methods of modifying HTML, such as those incorporating 341 * over-simplified PCRE patterns, but will not introduce the defects and 342 * failures that those methods bring in, which lead to broken page renders 343 * and often to security vulnerabilities. On the other hand, it will be faster 344 * than full-blown HTML parsers such as DOMDocument and use considerably 345 * less memory. It requires a negligible memory overhead, enough to consider 346 * it a zero-overhead system. 347 * 348 * The performance characteristics are maintained by avoiding tree construction 349 * and semantic cleanups which are specified in HTML5. Because of this, for 350 * example, it's not possible for the Tag Processor to associate any given 351 * opening tag with its corresponding closing tag, or to return the inner markup 352 * inside an element. Systems may be built on top of the Tag Processor to do 353 * this, but the Tag Processor is and should be constrained so it can remain an 354 * efficient, low-level, and reliable HTML scanner. 355 * 356 * The Tag Processor's design incorporates a "garbage-in-garbage-out" philosophy. 357 * HTML5 specifies that certain invalid content be transformed into different forms 358 * for display, such as removing null bytes from an input document and replacing 359 * invalid characters with the Unicode replacement character `U+FFFD` (visually "�"). 360 * Where errors or transformations exist within the HTML5 specification, the Tag Processor 361 * leaves those invalid inputs untouched, passing them through to the final browser 362 * to handle. While this implies that certain operations will be non-spec-compliant, 363 * such as reading the value of an attribute with invalid content, it also preserves a 364 * simplicity and efficiency for handling those error cases. 365 * 366 * Most operations within the Tag Processor are designed to minimize the difference 367 * between an input and output document for any given change. For example, the 368 * `add_class` and `remove_class` methods preserve whitespace and the class ordering 369 * within the `class` attribute; and when encountering tags with duplicated attributes, 370 * the Tag Processor will leave those invalid duplicate attributes where they are but 371 * update the proper attribute which the browser will read for parsing its value. An 372 * exception to this rule is that all attribute updates store their values as 373 * double-quoted strings, meaning that attributes on input with single-quoted or 374 * unquoted values will appear in the output with double-quotes. 375 * 376 * ### Scripting Flag 377 * 378 * The Tag Processor parses HTML with the "scripting flag" disabled. This means 379 * that it doesn't run any scripts while parsing the page. In a browser with 380 * JavaScript enabled, for example, the script can change the parse of the 381 * document as it loads. On the server, however, evaluating JavaScript is not 382 * only impractical, but also unwanted. 383 * 384 * Practically this means that the Tag Processor will descend into NOSCRIPT 385 * elements and process its child tags. Were the scripting flag enabled, such 386 * as in a typical browser, the contents of NOSCRIPT are skipped entirely. 387 * 388 * This allows the HTML API to process the content that will be presented in 389 * a browser when scripting is disabled, but it offers a different view of a 390 * page than most browser sessions will experience. E.g. the tags inside the 391 * NOSCRIPT disappear. 392 * 393 * ### Text Encoding 394 * 395 * The Tag Processor assumes that the input HTML document is encoded with a 396 * text encoding compatible with 7-bit ASCII's '<', '>', '&', ';', '/', '=', 397 * "'", '"', 'a' - 'z', 'A' - 'Z', and the whitespace characters ' ', tab, 398 * carriage-return, newline, and form-feed. 399 * 400 * In practice, this includes almost every single-byte encoding as well as 401 * UTF-8. Notably, however, it does not include UTF-16. If providing input 402 * that's incompatible, then convert the encoding beforehand. 403 * 404 * @since 6.2.0 405 * @since 6.2.1 Fix: Support for various invalid comments; attribute updates are case-insensitive. 406 * @since 6.3.2 Fix: Skip HTML-like content inside rawtext elements such as STYLE. 407 * @since 6.5.0 Pauses processor when input ends in an incomplete syntax token. 408 * Introduces "special" elements which act like void elements, e.g. TITLE, STYLE. 409 * Allows scanning through all tokens and processing modifiable text, where applicable. 410 */ 411 class WP_HTML_Tag_Processor { 412 /** 413 * The maximum number of bookmarks allowed to exist at 414 * any given time. 415 * 416 * @since 6.2.0 417 * @var int 418 * 419 * @see WP_HTML_Tag_Processor::set_bookmark() 420 */ 421 const MAX_BOOKMARKS = 10; 422 423 /** 424 * Maximum number of times seek() can be called. 425 * Prevents accidental infinite loops. 426 * 427 * @since 6.2.0 428 * @var int 429 * 430 * @see WP_HTML_Tag_Processor::seek() 431 */ 432 const MAX_SEEK_OPS = 1000; 433 434 /** 435 * The HTML document to parse. 436 * 437 * @since 6.2.0 438 * @var string 439 */ 440 protected $html; 441 442 /** 443 * The last query passed to next_tag(). 444 * 445 * @since 6.2.0 446 * @var array|null 447 */ 448 private $last_query; 449 450 /** 451 * The tag name this processor currently scans for. 452 * 453 * @since 6.2.0 454 * @var string|null 455 */ 456 private $sought_tag_name; 457 458 /** 459 * The CSS class name this processor currently scans for. 460 * 461 * @since 6.2.0 462 * @var string|null 463 */ 464 private $sought_class_name; 465 466 /** 467 * The match offset this processor currently scans for. 468 * 469 * @since 6.2.0 470 * @var int|null 471 */ 472 private $sought_match_offset; 473 474 /** 475 * Whether to visit tag closers, e.g. </div>, when walking an input document. 476 * 477 * @since 6.2.0 478 * @var bool 479 */ 480 private $stop_on_tag_closers; 481 482 /** 483 * Specifies mode of operation of the parser at any given time. 484 * 485 * | State | Meaning | 486 * |--------------------------|----------------------------------------------------------------------| 487 * | *Ready* | The parser is ready to run. | 488 * | *Complete* | There is nothing left to parse. | 489 * | *Incomplete* | The HTML ended in the middle of a token; nothing more can be parsed. | 490 * | *Matched tag* | Found an HTML tag; it's possible to modify its attributes. | 491 * | *Text node* | Found a #text node; this is plaintext and modifiable. | 492 * | *CDATA node* | Found a CDATA section; this is modifiable. | 493 * | *Comment* | Found a comment or bogus comment; this is modifiable. | 494 * | *Presumptuous* | Found an empty tag closer: `</>`. | 495 * | *Funky comment* | Found a tag closer with an invalid tag name; this is modifiable. | 496 * | *Processing instruction* | Found a processing instruction, e.g. `<?pi-target data>`. | 497 * 498 * @since 6.5.0 499 * 500 * @see WP_HTML_Tag_Processor::STATE_READY 501 * @see WP_HTML_Tag_Processor::STATE_COMPLETE 502 * @see WP_HTML_Tag_Processor::STATE_INCOMPLETE_INPUT 503 * @see WP_HTML_Tag_Processor::STATE_MATCHED_TAG 504 * @see WP_HTML_Tag_Processor::STATE_TEXT_NODE 505 * @see WP_HTML_Tag_Processor::STATE_CDATA_NODE 506 * @see WP_HTML_Tag_Processor::STATE_COMMENT 507 * @see WP_HTML_Tag_Processor::STATE_DOCTYPE 508 * @see WP_HTML_Tag_Processor::STATE_PRESUMPTUOUS_TAG 509 * @see WP_HTML_Tag_Processor::STATE_FUNKY_COMMENT 510 * @see WP_HTML_Tag_Processor::STATE_PROCESSING_INSTRUCTION 511 * 512 * @var string 513 */ 514 protected $parser_state = self::STATE_READY; 515 516 /** 517 * Indicates if the document is in quirks mode or no-quirks mode. 518 * 519 * Impact on HTML parsing: 520 * 521 * - In `NO_QUIRKS_MODE` (also known as "standard mode"): 522 * - CSS class and ID selectors match byte-for-byte (case-sensitively). 523 * - A TABLE start tag `<table>` implicitly closes any open `P` element. 524 * 525 * - In `QUIRKS_MODE`: 526 * - CSS class and ID selectors match in an ASCII case-insensitive manner. 527 * - A TABLE start tag `<table>` opens a `TABLE` element as a child of a `P` 528 * element if one is open. 529 * 530 * Quirks and no-quirks mode are thus mostly about styling, but have an impact when 531 * tables are found inside paragraph elements. 532 * 533 * @see self::QUIRKS_MODE 534 * @see self::NO_QUIRKS_MODE 535 * 536 * @since 6.7.0 537 * 538 * @var string 539 */ 540 protected $compat_mode = self::NO_QUIRKS_MODE; 541 542 /** 543 * Indicates whether the parser is inside foreign content, 544 * e.g. inside an SVG or MathML element. 545 * 546 * One of 'html', 'svg', or 'math'. 547 * 548 * Several parsing rules change based on whether the parser 549 * is inside foreign content, including whether CDATA sections 550 * are allowed and whether a self-closing flag indicates that 551 * an element has no content. 552 * 553 * @since 6.7.0 554 * 555 * @var string 556 */ 557 private $parsing_namespace = 'html'; 558 559 /** 560 * What kind of syntax token became an HTML comment. 561 * 562 * Since there are many ways in which HTML syntax can create an HTML comment, 563 * this indicates which of those caused it. This allows the Tag Processor to 564 * represent more from the original input document than would appear in the DOM. 565 * 566 * @since 6.5.0 567 * 568 * @var string|null 569 */ 570 protected $comment_type = null; 571 572 /** 573 * What kind of text the matched text node represents, if it was subdivided. 574 * 575 * @see self::TEXT_IS_NULL_SEQUENCE 576 * @see self::TEXT_IS_WHITESPACE 577 * @see self::TEXT_IS_GENERIC 578 * @see self::subdivide_text_appropriately 579 * 580 * @since 6.7.0 581 * 582 * @var string 583 */ 584 protected $text_node_classification = self::TEXT_IS_GENERIC; 585 586 /** 587 * How many bytes from the original HTML document have been read and parsed. 588 * 589 * This value points to the latest byte offset in the input document which 590 * has been already parsed. It is the internal cursor for the Tag Processor 591 * and updates while scanning through the HTML tokens. 592 * 593 * @since 6.2.0 594 * @var int 595 */ 596 private $bytes_already_parsed = 0; 597 598 /** 599 * Byte offset in input document where current token starts. 600 * 601 * Example: 602 * 603 * <div id="test">... 604 * 01234 605 * - token starts at 0 606 * 607 * @since 6.5.0 608 * 609 * @var int|null 610 */ 611 private $token_starts_at; 612 613 /** 614 * Byte length of current token. 615 * 616 * Example: 617 * 618 * <div id="test">... 619 * 0123456789012345 620 * - token length is 15 - 0 = 15 621 * 622 * a <!-- comment --> is a token. 623 * 0123456789 123456789 123456789 624 * - token length is 18 - 2 = 16 625 * 626 * @since 6.5.0 627 * 628 * @var int|null 629 */ 630 private $token_length; 631 632 /** 633 * Whether the current tag token has the self-closing flag. 634 * 635 * @since 7.1.0 636 * 637 * @var bool 638 */ 639 private $has_self_closing_flag = false; 640 641 /** 642 * Byte offset in input document where current tag name starts. 643 * 644 * Example: 645 * 646 * <div id="test">... 647 * 01234 648 * - tag name starts at 1 649 * 650 * @since 6.2.0 651 * 652 * @var int|null 653 */ 654 private $tag_name_starts_at; 655 656 /** 657 * Byte length of current tag name. 658 * 659 * Example: 660 * 661 * <div id="test">... 662 * 01234 663 * --- tag name length is 3 664 * 665 * @since 6.2.0 666 * 667 * @var int|null 668 */ 669 private $tag_name_length; 670 671 /** 672 * Byte offset into input document where current modifiable text starts. 673 * 674 * @since 6.5.0 675 * 676 * @var int 677 */ 678 private $text_starts_at; 679 680 /** 681 * Byte length of modifiable text. 682 * 683 * @since 6.5.0 684 * 685 * @var int 686 */ 687 private $text_length; 688 689 /** 690 * Whether the current tag is an opening tag, e.g. <div>, or a closing tag, e.g. </div>. 691 * 692 * @var bool 693 */ 694 private $is_closing_tag; 695 696 /** 697 * Lazily-built index of attributes found within an HTML tag, keyed by the attribute name. 698 * 699 * Example: 700 * 701 * // Supposing the parser is working through this content 702 * // and stops after recognizing the `id` attribute. 703 * // <div id="test-4" class=outline title="data:text/plain;base64=asdk3nk1j3fo8"> 704 * // ^ parsing will continue from this point. 705 * $this->attributes = array( 706 * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ) 707 * ); 708 * 709 * // When picking up parsing again, or when asking to find the 710 * // `class` attribute we will continue and add to this array. 711 * $this->attributes = array( 712 * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ), 713 * 'class' => new WP_HTML_Attribute_Token( 'class', 23, 7, 17, 13, false ) 714 * ); 715 * 716 * // Note that only the `class` attribute value is stored in the index. 717 * // That's because it is the only value used by this class at the moment. 718 * 719 * @since 6.2.0 720 * @var WP_HTML_Attribute_Token[] 721 * @phpstan-var array<non-empty-string, WP_HTML_Attribute_Token> 722 */ 723 private $attributes = array(); 724 725 /** 726 * Tracks spans of duplicate attributes on a given tag, used for removing 727 * all copies of an attribute when calling `remove_attribute()`. 728 * 729 * @since 6.3.2 730 * 731 * @var (WP_HTML_Span[])[]|null 732 */ 733 private $duplicate_attributes = null; 734 735 /** 736 * Which class names to add or remove from a tag. 737 * 738 * These are tracked separately from attribute updates because they are 739 * semantically distinct, whereas this interface exists for the common 740 * case of adding and removing class names while other attributes are 741 * generally modified as with DOM `setAttribute` calls. 742 * 743 * When modifying an HTML document these will eventually be collapsed 744 * into a single `set_attribute( 'class', $changes )` call. 745 * 746 * Example: 747 * 748 * // Add the `wp-block-group` class, remove the `wp-group` class. 749 * $classname_updates = array( 750 * // Indexed by a comparable class name. 751 * 'wp-block-group' => WP_HTML_Tag_Processor::ADD_CLASS, 752 * 'wp-group' => WP_HTML_Tag_Processor::REMOVE_CLASS 753 * ); 754 * 755 * @since 6.2.0 756 * @var array<non-empty-string, self::ADD_CLASS|self::REMOVE_CLASS> 757 */ 758 private $classname_updates = array(); 759 760 /** 761 * Tracks a semantic location in the original HTML which 762 * shifts with updates as they are applied to the document. 763 * 764 * @since 6.2.0 765 * @var WP_HTML_Span[] 766 */ 767 protected $bookmarks = array(); 768 769 const ADD_CLASS = true; 770 const REMOVE_CLASS = false; 771 const SKIP_CLASS = null; 772 773 /** 774 * Lexical replacements to apply to input HTML document. 775 * 776 * "Lexical" in this class refers to the part of this class which 777 * operates on pure text _as text_ and not as HTML. There's a line 778 * between the public interface, with HTML-semantic methods like 779 * `set_attribute` and `add_class`, and an internal state that tracks 780 * text offsets in the input document. 781 * 782 * When higher-level HTML methods are called, those have to transform their 783 * operations (such as setting an attribute's value) into text diffing 784 * operations (such as replacing the sub-string from indices A to B with 785 * some given new string). These text-diffing operations are the lexical 786 * updates. 787 * 788 * As new higher-level methods are added they need to collapse their 789 * operations into these lower-level lexical updates since that's the 790 * Tag Processor's internal language of change. Any code which creates 791 * these lexical updates must ensure that they do not cross HTML syntax 792 * boundaries, however, so these should never be exposed outside of this 793 * class or any classes which intentionally expand its functionality. 794 * 795 * These are enqueued while editing the document instead of being immediately 796 * applied to avoid processing overhead, string allocations, and string 797 * copies when applying many updates to a single document. 798 * 799 * Example: 800 * 801 * // Replace an attribute stored with a new value, indices 802 * // sourced from the lazily-parsed HTML recognizer. 803 * $start = $attributes['src']->start; 804 * $length = $attributes['src']->length; 805 * $modifications[] = new WP_HTML_Text_Replacement( $start, $length, $new_value ); 806 * 807 * // Correspondingly, something like this will appear in this array. 808 * $lexical_updates = array( 809 * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) 810 * ); 811 * 812 * @since 6.2.0 813 * @var array<int|string, WP_HTML_Text_Replacement> 814 */ 815 protected $lexical_updates = array(); 816 817 /** 818 * Tracks and limits `seek()` calls to prevent accidental infinite loops. 819 * 820 * @since 6.2.0 821 * @var int 822 * 823 * @see WP_HTML_Tag_Processor::seek() 824 */ 825 protected $seek_count = 0; 826 827 /** 828 * Whether the parser should skip over an immediately-following linefeed 829 * character, as is the case with LISTING, PRE, and TEXTAREA. 830 * 831 * > If the next token is a U+000A LINE FEED (LF) character token, then 832 * > ignore that token and move on to the next one. (Newlines at the start 833 * > of [these] elements are ignored as an authoring convenience.) 834 * 835 * @since 6.7.0 836 * 837 * @var int|null 838 */ 839 private $skip_newline_at = null; 840 841 /** 842 * Constructor. 843 * 844 * @since 6.2.0 845 * 846 * @param string $html HTML to process. 847 */ 848 public function __construct( $html ) { 849 if ( ! is_string( $html ) ) { 850 _doing_it_wrong( 851 __METHOD__, 852 __( 'The HTML parameter must be a string.' ), 853 '6.9.0' 854 ); 855 $html = ''; 856 } 857 $this->html = $html; 858 } 859 860 /** 861 * Switches parsing mode into a new namespace, such as when 862 * encountering an SVG tag and entering foreign content. 863 * 864 * @since 6.7.0 865 * 866 * @param string $new_namespace One of 'html', 'svg', or 'math' indicating into what 867 * namespace the next tokens will be processed. 868 * @return bool Whether the namespace was valid and changed. 869 */ 870 public function change_parsing_namespace( string $new_namespace ): bool { 871 if ( ! in_array( $new_namespace, array( 'html', 'math', 'svg' ), true ) ) { 872 return false; 873 } 874 875 $this->parsing_namespace = $new_namespace; 876 return true; 877 } 878 879 /** 880 * Finds the next tag matching the $query. 881 * 882 * @since 6.2.0 883 * @since 6.5.0 No longer processes incomplete tokens at end of document; pauses the processor at start of token. 884 * 885 * @param array|string|null $query { 886 * Optional. Which tag name to find, having which class, etc. Default is to find any tag. 887 * 888 * @type string|null $tag_name Which tag to find, or `null` for "any tag." 889 * @type int|null $match_offset Find the Nth tag matching all search criteria. 890 * 1 for "first" tag, 3 for "third," etc. 891 * Defaults to first tag. 892 * @type string|null $class_name Tag must contain this whole class name to match. 893 * @type string|null $tag_closers "visit" or "skip": whether to stop on tag closers, e.g. </div>. 894 * } 895 * @return bool Whether a tag was matched. 896 * 897 * @phpstan-impure 898 */ 899 public function next_tag( $query = null ): bool { 900 $this->parse_query( $query ); 901 $already_found = 0; 902 903 do { 904 if ( false === $this->next_token() ) { 905 return false; 906 } 907 908 if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { 909 continue; 910 } 911 912 if ( $this->matches() ) { 913 ++$already_found; 914 } 915 } while ( $already_found < $this->sought_match_offset ); 916 917 return true; 918 } 919 920 /** 921 * Finds the next token in the HTML document. 922 * 923 * An HTML document can be viewed as a stream of tokens, 924 * where tokens are things like HTML tags, HTML comments, 925 * text nodes, etc. This method finds the next token in 926 * the HTML document and returns whether it found one. 927 * 928 * If it starts parsing a token and reaches the end of the 929 * document then it will seek to the start of the last 930 * token and pause, returning `false` to indicate that it 931 * failed to find a complete token. 932 * 933 * Possible token types, based on the HTML specification: 934 * 935 * - an HTML tag, whether opening, closing, or void. 936 * - a text node - the plaintext inside tags. 937 * - an HTML comment. 938 * - a DOCTYPE declaration. 939 * - an HTML processing instruction, e.g. `<?pi …data?>`. 940 * 941 * @since 6.5.0 942 * @since 6.7.0 Recognizes CDATA sections within foreign content. 943 * @since 7.1.0 Recognizes processing instructions. 944 * 945 * @return bool Whether a token was parsed. 946 */ 947 public function next_token(): bool { 948 return $this->base_class_next_token(); 949 } 950 951 /** 952 * Internal method which finds the next token in the HTML document. 953 * 954 * This method is a protected internal function which implements the logic for 955 * finding the next token in a document. It exists so that the parser can update 956 * its state without affecting the location of the cursor in the document and 957 * without triggering subclass methods for things like `next_token()`, e.g. when 958 * applying patches before searching for the next token. 959 * 960 * @since 6.5.0 961 * @ignore 962 * 963 * @return bool Whether a token was parsed. 964 */ 965 private function base_class_next_token(): bool { 966 $was_at = $this->bytes_already_parsed; 967 $this->after_tag(); 968 969 // Don't proceed if there's nothing more to scan. 970 if ( 971 self::STATE_COMPLETE === $this->parser_state || 972 self::STATE_INCOMPLETE_INPUT === $this->parser_state 973 ) { 974 return false; 975 } 976 977 /* 978 * The next step in the parsing loop determines the parsing state; 979 * clear it so that state doesn't linger from the previous step. 980 */ 981 $this->parser_state = self::STATE_READY; 982 983 if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { 984 $this->parser_state = self::STATE_COMPLETE; 985 return false; 986 } 987 988 // Find the next tag if it exists. 989 if ( false === $this->parse_next_tag() ) { 990 if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { 991 $this->bytes_already_parsed = $was_at; 992 } 993 994 return false; 995 } 996 997 /* 998 * For legacy reasons the rest of this function handles tags and their 999 * attributes. If the processor has reached the end of the document 1000 * or if it matched any other token then it should return here to avoid 1001 * attempting to process tag-specific syntax. 1002 */ 1003 if ( 1004 self::STATE_INCOMPLETE_INPUT !== $this->parser_state && 1005 self::STATE_COMPLETE !== $this->parser_state && 1006 self::STATE_MATCHED_TAG !== $this->parser_state 1007 ) { 1008 return true; 1009 } 1010 1011 // Parse all of its attributes. 1012 while ( $this->parse_next_attribute() ) { 1013 continue; 1014 } 1015 1016 // Ensure that the tag closes before the end of the document. 1017 if ( 1018 self::STATE_INCOMPLETE_INPUT === $this->parser_state || 1019 $this->bytes_already_parsed >= strlen( $this->html ) 1020 ) { 1021 // Does this appropriately clear state (parsed attributes)? 1022 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1023 $this->bytes_already_parsed = $was_at; 1024 1025 return false; 1026 } 1027 1028 $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); 1029 if ( false === $tag_ends_at ) { 1030 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1031 $this->bytes_already_parsed = $was_at; 1032 1033 return false; 1034 } 1035 $this->parser_state = self::STATE_MATCHED_TAG; 1036 $this->bytes_already_parsed = $tag_ends_at + 1; 1037 $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; 1038 1039 /* 1040 * Certain tags require additional processing. The first-letter pre-check 1041 * avoids unnecessary string allocation when comparing the tag names. 1042 * 1043 * - IFRAME 1044 * - LISTING (deprecated) 1045 * - NOEMBED (deprecated) 1046 * - NOFRAMES (deprecated) 1047 * - PRE 1048 * - SCRIPT 1049 * - STYLE 1050 * - TEXTAREA 1051 * - TITLE 1052 * - XMP (deprecated) 1053 */ 1054 if ( 1055 $this->is_closing_tag || 1056 'html' !== $this->parsing_namespace || 1057 1 !== strspn( $this->html, 'iIlLnNpPsStTxX', $this->tag_name_starts_at, 1 ) 1058 ) { 1059 return true; 1060 } 1061 1062 $tag_name = $this->get_tag(); 1063 1064 /* 1065 * For LISTING, PRE, and TEXTAREA, the first linefeed of an immediately-following 1066 * text node is ignored as an authoring convenience. 1067 * 1068 * @see static::skip_newline_at 1069 */ 1070 if ( 'LISTING' === $tag_name || 'PRE' === $tag_name ) { 1071 $this->skip_newline_at = $this->bytes_already_parsed; 1072 return true; 1073 } 1074 1075 /* 1076 * There are certain elements whose children are not DATA but are instead 1077 * RCDATA or RAWTEXT. These cannot contain other elements, and the contents 1078 * are parsed as plaintext, with character references decoded in RCDATA but 1079 * not in RAWTEXT. 1080 * 1081 * These elements are described here as "self-contained" or special atomic 1082 * elements whose end tag is consumed with the opening tag, and they will 1083 * contain modifiable text inside of them. 1084 * 1085 * Preserve the opening tag pointers, as these will be overwritten 1086 * when finding the closing tag. They will be reset after finding 1087 * the closing tag to point to the opening of the special atomic 1088 * tag sequence. 1089 */ 1090 $tag_name_starts_at = $this->tag_name_starts_at; 1091 $tag_name_length = $this->tag_name_length; 1092 $tag_ends_at = $this->token_starts_at + $this->token_length; 1093 $has_self_closing_flag = $this->has_self_closing_flag; 1094 $attributes = $this->attributes; 1095 $duplicate_attributes = $this->duplicate_attributes; 1096 1097 // Find the closing tag if necessary. 1098 switch ( $tag_name ) { 1099 case 'SCRIPT': 1100 $found_closer = $this->skip_script_data(); 1101 break; 1102 1103 case 'TEXTAREA': 1104 case 'TITLE': 1105 $found_closer = $this->skip_rcdata( $tag_name ); 1106 break; 1107 1108 /* 1109 * In the browser this list would include the NOSCRIPT element, 1110 * but the Tag Processor is an environment with the scripting 1111 * flag disabled, meaning that it needs to descend into the 1112 * NOSCRIPT element to be able to properly process what will be 1113 * sent to a browser. 1114 * 1115 * Note that this rule makes HTML5 syntax incompatible with XML, 1116 * because the parsing of this token depends on client application. 1117 * The NOSCRIPT element cannot be represented in the XHTML syntax. 1118 */ 1119 case 'IFRAME': 1120 case 'NOEMBED': 1121 case 'NOFRAMES': 1122 case 'STYLE': 1123 case 'XMP': 1124 $found_closer = $this->skip_rawtext( $tag_name ); 1125 break; 1126 1127 // No other tags should be treated in their entirety here. 1128 default: 1129 return true; 1130 } 1131 1132 if ( ! $found_closer ) { 1133 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1134 $this->bytes_already_parsed = $was_at; 1135 return false; 1136 } 1137 1138 /* 1139 * The values here look like they reference the opening tag but they reference 1140 * the closing tag instead. This is why the opening tag values were stored 1141 * above in a variable. It reads confusingly here, but that's because the 1142 * functions that skip the contents have moved all the internal cursors past 1143 * the inner content of the tag. 1144 */ 1145 $this->token_starts_at = $was_at; 1146 $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; 1147 $this->text_starts_at = $tag_ends_at; 1148 $this->text_length = $this->tag_name_starts_at - $this->text_starts_at; 1149 $this->tag_name_starts_at = $tag_name_starts_at; 1150 $this->tag_name_length = $tag_name_length; 1151 $this->has_self_closing_flag = $has_self_closing_flag; 1152 $this->attributes = $attributes; 1153 $this->duplicate_attributes = $duplicate_attributes; 1154 1155 return true; 1156 } 1157 1158 /** 1159 * Whether the processor paused because the input HTML document ended 1160 * in the middle of a syntax element, such as in the middle of a tag. 1161 * 1162 * Example: 1163 * 1164 * $processor = new WP_HTML_Tag_Processor( '<input type="text" value="Th' ); 1165 * false === $processor->next_tag(); 1166 * true === $processor->paused_at_incomplete_token(); 1167 * 1168 * @since 6.5.0 1169 * 1170 * @return bool Whether the parse paused at the start of an incomplete token. 1171 */ 1172 public function paused_at_incomplete_token(): bool { 1173 return self::STATE_INCOMPLETE_INPUT === $this->parser_state; 1174 } 1175 1176 /** 1177 * Generator for a foreach loop to step through each class name for the matched tag. 1178 * 1179 * This generator function is designed to be used inside a "foreach" loop. 1180 * 1181 * ```php interactive 1182 * $p = new WP_HTML_Tag_Processor( "<div class='free <egg>\tlang-en'>" ); 1183 * $p->next_tag(); 1184 * foreach ( $p->class_list() as $class_name ) { 1185 * echo "{$class_name} "; 1186 * } 1187 * // Outputs: "free <egg> lang-en " 1188 * ``` 1189 * 1190 * @since 6.4.0 1191 * 1192 * @return Generator<int, non-empty-string> 1193 */ 1194 public function class_list() { 1195 if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { 1196 return; 1197 } 1198 1199 /** @var string $class contains the string value of the class attribute, with character references decoded. */ 1200 $class = $this->get_attribute( 'class' ); 1201 1202 if ( ! is_string( $class ) ) { 1203 return; 1204 } 1205 1206 $seen = array(); 1207 1208 $is_quirks = self::QUIRKS_MODE === $this->compat_mode; 1209 1210 $at = 0; 1211 while ( $at < strlen( $class ) ) { 1212 // Skip past any initial boundary characters. 1213 $at += strspn( $class, " \t\f\r\n", $at ); 1214 if ( $at >= strlen( $class ) ) { 1215 return; 1216 } 1217 1218 // Find the byte length until the next boundary. 1219 $length = strcspn( $class, " \t\f\r\n", $at ); 1220 if ( 0 === $length ) { 1221 return; 1222 } 1223 1224 $name = substr( $class, $at, $length ); 1225 if ( $is_quirks ) { 1226 $name = strtolower( $name ); 1227 } 1228 $at += $length; 1229 1230 /* 1231 * It's expected that the number of class names for a given tag is relatively small. 1232 * Given this, it is probably faster overall to scan an array for a value rather 1233 * than to use the class name as a key and check if it's a key of $seen. 1234 */ 1235 if ( in_array( $name, $seen, true ) ) { 1236 continue; 1237 } 1238 1239 $seen[] = $name; 1240 yield $name; 1241 } 1242 } 1243 1244 1245 /** 1246 * Returns if a matched tag contains the given ASCII case-insensitive class name. 1247 * 1248 * @since 6.4.0 1249 * 1250 * @param string $wanted_class Look for this CSS class name, ASCII case-insensitive. 1251 * @return bool|null Whether the matched tag contains the given class name, or null if not matched. 1252 */ 1253 public function has_class( $wanted_class ): ?bool { 1254 if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { 1255 return null; 1256 } 1257 1258 $case_insensitive = self::QUIRKS_MODE === $this->compat_mode; 1259 1260 $wanted_length = strlen( $wanted_class ); 1261 foreach ( $this->class_list() as $class_name ) { 1262 if ( 1263 strlen( $class_name ) === $wanted_length && 1264 0 === substr_compare( $class_name, $wanted_class, 0, strlen( $wanted_class ), $case_insensitive ) 1265 ) { 1266 return true; 1267 } 1268 } 1269 1270 return false; 1271 } 1272 1273 1274 /** 1275 * Sets a bookmark in the HTML document. 1276 * 1277 * Bookmarks represent specific places or tokens in the HTML 1278 * document, such as a tag opener or closer. When applying 1279 * edits to a document, such as setting an attribute, the 1280 * text offsets of that token may shift; the bookmark is 1281 * kept updated with those shifts and remains stable unless 1282 * the entire span of text in which the token sits is removed. 1283 * 1284 * Release bookmarks when they are no longer needed. 1285 * 1286 * Example: 1287 * 1288 * <main><h2>Surprising fact you may not know!</h2></main> 1289 * ^ ^ 1290 * \-|-- this `H2` opener bookmark tracks the token 1291 * 1292 * <main class="clickbait"><h2>Surprising fact you may no… 1293 * ^ ^ 1294 * \-|-- it shifts with edits 1295 * 1296 * Bookmarks provide the ability to seek to a previously-scanned 1297 * place in the HTML document. This avoids the need to re-scan 1298 * the entire document. 1299 * 1300 * Example: 1301 * 1302 * <ul><li>One</li><li>Two</li><li>Three</li></ul> 1303 * ^^^^ 1304 * want to note this last item 1305 * 1306 * $p = new WP_HTML_Tag_Processor( $html ); 1307 * $in_list = false; 1308 * while ( $p->next_tag( array( 'tag_closers' => $in_list ? 'visit' : 'skip' ) ) ) { 1309 * if ( 'UL' === $p->get_tag() ) { 1310 * if ( $p->is_tag_closer() ) { 1311 * $in_list = false; 1312 * $p->set_bookmark( 'resume' ); 1313 * if ( $p->seek( 'last-li' ) ) { 1314 * $p->add_class( 'last-li' ); 1315 * } 1316 * $p->seek( 'resume' ); 1317 * $p->release_bookmark( 'last-li' ); 1318 * $p->release_bookmark( 'resume' ); 1319 * } else { 1320 * $in_list = true; 1321 * } 1322 * } 1323 * 1324 * if ( 'LI' === $p->get_tag() ) { 1325 * $p->set_bookmark( 'last-li' ); 1326 * } 1327 * } 1328 * 1329 * Bookmarks intentionally hide the internal string offsets 1330 * to which they refer. They are maintained internally as 1331 * updates are applied to the HTML document and therefore 1332 * retain their "position" - the location to which they 1333 * originally pointed. The inability to use bookmarks with 1334 * functions like `substr` is therefore intentional to guard 1335 * against accidentally breaking the HTML. 1336 * 1337 * Because bookmarks allocate memory and require processing 1338 * for every applied update, they are limited and require 1339 * a name. They should not be created with programmatically-made 1340 * names, such as "li_{$index}" with some loop. As a general 1341 * rule they should only be created with string-literal names 1342 * like "start-of-section" or "last-paragraph". 1343 * 1344 * Bookmarks are a powerful tool to enable complicated behavior. 1345 * Consider double-checking that you need this tool if you are 1346 * reaching for it, as inappropriate use could lead to broken 1347 * HTML structure or unwanted processing overhead. 1348 * 1349 * @since 6.2.0 1350 * 1351 * @param string $name Identifies this particular bookmark. 1352 * @return bool Whether the bookmark was successfully created. 1353 */ 1354 public function set_bookmark( $name ): bool { 1355 // It only makes sense to set a bookmark if the parser has paused on a concrete token. 1356 if ( 1357 self::STATE_COMPLETE === $this->parser_state || 1358 self::STATE_INCOMPLETE_INPUT === $this->parser_state 1359 ) { 1360 return false; 1361 } 1362 1363 if ( ! array_key_exists( $name, $this->bookmarks ) && count( $this->bookmarks ) >= static::MAX_BOOKMARKS ) { 1364 _doing_it_wrong( 1365 __METHOD__, 1366 __( 'Too many bookmarks: cannot create any more.' ), 1367 '6.2.0' 1368 ); 1369 return false; 1370 } 1371 1372 $this->bookmarks[ $name ] = new WP_HTML_Span( $this->token_starts_at, $this->token_length ); 1373 1374 return true; 1375 } 1376 1377 1378 /** 1379 * Removes a bookmark that is no longer needed. 1380 * 1381 * Releasing a bookmark frees up the small 1382 * performance overhead it requires. 1383 * 1384 * @param string $name Name of the bookmark to remove. 1385 * @return bool Whether the bookmark already existed before removal. 1386 */ 1387 public function release_bookmark( $name ): bool { 1388 if ( ! array_key_exists( $name, $this->bookmarks ) ) { 1389 return false; 1390 } 1391 1392 unset( $this->bookmarks[ $name ] ); 1393 1394 return true; 1395 } 1396 1397 /** 1398 * Skips contents of generic rawtext elements. 1399 * 1400 * @since 6.3.2 1401 * @ignore 1402 * 1403 * @see https://html.spec.whatwg.org/#generic-raw-text-element-parsing-algorithm 1404 * 1405 * @param string $tag_name The uppercase tag name which will close the RAWTEXT region. 1406 * @return bool Whether an end to the RAWTEXT region was found before the end of the document. 1407 */ 1408 private function skip_rawtext( string $tag_name ): bool { 1409 /* 1410 * These two functions distinguish themselves on whether character references are 1411 * decoded, and since functionality to read the inner markup isn't supported, it's 1412 * not necessary to implement these two functions separately. 1413 */ 1414 return $this->skip_rcdata( $tag_name ); 1415 } 1416 1417 /** 1418 * Skips contents of RCDATA elements, namely title and textarea tags. 1419 * 1420 * @since 6.2.0 1421 * @ignore 1422 * 1423 * @see https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state 1424 * 1425 * @param string $tag_name The uppercase tag name which will close the RCDATA region. 1426 * @return bool Whether an end to the RCDATA region was found before the end of the document. 1427 */ 1428 private function skip_rcdata( string $tag_name ): bool { 1429 $html = $this->html; 1430 $doc_length = strlen( $html ); 1431 $tag_length = strlen( $tag_name ); 1432 1433 $at = $this->bytes_already_parsed; 1434 1435 while ( false !== $at && $at < $doc_length ) { 1436 $at = strpos( $this->html, '</', $at ); 1437 $this->tag_name_starts_at = $at; 1438 1439 // Fail if there is no possible tag closer. 1440 if ( false === $at || ( $at + 2 + $tag_length ) >= $doc_length ) { 1441 return false; 1442 } 1443 1444 $at += 2; 1445 1446 /* 1447 * Find a case-insensitive match to the tag name. 1448 * 1449 * Because tag names are limited to US-ASCII there is no 1450 * need to perform any kind of Unicode normalization when 1451 * comparing; any character which could be impacted by such 1452 * normalization could not be part of a tag name. 1453 */ 1454 for ( $i = 0; $i < $tag_length; $i++ ) { 1455 $tag_char = $tag_name[ $i ]; 1456 $html_char = $html[ $at + $i ]; 1457 1458 if ( $html_char !== $tag_char && strtoupper( $html_char ) !== $tag_char ) { 1459 $at += $i; 1460 continue 2; 1461 } 1462 } 1463 1464 $at += $tag_length; 1465 $this->bytes_already_parsed = $at; 1466 1467 if ( $at >= strlen( $html ) ) { 1468 return false; 1469 } 1470 1471 /* 1472 * Ensure that the tag name terminates to avoid matching on 1473 * substrings of a longer tag name. For example, the sequence 1474 * "</textarearug" should not match for "</textarea" even 1475 * though "textarea" is found within the text. 1476 */ 1477 $c = $html[ $at ]; 1478 if ( ' ' !== $c && "\t" !== $c && "\f" !== $c && "\r" !== $c && "\n" !== $c && '/' !== $c && '>' !== $c ) { 1479 continue; 1480 } 1481 1482 while ( $this->parse_next_attribute() ) { 1483 continue; 1484 } 1485 1486 $at = $this->bytes_already_parsed; 1487 if ( $at >= strlen( $this->html ) ) { 1488 return false; 1489 } 1490 1491 if ( '>' === $html[ $at ] ) { 1492 $this->bytes_already_parsed = $at + 1; 1493 return true; 1494 } 1495 1496 if ( $at + 1 >= strlen( $this->html ) ) { 1497 return false; 1498 } 1499 1500 if ( '/' === $html[ $at ] && '>' === $html[ $at + 1 ] ) { 1501 $this->bytes_already_parsed = $at + 2; 1502 return true; 1503 } 1504 } 1505 1506 return false; 1507 } 1508 1509 /** 1510 * Skips contents of script tags. 1511 * 1512 * @since 6.2.0 1513 * @ignore 1514 * 1515 * @return bool Whether the script tag was closed before the end of the document. 1516 */ 1517 private function skip_script_data(): bool { 1518 $state = 'unescaped'; 1519 $html = $this->html; 1520 $doc_length = strlen( $html ); 1521 $at = $this->bytes_already_parsed; 1522 1523 while ( false !== $at && $at < $doc_length ) { 1524 $at += strcspn( $html, '-<', $at ); 1525 1526 /* 1527 * Optimization: Terminating a complete script element requires at least eight 1528 * additional bytes in the document. Some checks below may cause local escaped 1529 * state transitions when processing shorter strings, but those transitions are 1530 * irrelevant if the script tag is incomplete and the function must return false. 1531 * 1532 * This may need updating if those transitions become significant or exported from 1533 * this function in some way, such as when building safe methods to embed JavaScript 1534 * or data inside a SCRIPT element. 1535 * 1536 * $at may be here. 1537 * ↓ 1538 * ...</script> 1539 * ╰──┬───╯ 1540 * $at + 8 additional bytes are required for a non-false return value. 1541 * 1542 * This single check eliminates the need to check lengths for the shorter spans: 1543 * 1544 * $at may be here. 1545 * ↓ 1546 * <script><!-- --></script> 1547 * ├╯ 1548 * $at + 2 additional characters does not require a length check. 1549 * 1550 * The transition from "escaped" to "unescaped" is not relevant if the document ends: 1551 * 1552 * $at may be here. 1553 * ↓ 1554 * <script><!-- -->[[END-OF-DOCUMENT]] 1555 * ╰──┬───╯ 1556 * $at + 8 additional bytes is not satisfied, return false. 1557 */ 1558 if ( $at + 8 >= $doc_length ) { 1559 return false; 1560 } 1561 1562 /* 1563 * For all script states a "-->" transitions 1564 * back into the normal unescaped script mode, 1565 * even if that's the current state. 1566 */ 1567 if ( 1568 '-' === $html[ $at ] && 1569 '-' === $html[ $at + 1 ] && 1570 '>' === $html[ $at + 2 ] 1571 ) { 1572 $at += 3; 1573 $state = 'unescaped'; 1574 continue; 1575 } 1576 1577 /* 1578 * Everything of interest past here starts with "<". 1579 * Check this character and advance position regardless. 1580 */ 1581 if ( '<' !== $html[ $at++ ] ) { 1582 continue; 1583 } 1584 1585 /* 1586 * "<!--" only transitions from _unescaped_ to _escaped_. This byte sequence is only 1587 * significant in the _unescaped_ state and is ignored in any other state. 1588 */ 1589 if ( 1590 'unescaped' === $state && 1591 '!' === $html[ $at ] && 1592 '-' === $html[ $at + 1 ] && 1593 '-' === $html[ $at + 2 ] 1594 ) { 1595 $at += 3; 1596 1597 /* 1598 * The parser is ready to enter the _escaped_ state, but may remain in the 1599 * _unescaped_ state. This occurs when "<!--" is immediately followed by a 1600 * sequence of 0 or more "-" followed by ">". This is similar to abruptly closed 1601 * HTML comments like "<!-->" or "<!--->". 1602 * 1603 * Note that this check may advance the position significantly and requires a 1604 * length check to prevent bad offsets on inputs like `<script><!---------`. 1605 */ 1606 $at += strspn( $html, '-', $at ); 1607 if ( $at < $doc_length && '>' === $html[ $at ] ) { 1608 ++$at; 1609 continue; 1610 } 1611 1612 $state = 'escaped'; 1613 continue; 1614 } 1615 1616 if ( '/' === $html[ $at ] ) { 1617 $closer_potentially_starts_at = $at - 1; 1618 $is_closing = true; 1619 ++$at; 1620 } else { 1621 $is_closing = false; 1622 } 1623 1624 /* 1625 * At this point the only remaining state-changes occur with the 1626 * <script> and </script> tags; unless one of these appears next, 1627 * proceed scanning to the next potential token in the text. 1628 */ 1629 if ( ! ( 1630 ( 's' === $html[ $at ] || 'S' === $html[ $at ] ) && 1631 ( 'c' === $html[ $at + 1 ] || 'C' === $html[ $at + 1 ] ) && 1632 ( 'r' === $html[ $at + 2 ] || 'R' === $html[ $at + 2 ] ) && 1633 ( 'i' === $html[ $at + 3 ] || 'I' === $html[ $at + 3 ] ) && 1634 ( 'p' === $html[ $at + 4 ] || 'P' === $html[ $at + 4 ] ) && 1635 ( 't' === $html[ $at + 5 ] || 'T' === $html[ $at + 5 ] ) 1636 ) ) { 1637 continue; 1638 } 1639 1640 /* 1641 * Ensure that the script tag terminates to avoid matching on 1642 * substrings of a non-match. For example, the sequence 1643 * "<script123" should not end a script region even though 1644 * "<script" is found within the text. 1645 */ 1646 $at += 6; 1647 $c = $html[ $at ]; 1648 if ( 1649 /** 1650 * These characters trigger state transitions of interest: 1651 * 1652 * - @see {https://html.spec.whatwg.org/multipage/parsing.html#script-data-end-tag-name-state} 1653 * - @see {https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-end-tag-name-state} 1654 * - @see {https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escape-start-state} 1655 * - @see {https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escape-end-state} 1656 * 1657 * The "\r" character is not present in the above references. However, "\r" must be 1658 * treated the same as "\n". This is because the HTML Standard requires newline 1659 * normalization during preprocessing which applies this replacement. 1660 * 1661 * - @see https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream 1662 * - @see https://infra.spec.whatwg.org/#normalize-newlines 1663 */ 1664 '>' !== $c && 1665 ' ' !== $c && 1666 "\n" !== $c && 1667 '/' !== $c && 1668 "\t" !== $c && 1669 "\f" !== $c && 1670 "\r" !== $c 1671 ) { 1672 continue; 1673 } 1674 1675 if ( 'escaped' === $state && ! $is_closing ) { 1676 $state = 'double-escaped'; 1677 continue; 1678 } 1679 1680 if ( 'double-escaped' === $state && $is_closing ) { 1681 $state = 'escaped'; 1682 continue; 1683 } 1684 1685 if ( $is_closing ) { 1686 $this->bytes_already_parsed = $closer_potentially_starts_at; 1687 $this->tag_name_starts_at = $closer_potentially_starts_at; 1688 if ( $this->bytes_already_parsed >= $doc_length ) { 1689 return false; 1690 } 1691 1692 while ( $this->parse_next_attribute() ) { 1693 continue; 1694 } 1695 1696 if ( $this->bytes_already_parsed >= $doc_length ) { 1697 return false; 1698 } 1699 1700 if ( '>' === $html[ $this->bytes_already_parsed ] ) { 1701 ++$this->bytes_already_parsed; 1702 return true; 1703 } 1704 } 1705 1706 ++$at; 1707 } 1708 1709 return false; 1710 } 1711 1712 /** 1713 * Parses the next tag. 1714 * 1715 * This will find and start parsing the next tag, including 1716 * the opening `<`, the potential closer `/`, and the tag 1717 * name. It does not parse the attributes or scan to the 1718 * closing `>`; these are left for other methods. 1719 * 1720 * @since 6.2.0 1721 * @since 6.2.1 Support abruptly-closed comments, invalid-tag-closer-comments, and empty elements. 1722 * @ignore 1723 * 1724 * @return bool Whether a tag was found before the end of the document. 1725 * 1726 * @phpstan-impure 1727 */ 1728 private function parse_next_tag(): bool { 1729 $this->after_tag(); 1730 1731 $html = $this->html; 1732 $doc_length = strlen( $html ); 1733 $was_at = $this->bytes_already_parsed; 1734 $at = $was_at; 1735 1736 while ( $at < $doc_length ) { 1737 $at = strpos( $html, '<', $at ); 1738 if ( false === $at ) { 1739 break; 1740 } 1741 1742 if ( $at > $was_at ) { 1743 /* 1744 * A "<" normally starts a new HTML tag or syntax token, but in cases where the 1745 * following character can't produce a valid token, the "<" is instead treated 1746 * as plaintext and the parser should skip over it. This avoids a problem when 1747 * following earlier practices of typing emoji with text, e.g. "<3". This 1748 * should be a heart, not a tag. It's supposed to be rendered, not hidden. 1749 * 1750 * At this point the parser checks if this is one of those cases and if it is 1751 * will continue searching for the next "<" in search of a token boundary. 1752 * 1753 * @see https://html.spec.whatwg.org/#tag-open-state 1754 */ 1755 if ( 1 !== strspn( $html, '!/?abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', $at + 1, 1 ) ) { 1756 ++$at; 1757 continue; 1758 } 1759 1760 $this->parser_state = self::STATE_TEXT_NODE; 1761 $this->token_starts_at = $was_at; 1762 $this->token_length = $at - $was_at; 1763 $this->text_starts_at = $was_at; 1764 $this->text_length = $this->token_length; 1765 $this->bytes_already_parsed = $at; 1766 return true; 1767 } 1768 1769 $this->token_starts_at = $at; 1770 1771 if ( $at + 1 < $doc_length && '/' === $this->html[ $at + 1 ] ) { 1772 $this->is_closing_tag = true; 1773 ++$at; 1774 } else { 1775 $this->is_closing_tag = false; 1776 } 1777 1778 /* 1779 * HTML tag names must start with [a-zA-Z] otherwise they are not tags. 1780 * For example, "<3" is rendered as text, not a tag opener. If at least 1781 * one letter follows the "<" then _it is_ a tag, but if the following 1782 * character is anything else it _is not a tag_. 1783 * 1784 * It's not uncommon to find non-tags starting with `<` in an HTML 1785 * document, so it's good for performance to make this pre-check before 1786 * continuing to attempt to parse a tag name. 1787 * 1788 * Reference: 1789 * * https://html.spec.whatwg.org/multipage/parsing.html#data-state 1790 * * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state 1791 */ 1792 $tag_name_prefix_length = strspn( $html, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', $at + 1 ); 1793 if ( $tag_name_prefix_length > 0 ) { 1794 ++$at; 1795 $this->parser_state = self::STATE_MATCHED_TAG; 1796 $this->tag_name_starts_at = $at; 1797 $this->tag_name_length = $tag_name_prefix_length + strcspn( $html, " \t\f\r\n/>", $at + $tag_name_prefix_length ); 1798 $this->bytes_already_parsed = $at + $this->tag_name_length; 1799 return true; 1800 } 1801 1802 /* 1803 * Abort if no tag is found before the end of 1804 * the document. There is nothing left to parse. 1805 */ 1806 if ( $at + 1 >= $doc_length ) { 1807 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1808 1809 return false; 1810 } 1811 1812 /* 1813 * `<!` transitions to markup declaration open state 1814 * https://html.spec.whatwg.org/multipage/parsing.html#markup-declaration-open-state 1815 */ 1816 if ( ! $this->is_closing_tag && '!' === $html[ $at + 1 ] ) { 1817 /* 1818 * `<!--` transitions to a comment state – apply further comment rules. 1819 * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state 1820 */ 1821 if ( 0 === substr_compare( $html, '--', $at + 2, 2 ) ) { 1822 $closer_at = $at + 4; 1823 // If it's not possible to close the comment then there is nothing more to scan. 1824 if ( $doc_length <= $closer_at ) { 1825 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1826 1827 return false; 1828 } 1829 1830 // Abruptly-closed empty comments are a sequence of dashes followed by `>`. 1831 $span_of_dashes = strspn( $html, '-', $closer_at ); 1832 if ( $doc_length <= $span_of_dashes + $closer_at ) { 1833 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1834 1835 return false; 1836 } 1837 1838 if ( '>' === $html[ $closer_at + $span_of_dashes ] ) { 1839 /* 1840 * @todo When implementing `set_modifiable_text()` ensure that updates to this token 1841 * don't break the syntax for short comments, e.g. `<!--->`. Unlike other comment 1842 * and bogus comment syntax, these leave no clear insertion point for text and 1843 * they need to be modified specially in order to contain text. E.g. to store 1844 * `?` as the modifiable text, the `<!--->` needs to become `<!--?-->`, which 1845 * involves inserting an additional `-` into the token after the modifiable text. 1846 */ 1847 $this->parser_state = self::STATE_COMMENT; 1848 $this->comment_type = self::COMMENT_AS_ABRUPTLY_CLOSED_COMMENT; 1849 $this->token_length = $closer_at + $span_of_dashes + 1 - $this->token_starts_at; 1850 1851 // Only provide modifiable text if the token is long enough to contain it. 1852 if ( $span_of_dashes >= 2 ) { 1853 $this->comment_type = self::COMMENT_AS_HTML_COMMENT; 1854 $this->text_starts_at = $this->token_starts_at + 4; 1855 $this->text_length = $span_of_dashes - 2; 1856 } 1857 1858 $this->bytes_already_parsed = $closer_at + $span_of_dashes + 1; 1859 return true; 1860 } 1861 1862 /* 1863 * Comments may be closed by either a --> or an invalid --!>. 1864 * The first occurrence closes the comment. 1865 * 1866 * See https://html.spec.whatwg.org/#parse-error-incorrectly-closed-comment 1867 */ 1868 --$closer_at; // Pre-increment inside condition below reduces risk of accidental infinite looping. 1869 while ( ++$closer_at < $doc_length ) { 1870 $closer_at = strpos( $html, '--', $closer_at ); 1871 if ( false === $closer_at ) { 1872 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1873 1874 return false; 1875 } 1876 1877 if ( $closer_at + 2 < $doc_length && '>' === $html[ $closer_at + 2 ] ) { 1878 $this->parser_state = self::STATE_COMMENT; 1879 $this->comment_type = self::COMMENT_AS_HTML_COMMENT; 1880 $this->token_length = $closer_at + 3 - $this->token_starts_at; 1881 $this->text_starts_at = $this->token_starts_at + 4; 1882 $this->text_length = $closer_at - $this->text_starts_at; 1883 $this->bytes_already_parsed = $closer_at + 3; 1884 return true; 1885 } 1886 1887 if ( 1888 $closer_at + 3 < $doc_length && 1889 '!' === $html[ $closer_at + 2 ] && 1890 '>' === $html[ $closer_at + 3 ] 1891 ) { 1892 $this->parser_state = self::STATE_COMMENT; 1893 $this->comment_type = self::COMMENT_AS_HTML_COMMENT; 1894 $this->token_length = $closer_at + 4 - $this->token_starts_at; 1895 $this->text_starts_at = $this->token_starts_at + 4; 1896 $this->text_length = $closer_at - $this->text_starts_at; 1897 $this->bytes_already_parsed = $closer_at + 4; 1898 return true; 1899 } 1900 } 1901 } 1902 1903 /* 1904 * `<!DOCTYPE` transitions to DOCTYPE state – skip to the nearest > 1905 * These are ASCII-case-insensitive. 1906 * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state 1907 */ 1908 if ( 1909 $doc_length > $at + 8 && 1910 ( 'D' === $html[ $at + 2 ] || 'd' === $html[ $at + 2 ] ) && 1911 ( 'O' === $html[ $at + 3 ] || 'o' === $html[ $at + 3 ] ) && 1912 ( 'C' === $html[ $at + 4 ] || 'c' === $html[ $at + 4 ] ) && 1913 ( 'T' === $html[ $at + 5 ] || 't' === $html[ $at + 5 ] ) && 1914 ( 'Y' === $html[ $at + 6 ] || 'y' === $html[ $at + 6 ] ) && 1915 ( 'P' === $html[ $at + 7 ] || 'p' === $html[ $at + 7 ] ) && 1916 ( 'E' === $html[ $at + 8 ] || 'e' === $html[ $at + 8 ] ) 1917 ) { 1918 $closer_at = strpos( $html, '>', $at + 9 ); 1919 if ( false === $closer_at ) { 1920 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1921 1922 return false; 1923 } 1924 1925 $this->parser_state = self::STATE_DOCTYPE; 1926 $this->token_length = $closer_at + 1 - $this->token_starts_at; 1927 $this->text_starts_at = $this->token_starts_at + 9; 1928 $this->text_length = $closer_at - $this->text_starts_at; 1929 $this->bytes_already_parsed = $closer_at + 1; 1930 return true; 1931 } 1932 1933 if ( 1934 'html' !== $this->parsing_namespace && 1935 strlen( $html ) > $at + 8 && 1936 '[' === $html[ $at + 2 ] && 1937 'C' === $html[ $at + 3 ] && 1938 'D' === $html[ $at + 4 ] && 1939 'A' === $html[ $at + 5 ] && 1940 'T' === $html[ $at + 6 ] && 1941 'A' === $html[ $at + 7 ] && 1942 '[' === $html[ $at + 8 ] 1943 ) { 1944 $closer_at = strpos( $html, ']]>', $at + 9 ); 1945 if ( false === $closer_at ) { 1946 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1947 1948 return false; 1949 } 1950 1951 $this->parser_state = self::STATE_CDATA_NODE; 1952 $this->text_starts_at = $at + 9; 1953 $this->text_length = $closer_at - $this->text_starts_at; 1954 $this->token_length = $closer_at + 3 - $this->token_starts_at; 1955 $this->bytes_already_parsed = $closer_at + 3; 1956 return true; 1957 } 1958 1959 /* 1960 * Anything else here is an incorrectly-opened comment and transitions 1961 * to the bogus comment state - skip to the nearest >. If no closer is 1962 * found then the HTML was truncated inside the markup declaration. 1963 */ 1964 $closer_at = strpos( $html, '>', $at + 1 ); 1965 if ( false === $closer_at ) { 1966 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 1967 1968 return false; 1969 } 1970 1971 $this->parser_state = self::STATE_COMMENT; 1972 $this->comment_type = self::COMMENT_AS_INVALID_HTML; 1973 $this->token_length = $closer_at + 1 - $this->token_starts_at; 1974 $this->text_starts_at = $this->token_starts_at + 2; 1975 $this->text_length = $closer_at - $this->text_starts_at; 1976 $this->bytes_already_parsed = $closer_at + 1; 1977 1978 /* 1979 * Identify nodes that would be CDATA if HTML had CDATA sections. 1980 * 1981 * This section must occur after identifying the bogus comment end 1982 * because in an HTML parser it will span to the nearest `>`, even 1983 * if there's no `]]>` as would be required in an XML document. It 1984 * is therefore not possible to parse a CDATA section containing 1985 * a `>` in the HTML syntax. 1986 * 1987 * Inside foreign elements there is a discrepancy between browsers 1988 * and the specification on this. 1989 * 1990 * @todo Track whether the Tag Processor is inside a foreign element 1991 * and require the proper closing `]]>` in those cases. 1992 */ 1993 if ( 1994 $this->token_length >= 10 && 1995 '[' === $html[ $this->token_starts_at + 2 ] && 1996 'C' === $html[ $this->token_starts_at + 3 ] && 1997 'D' === $html[ $this->token_starts_at + 4 ] && 1998 'A' === $html[ $this->token_starts_at + 5 ] && 1999 'T' === $html[ $this->token_starts_at + 6 ] && 2000 'A' === $html[ $this->token_starts_at + 7 ] && 2001 '[' === $html[ $this->token_starts_at + 8 ] && 2002 ']' === $html[ $closer_at - 1 ] && 2003 ']' === $html[ $closer_at - 2 ] 2004 ) { 2005 $this->parser_state = self::STATE_COMMENT; 2006 $this->comment_type = self::COMMENT_AS_CDATA_LOOKALIKE; 2007 $this->text_starts_at += 7; 2008 $this->text_length -= 9; 2009 } 2010 2011 return true; 2012 } 2013 2014 /* 2015 * </> is a missing end tag name, which is ignored. 2016 * 2017 * This was also known as the "presumptuous empty tag" 2018 * in early discussions as it was proposed to close 2019 * the nearest previous opening tag. 2020 * 2021 * See https://html.spec.whatwg.org/#parse-error-missing-end-tag-name 2022 */ 2023 if ( '>' === $html[ $at + 1 ] ) { 2024 // `<>` is interpreted as plaintext. 2025 if ( ! $this->is_closing_tag ) { 2026 ++$at; 2027 continue; 2028 } 2029 2030 $this->parser_state = self::STATE_PRESUMPTUOUS_TAG; 2031 $this->token_length = $at + 2 - $this->token_starts_at; 2032 $this->bytes_already_parsed = $at + 2; 2033 return true; 2034 } 2035 2036 /** 2037 * `<?` transitions to the processing instruction open state. 2038 * 2039 * A processing instruction whose target starts with an ASCII letter or `_`, 2040 * continues with ASCII alphanumerics, `-`, or `_`, and is not an ASCII 2041 * case-insensitive match for `xml` or `xml-stylesheet` produces a processing 2042 * instruction node. Anything else transitions to the bogus comment state. 2043 * 2044 * Both forms end at the nearest `>`; a processing instruction cannot 2045 * contain one in the HTML syntax. 2046 * 2047 * @link https://html.spec.whatwg.org/multipage/parsing.html#processing-instruction-open-state 2048 */ 2049 if ( ! $this->is_closing_tag && '?' === $html[ $at + 1 ] ) { 2050 $closer_at = strpos( $html, '>', $at + 2 ); 2051 if ( false === $closer_at ) { 2052 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2053 2054 return false; 2055 } 2056 2057 $target_at = $at + 2; 2058 $target_length = 0; 2059 $first_char = $html[ $target_at ]; 2060 if ( 2061 ( 'a' <= $first_char && 'z' >= $first_char ) || 2062 ( 'A' <= $first_char && 'Z' >= $first_char ) || 2063 '_' === $first_char 2064 ) { 2065 $target_length = 1 + strspn( $html, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-', $target_at + 1 ); 2066 } 2067 2068 /* 2069 * In the processing instruction target state, only whitespace, `?`, 2070 * or `>` may terminate the target; any other character converts the 2071 * token into a bogus comment. 2072 * 2073 * The `xml` and `xml-stylesheet` targets are reserved and disallowed; 2074 * they also convert the token into a bogus comment. 2075 */ 2076 $is_valid_pi = ( 2077 0 !== $target_length && 2078 str_contains( " \t\f\r\n?>", $html[ $target_at + $target_length ] ) && 2079 ! ( 3 === $target_length && 0 === substr_compare( $html, 'xml', $target_at, 3, true ) ) && 2080 ! ( 14 === $target_length && 0 === substr_compare( $html, 'xml-stylesheet', $target_at, 14, true ) ) 2081 ); 2082 2083 if ( $is_valid_pi ) { 2084 /* 2085 * The processing instruction data starts after any whitespace 2086 * following the target and ends at the `>`. When the token is 2087 * closed by `?>`, that final `?` is not part of the data. 2088 */ 2089 $data_at = $target_at + $target_length; 2090 $data_at += strspn( $html, " \t\f\r\n", $data_at ); 2091 2092 $data_length = $closer_at - $data_at; 2093 if ( $data_length > 0 && '?' === $html[ $closer_at - 1 ] ) { 2094 --$data_length; 2095 } 2096 2097 $this->parser_state = self::STATE_PROCESSING_INSTRUCTION; 2098 $this->tag_name_starts_at = $target_at; 2099 $this->tag_name_length = $target_length; 2100 $this->token_length = $closer_at + 1 - $this->token_starts_at; 2101 $this->text_starts_at = $data_at; 2102 $this->text_length = $data_length; 2103 $this->bytes_already_parsed = $closer_at + 1; 2104 return true; 2105 } 2106 2107 $this->parser_state = self::STATE_COMMENT; 2108 $this->comment_type = self::COMMENT_AS_INVALID_HTML; 2109 $this->token_length = $closer_at + 1 - $this->token_starts_at; 2110 $this->text_starts_at = $this->token_starts_at + 2; 2111 $this->text_length = $closer_at - $this->text_starts_at; 2112 $this->bytes_already_parsed = $closer_at + 1; 2113 2114 /* 2115 * Identify an XML-like Processing Instruction node. 2116 * 2117 * HTML and XML processing instructions have different parsing rules. 2118 * The HTML API recognizes XML-like processing instructions that are 2119 * _not_ HTML processing instructions. The HTML standard transforms 2120 * them to "bogus comments," represented by the HTML API as comments 2121 * with the `COMMENT_AS_PI_NODE_LOOKALIKE` type. This includes the 2122 * special targets `xml` and `xml-stylesheet` which are reserved 2123 * targets not allowed in HTML processing instructions. 2124 * 2125 * > NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] | 2126 * [#x370-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x2070-#x218F] | 2127 * [#x2C00-#x2FEF] | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | 2128 * [#x10000-#xEFFFF] 2129 * > NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 | [#x0300-#x036F] | [#x203F-#x2040] 2130 * 2131 * @see https://www.w3.org/TR/2006/REC-xml11-20060816/#NT-PITarget 2132 */ 2133 if ( $this->token_length >= 5 && '?' === $html[ $closer_at - 1 ] ) { 2134 $comment_text = substr( $html, $this->token_starts_at + 2, $this->token_length - 4 ); 2135 $pi_target_length = strspn( $comment_text, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:_' ); 2136 2137 if ( 0 < $pi_target_length ) { 2138 $pi_target_length += strspn( $comment_text, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:_-.', $pi_target_length ); 2139 2140 $this->comment_type = self::COMMENT_AS_PI_NODE_LOOKALIKE; 2141 $this->tag_name_starts_at = $this->token_starts_at + 2; 2142 $this->tag_name_length = $pi_target_length; 2143 $this->text_starts_at += $pi_target_length; 2144 $this->text_length -= $pi_target_length + 1; 2145 } 2146 } 2147 2148 return true; 2149 } 2150 2151 /* 2152 * If a non-alpha starts the tag name in a tag closer it's a comment. 2153 * Find the first `>`, which closes the comment. 2154 * 2155 * This parser classifies these particular comments as special "funky comments" 2156 * which are made available for further processing. 2157 * 2158 * See https://html.spec.whatwg.org/#parse-error-invalid-first-character-of-tag-name 2159 */ 2160 if ( $this->is_closing_tag ) { 2161 // No chance of finding a closer. 2162 if ( $at + 3 > $doc_length ) { 2163 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2164 2165 return false; 2166 } 2167 2168 $closer_at = strpos( $html, '>', $at + 2 ); 2169 if ( false === $closer_at ) { 2170 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2171 2172 return false; 2173 } 2174 2175 $this->parser_state = self::STATE_FUNKY_COMMENT; 2176 $this->token_length = $closer_at + 1 - $this->token_starts_at; 2177 $this->text_starts_at = $this->token_starts_at + 2; 2178 $this->text_length = $closer_at - $this->text_starts_at; 2179 $this->bytes_already_parsed = $closer_at + 1; 2180 return true; 2181 } 2182 2183 ++$at; 2184 } 2185 2186 /* 2187 * This does not imply an incomplete parse; it indicates that there 2188 * can be nothing left in the document other than a #text node. 2189 */ 2190 $this->parser_state = self::STATE_TEXT_NODE; 2191 $this->token_starts_at = $was_at; 2192 $this->token_length = $doc_length - $was_at; 2193 $this->text_starts_at = $was_at; 2194 $this->text_length = $this->token_length; 2195 $this->bytes_already_parsed = $doc_length; 2196 return true; 2197 } 2198 2199 /** 2200 * Parses the next attribute. 2201 * 2202 * @since 6.2.0 2203 * @ignore 2204 * 2205 * @return bool Whether an attribute was found. Returns `false` upon reaching the end 2206 * of the tag (`>` or `/>`) or the end of the document. 2207 */ 2208 private function parse_next_attribute(): bool { 2209 $doc_length = strlen( $this->html ); 2210 2211 // Skip whitespace and slashes. 2212 $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); 2213 $this->bytes_already_parsed += $skipped_length; 2214 if ( $this->bytes_already_parsed >= $doc_length ) { 2215 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2216 2217 return false; 2218 } 2219 2220 /** 2221 * This block serves two purposes: 2222 * 2223 * - A fast path for common tag-ending `>`. 2224 * - A check for the self-closing flag which must appear as `/>`. 2225 * 2226 * In a tag like `<g attr=/>`, `/` is the attribute value, not a self-closing 2227 * flag. When it appears in this form, the parser has already consumed the 2228 * attribute value, `$skipped_length` is 0, and this checks below correctly 2229 * identify whether there is a self-closing flag. 2230 * 2231 * Note: Both start and end tags may have the self-closing flag. 2232 */ 2233 if ( '>' === $this->html[ $this->bytes_already_parsed ] ) { 2234 if ( $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] ) { 2235 $this->has_self_closing_flag = true; 2236 } 2237 return false; 2238 } 2239 2240 /* 2241 * Treat the equal sign as a part of the attribute 2242 * name if it is the first encountered byte. 2243 * 2244 * @see https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state 2245 */ 2246 $name_length = '=' === $this->html[ $this->bytes_already_parsed ] 2247 ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) 2248 : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); 2249 2250 // No attribute, just tag closer. 2251 if ( 0 === $name_length || $this->bytes_already_parsed + $name_length >= $doc_length ) { 2252 return false; 2253 } 2254 2255 $attribute_start = $this->bytes_already_parsed; 2256 $attribute_name = substr( $this->html, $attribute_start, $name_length ); 2257 $this->bytes_already_parsed += $name_length; 2258 if ( $this->bytes_already_parsed >= $doc_length ) { 2259 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2260 2261 return false; 2262 } 2263 2264 $this->skip_whitespace(); 2265 if ( $this->bytes_already_parsed >= $doc_length ) { 2266 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2267 2268 return false; 2269 } 2270 2271 $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; 2272 if ( $has_value ) { 2273 ++$this->bytes_already_parsed; 2274 $this->skip_whitespace(); 2275 if ( $this->bytes_already_parsed >= $doc_length ) { 2276 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2277 2278 return false; 2279 } 2280 2281 switch ( $this->html[ $this->bytes_already_parsed ] ) { 2282 case "'": 2283 case '"': 2284 $quote = $this->html[ $this->bytes_already_parsed ]; 2285 $value_start = $this->bytes_already_parsed + 1; 2286 $end_quote_at = strpos( $this->html, $quote, $value_start ); 2287 $end_quote_at = false === $end_quote_at ? $doc_length : $end_quote_at; 2288 $value_length = $end_quote_at - $value_start; 2289 $attribute_end = $end_quote_at + 1; 2290 $this->bytes_already_parsed = $attribute_end; 2291 break; 2292 2293 default: 2294 $value_start = $this->bytes_already_parsed; 2295 $value_length = strcspn( $this->html, "> \t\f\r\n", $value_start ); 2296 $attribute_end = $value_start + $value_length; 2297 $this->bytes_already_parsed = $attribute_end; 2298 } 2299 } else { 2300 $value_start = $this->bytes_already_parsed; 2301 $value_length = 0; 2302 $attribute_end = $attribute_start + $name_length; 2303 } 2304 2305 if ( $attribute_end >= $doc_length ) { 2306 $this->parser_state = self::STATE_INCOMPLETE_INPUT; 2307 2308 return false; 2309 } 2310 2311 if ( $this->is_closing_tag ) { 2312 return true; 2313 } 2314 2315 /* 2316 * > There must never be two or more attributes on 2317 * > the same start tag whose names are an ASCII 2318 * > case-insensitive match for each other. 2319 * - HTML 5 spec 2320 * 2321 * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive 2322 * 2323 * The tokenizer replaces U+0000 NULL bytes in 2324 * attribute names with U+FFFD. 2325 * 2326 * @see https://html.spec.whatwg.org/#attribute-name-state 2327 */ 2328 $comparable_name = strtolower( str_replace( "\x00", "\u{FFFD}", $attribute_name ) ); 2329 2330 // If an attribute is listed many times, only use the first declaration and ignore the rest. 2331 if ( ! isset( $this->attributes[ $comparable_name ] ) ) { 2332 $this->attributes[ $comparable_name ] = new WP_HTML_Attribute_Token( 2333 $attribute_name, 2334 $value_start, 2335 $value_length, 2336 $attribute_start, 2337 $attribute_end - $attribute_start, 2338 ! $has_value 2339 ); 2340 2341 return true; 2342 } 2343 2344 /* 2345 * Track the duplicate attributes so if we remove it, all disappear together. 2346 * 2347 * While `$this->duplicated_attributes` could always be stored as an `array()`, 2348 * which would simplify the logic here, storing a `null` and only allocating 2349 * an array when encountering duplicates avoids needless allocations in the 2350 * normative case of parsing tags with no duplicate attributes. 2351 */ 2352 $duplicate_span = new WP_HTML_Span( $attribute_start, $attribute_end - $attribute_start ); 2353 if ( null === $this->duplicate_attributes ) { 2354 $this->duplicate_attributes = array( $comparable_name => array( $duplicate_span ) ); 2355 } elseif ( ! isset( $this->duplicate_attributes[ $comparable_name ] ) ) { 2356 $this->duplicate_attributes[ $comparable_name ] = array( $duplicate_span ); 2357 } else { 2358 $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; 2359 } 2360 2361 return true; 2362 } 2363 2364 /** 2365 * Move the internal cursor past any immediate successive whitespace. 2366 * 2367 * @since 6.2.0 2368 * @ignore 2369 */ 2370 private function skip_whitespace(): void { 2371 $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n", $this->bytes_already_parsed ); 2372 } 2373 2374 /** 2375 * Applies attribute updates and cleans up once a tag is fully parsed. 2376 * 2377 * @since 6.2.0 2378 * @ignore 2379 */ 2380 private function after_tag(): void { 2381 /* 2382 * There could be lexical updates enqueued for an attribute that 2383 * also exists on the next tag. In order to avoid conflating the 2384 * attributes across the two tags, lexical updates with names 2385 * need to be flushed to raw lexical updates. 2386 */ 2387 $this->class_name_updates_to_attributes_updates(); 2388 2389 /* 2390 * Purge updates if there are too many. The actual count isn't 2391 * scientific, but a few values from 100 to a few thousand were 2392 * tests to find a practically-useful limit. 2393 * 2394 * If the update queue grows too big, then the Tag Processor 2395 * will spend more time iterating through them and lose the 2396 * efficiency gains of deferring applying them. 2397 */ 2398 if ( 1000 < count( $this->lexical_updates ) ) { 2399 $this->get_updated_html(); 2400 } 2401 2402 foreach ( $this->lexical_updates as $name => $update ) { 2403 /* 2404 * Any updates appearing after the cursor should be applied 2405 * before proceeding, otherwise they may be overlooked. 2406 */ 2407 if ( $update->start >= $this->bytes_already_parsed ) { 2408 $this->get_updated_html(); 2409 break; 2410 } 2411 2412 if ( is_int( $name ) ) { 2413 continue; 2414 } 2415 2416 $this->lexical_updates[] = $update; 2417 unset( $this->lexical_updates[ $name ] ); 2418 } 2419 2420 $this->token_starts_at = null; 2421 $this->token_length = null; 2422 $this->has_self_closing_flag = false; 2423 $this->tag_name_starts_at = null; 2424 $this->tag_name_length = null; 2425 $this->text_starts_at = 0; 2426 $this->text_length = 0; 2427 $this->is_closing_tag = null; 2428 $this->attributes = array(); 2429 $this->comment_type = null; 2430 $this->text_node_classification = self::TEXT_IS_GENERIC; 2431 $this->duplicate_attributes = null; 2432 } 2433 2434 /** 2435 * Converts class name updates into tag attributes updates 2436 * (they are accumulated in different data formats for performance). 2437 * 2438 * @since 6.2.0 2439 * @ignore 2440 * 2441 * @see WP_HTML_Tag_Processor::$lexical_updates 2442 * @see WP_HTML_Tag_Processor::$classname_updates 2443 */ 2444 private function class_name_updates_to_attributes_updates(): void { 2445 if ( count( $this->classname_updates ) === 0 ) { 2446 return; 2447 } 2448 2449 $existing_class = $this->get_enqueued_attribute_value( 'class' ); 2450 if ( null === $existing_class || true === $existing_class ) { 2451 $existing_class = ''; 2452 } 2453 2454 if ( false === $existing_class && isset( $this->attributes['class'] ) ) { 2455 $existing_class = $this->get_decoded_attribute_value( $this->attributes['class'] ); 2456 } 2457 2458 if ( false === $existing_class ) { 2459 $existing_class = ''; 2460 } 2461 2462 /** 2463 * Updated "class" attribute value. 2464 * 2465 * This is incrementally built while scanning through the existing class 2466 * attribute, skipping removed classes on the way, and then appending 2467 * added classes at the end. Only when finished processing will the 2468 * value contain the final new value. 2469 2470 * @var string $class 2471 */ 2472 $class = ''; 2473 2474 /** 2475 * Tracks the cursor position in the existing 2476 * class attribute value while parsing. 2477 * 2478 * @var int $at 2479 */ 2480 $at = 0; 2481 2482 /** 2483 * Indicates if there's any need to modify the existing class attribute. 2484 * 2485 * If a call to `add_class()` and `remove_class()` wouldn't impact 2486 * the `class` attribute value then there's no need to rebuild it. 2487 * For example, when adding a class that's already present or 2488 * removing one that isn't. 2489 * 2490 * This flag enables a performance optimization when none of the enqueued 2491 * class updates would impact the `class` attribute; namely, that the 2492 * processor can continue without modifying the input document, as if 2493 * none of the `add_class()` or `remove_class()` calls had been made. 2494 * 2495 * This flag is set upon the first change that requires a string update. 2496 * 2497 * @var bool $modified 2498 */ 2499 $modified = false; 2500 2501 $seen = array(); 2502 $to_remove = array(); 2503 $is_quirks = self::QUIRKS_MODE === $this->compat_mode; 2504 if ( $is_quirks ) { 2505 foreach ( $this->classname_updates as $updated_name => $action ) { 2506 if ( self::REMOVE_CLASS === $action ) { 2507 $to_remove[] = strtolower( $updated_name ); 2508 } 2509 } 2510 } else { 2511 foreach ( $this->classname_updates as $updated_name => $action ) { 2512 if ( self::REMOVE_CLASS === $action ) { 2513 $to_remove[] = $updated_name; 2514 } 2515 } 2516 } 2517 2518 // Remove unwanted classes by only copying the new ones. 2519 $existing_class_length = strlen( $existing_class ); 2520 while ( $at < $existing_class_length ) { 2521 // Skip to the first non-whitespace character. 2522 $ws_at = $at; 2523 $ws_length = strspn( $existing_class, " \t\f\r\n", $ws_at ); 2524 $at += $ws_length; 2525 2526 // Capture the class name – it's everything until the next whitespace. 2527 $name_length = strcspn( $existing_class, " \t\f\r\n", $at ); 2528 if ( 0 === $name_length ) { 2529 // If no more class names are found then that's the end. 2530 break; 2531 } 2532 2533 $name = substr( $existing_class, $at, $name_length ); 2534 $comparable_class_name = $is_quirks ? strtolower( $name ) : $name; 2535 $at += $name_length; 2536 2537 // If this class is marked for removal, remove it and move on to the next one. 2538 if ( in_array( $comparable_class_name, $to_remove, true ) ) { 2539 $modified = true; 2540 continue; 2541 } 2542 2543 // If a class has already been seen then skip it; it should not be added twice. 2544 if ( in_array( $comparable_class_name, $seen, true ) ) { 2545 continue; 2546 } 2547 2548 $seen[] = $comparable_class_name; 2549 2550 /* 2551 * Otherwise, append it to the new "class" attribute value. 2552 * 2553 * There are options for handling whitespace between tags. 2554 * Preserving the existing whitespace produces fewer changes 2555 * to the HTML content and should clarify the before/after 2556 * content when debugging the modified output. 2557 * 2558 * This approach contrasts normalizing the inter-class 2559 * whitespace to a single space, which might appear cleaner 2560 * in the output HTML but produce a noisier change. 2561 */ 2562 if ( '' !== $class ) { 2563 $class .= substr( $existing_class, $ws_at, $ws_length ); 2564 } 2565 $class .= $name; 2566 } 2567 2568 // Add new classes by appending those which haven't already been seen. 2569 foreach ( $this->classname_updates as $name => $operation ) { 2570 $comparable_name = $is_quirks ? strtolower( $name ) : $name; 2571 if ( self::ADD_CLASS === $operation && ! in_array( $comparable_name, $seen, true ) ) { 2572 $modified = true; 2573 2574 $class .= strlen( $class ) > 0 ? ' ' : ''; 2575 $class .= $name; 2576 } 2577 } 2578 2579 $this->classname_updates = array(); 2580 if ( ! $modified ) { 2581 return; 2582 } 2583 2584 if ( strlen( $class ) > 0 ) { 2585 $this->set_attribute( 'class', $class ); 2586 } else { 2587 $this->remove_attribute( 'class' ); 2588 } 2589 } 2590 2591 /** 2592 * Applies attribute updates to HTML document. 2593 * 2594 * @since 6.2.0 2595 * @since 6.2.1 Accumulates shift for internal cursor and passed pointer. 2596 * @since 6.3.0 Invalidate any bookmarks whose targets are overwritten. 2597 * @ignore 2598 * 2599 * @param int $shift_this_point Accumulate and return shift for this position. 2600 * @return int How many bytes the given pointer moved in response to the updates. 2601 */ 2602 private function apply_attributes_updates( int $shift_this_point ): int { 2603 if ( ! count( $this->lexical_updates ) ) { 2604 return 0; 2605 } 2606 2607 $accumulated_shift_for_given_point = 0; 2608 2609 /* 2610 * Attribute updates can be enqueued in any order but updates 2611 * to the document must occur in lexical order; that is, each 2612 * replacement must be made before all others which follow it 2613 * at later string indices in the input document. 2614 * 2615 * Sorting avoids making out-of-order replacements which 2616 * can lead to mangled output, partially-duplicated 2617 * attributes, and overwritten attributes. 2618 */ 2619 usort( $this->lexical_updates, array( self::class, 'sort_start_ascending' ) ); 2620 2621 $bytes_already_copied = 0; 2622 $output_buffer = ''; 2623 foreach ( $this->lexical_updates as $diff ) { 2624 $shift = strlen( $diff->text ) - $diff->length; 2625 2626 // Adjust the cursor position by however much an update affects it. 2627 if ( $diff->start < $this->bytes_already_parsed ) { 2628 $this->bytes_already_parsed += $shift; 2629 } 2630 2631 // Accumulate shift of the given pointer within this function call. 2632 if ( $diff->start < $shift_this_point ) { 2633 $accumulated_shift_for_given_point += $shift; 2634 } 2635 2636 $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); 2637 $output_buffer .= $diff->text; 2638 $bytes_already_copied = $diff->start + $diff->length; 2639 } 2640 2641 $this->html = $output_buffer . substr( $this->html, $bytes_already_copied ); 2642 2643 /* 2644 * Adjust bookmark locations to account for how the text 2645 * replacements adjust offsets in the input document. 2646 */ 2647 foreach ( $this->bookmarks as $bookmark_name => $bookmark ) { 2648 $bookmark_end = $bookmark->start + $bookmark->length; 2649 2650 /* 2651 * Each lexical update which appears before the bookmark's endpoints 2652 * might shift the offsets for those endpoints. Loop through each change 2653 * and accumulate the total shift for each bookmark, then apply that 2654 * shift after tallying the full delta. 2655 */ 2656 $head_delta = 0; 2657 $tail_delta = 0; 2658 2659 foreach ( $this->lexical_updates as $diff ) { 2660 $diff_end = $diff->start + $diff->length; 2661 2662 if ( $bookmark->start < $diff->start && $bookmark_end < $diff->start ) { 2663 break; 2664 } 2665 2666 if ( $bookmark->start >= $diff->start && $bookmark_end < $diff_end ) { 2667 $this->release_bookmark( $bookmark_name ); 2668 continue 2; 2669 } 2670 2671 $delta = strlen( $diff->text ) - $diff->length; 2672 2673 if ( $bookmark->start >= $diff->start ) { 2674 $head_delta += $delta; 2675 } 2676 2677 if ( $bookmark_end >= $diff_end ) { 2678 $tail_delta += $delta; 2679 } 2680 } 2681 2682 $bookmark->start += $head_delta; 2683 $bookmark->length += $tail_delta - $head_delta; 2684 } 2685 2686 $this->lexical_updates = array(); 2687 2688 return $accumulated_shift_for_given_point; 2689 } 2690 2691 /** 2692 * Checks whether a bookmark with the given name exists. 2693 * 2694 * @since 6.3.0 2695 * 2696 * @param string $bookmark_name Name to identify a bookmark that potentially exists. 2697 * @return bool Whether that bookmark exists. 2698 */ 2699 public function has_bookmark( $bookmark_name ): bool { 2700 return array_key_exists( $bookmark_name, $this->bookmarks ); 2701 } 2702 2703 /** 2704 * Move the internal cursor in the Tag Processor to a given bookmark's location. 2705 * 2706 * In order to prevent accidental infinite loops, there's a 2707 * maximum limit on the number of times seek() can be called. 2708 * 2709 * @since 6.2.0 2710 * 2711 * @param string $bookmark_name Jump to the place in the document identified by this bookmark name. 2712 * @return bool Whether the internal cursor was successfully moved to the bookmark's location. 2713 */ 2714 public function seek( $bookmark_name ): bool { 2715 if ( ! array_key_exists( $bookmark_name, $this->bookmarks ) ) { 2716 _doing_it_wrong( 2717 __METHOD__, 2718 __( 'Unknown bookmark name.' ), 2719 '6.2.0' 2720 ); 2721 return false; 2722 } 2723 2724 $existing_bookmark = $this->bookmarks[ $bookmark_name ]; 2725 2726 if ( 2727 $this->token_starts_at === $existing_bookmark->start && 2728 $this->token_length === $existing_bookmark->length 2729 ) { 2730 return true; 2731 } 2732 2733 if ( ++$this->seek_count > static::MAX_SEEK_OPS ) { 2734 _doing_it_wrong( 2735 __METHOD__, 2736 __( 'Too many calls to seek() - this can lead to performance issues.' ), 2737 '6.2.0' 2738 ); 2739 return false; 2740 } 2741 2742 // Flush out any pending updates to the document. 2743 $this->get_updated_html(); 2744 2745 // Point this tag processor before the sought tag opener and consume it. 2746 $this->bytes_already_parsed = $this->bookmarks[ $bookmark_name ]->start; 2747 $this->parser_state = self::STATE_READY; 2748 return $this->next_token(); 2749 } 2750 2751 /** 2752 * Compare two WP_HTML_Text_Replacement objects. 2753 * 2754 * @since 6.2.0 2755 * @ignore 2756 * 2757 * @param WP_HTML_Text_Replacement $a First attribute update. 2758 * @param WP_HTML_Text_Replacement $b Second attribute update. 2759 * @return int Comparison value for string order. 2760 */ 2761 private static function sort_start_ascending( WP_HTML_Text_Replacement $a, WP_HTML_Text_Replacement $b ): int { 2762 $by_start = $a->start - $b->start; 2763 if ( 0 !== $by_start ) { 2764 return $by_start; 2765 } 2766 2767 $by_text = isset( $a->text, $b->text ) ? strcmp( $a->text, $b->text ) : 0; 2768 if ( 0 !== $by_text ) { 2769 return $by_text; 2770 } 2771 2772 /* 2773 * This code should be unreachable, because it implies the two replacements 2774 * start at the same location and contain the same text. 2775 */ 2776 return $a->length - $b->length; 2777 } 2778 2779 /** 2780 * Return the enqueued value for a given attribute, if one exists. 2781 * 2782 * Enqueued updates can take different data types: 2783 * - If an update is enqueued and is boolean, the return will be `true` 2784 * - If an update is otherwise enqueued, the return will be the string value of that update. 2785 * - If an attribute is enqueued to be removed, the return will be `null` to indicate that. 2786 * - If no updates are enqueued, the return will be `false` to differentiate from "removed." 2787 * 2788 * @since 6.2.0 2789 * @ignore 2790 * 2791 * @param string $comparable_name The attribute name in its comparable form. 2792 * @return string|bool|null Value of enqueued update if present, otherwise false. 2793 */ 2794 private function get_enqueued_attribute_value( string $comparable_name ) { 2795 if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { 2796 return false; 2797 } 2798 2799 if ( ! isset( $this->lexical_updates[ $comparable_name ] ) ) { 2800 return false; 2801 } 2802 2803 $enqueued_text = $this->lexical_updates[ $comparable_name ]->text; 2804 2805 // Removed attributes erase the entire span. 2806 if ( '' === $enqueued_text ) { 2807 return null; 2808 } 2809 2810 /* 2811 * Boolean attribute updates are just the attribute name without a corresponding value. 2812 * 2813 * This value might differ from the given comparable name in that there could be leading 2814 * or trailing whitespace, and that the casing follows the name given in `set_attribute`. 2815 * 2816 * Example: 2817 * 2818 * $p->set_attribute( 'data-TEST-id', 'update' ); 2819 * 'update' === $p->get_enqueued_attribute_value( 'data-test-id' ); 2820 * 2821 * Detect this difference based on the absence of the `=`, which _must_ exist in any 2822 * attribute containing a value, e.g. `<input type="text" enabled />`. 2823 * ¹ ² 2824 * 1. Attribute with a string value. 2825 * 2. Boolean attribute whose value is `true`. 2826 */ 2827 $equals_at = strpos( $enqueued_text, '=' ); 2828 if ( false === $equals_at ) { 2829 return true; 2830 } 2831 2832 /* 2833 * Finally, a normal update's value will appear after the `=` and 2834 * be double-quoted, as performed incidentally by `set_attribute`. 2835 * 2836 * e.g. `type="text"` 2837 * ¹² ³ 2838 * 1. Equals is here. 2839 * 2. Double-quoting starts one after the equals sign. 2840 * 3. Double-quoting ends at the last character in the update. 2841 */ 2842 $enqueued_value = substr( $enqueued_text, $equals_at + 2, -1 ); 2843 return WP_HTML_Decoder::decode_attribute( $enqueued_value ); 2844 } 2845 2846 /** 2847 * Returns the value of a requested attribute from a matched tag opener if that attribute exists. 2848 * 2849 * Example: 2850 * 2851 * $p = new WP_HTML_Tag_Processor( '<div enabled class="test" data-test-id="14">Test</div>' ); 2852 * $p->next_tag( array( 'class_name' => 'test' ) ) === true; 2853 * $p->get_attribute( 'data-test-id' ) === '14'; 2854 * $p->get_attribute( 'enabled' ) === true; 2855 * $p->get_attribute( 'aria-label' ) === null; 2856 * 2857 * $p->next_tag() === false; 2858 * $p->get_attribute( 'class' ) === null; 2859 * 2860 * @since 6.2.0 2861 * 2862 * @param string $name Name of attribute whose value is requested. 2863 * @return string|true|null Value of attribute or `null` if not available. Boolean attributes return `true`. 2864 */ 2865 public function get_attribute( $name ) { 2866 if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { 2867 return null; 2868 } 2869 2870 $comparable = strtolower( $name ); 2871 2872 /* 2873 * For every attribute other than `class` it's possible to perform a quick check if 2874 * there's an enqueued lexical update whose value takes priority over what's found in 2875 * the input document. 2876 * 2877 * The `class` attribute is special though because of the exposed helpers `add_class` 2878 * and `remove_class`. These form a builder for the `class` attribute, so an additional 2879 * check for enqueued class changes is required in addition to the check for any enqueued 2880 * attribute values. If any exist, those enqueued class changes must first be flushed out 2881 * into an attribute value update. 2882 */ 2883 if ( 'class' === $comparable ) { 2884 $this->class_name_updates_to_attributes_updates(); 2885 } 2886 2887 // Return any enqueued attribute value updates if they exist. 2888 $enqueued_value = $this->get_enqueued_attribute_value( $comparable ); 2889 if ( false !== $enqueued_value ) { 2890 return $enqueued_value; 2891 } 2892 2893 if ( ! isset( $this->attributes[ $comparable ] ) ) { 2894 return null; 2895 } 2896 2897 $attribute = $this->attributes[ $comparable ]; 2898 2899 /* 2900 * This flag distinguishes an attribute with no value 2901 * from an attribute with an empty string value. For 2902 * unquoted attributes this could look very similar. 2903 * It refers to whether an `=` follows the name. 2904 * 2905 * e.g. <div boolean-attribute empty-attribute=></div> 2906 * ¹ ² 2907 * 1. Attribute `boolean-attribute` is `true`. 2908 * 2. Attribute `empty-attribute` is `""`. 2909 */ 2910 if ( true === $attribute->is_true ) { 2911 return true; 2912 } 2913 2914 return $this->get_decoded_attribute_value( $attribute ); 2915 } 2916 2917 /** 2918 * Decode an attribute value from source. 2919 * 2920 * This method applies the following transformations that the processor defers: 2921 * - Normalize newlines (input stream preprocessing) 2922 * - Replace NULL bytes (tokenization) 2923 * - Decode character references (tokenization) 2924 * 2925 * @since 7.1.0 2926 * @ignore 2927 * 2928 * @param WP_HTML_Attribute_Token $attribute Attribute token from the input document. 2929 * @return string Decoded attribute value. 2930 */ 2931 private function get_decoded_attribute_value( WP_HTML_Attribute_Token $attribute ): string { 2932 $raw_value = substr( $this->html, $attribute->value_starts_at, $attribute->value_length ); 2933 $raw_value = str_replace( "\r\n", "\n", $raw_value ); 2934 $raw_value = str_replace( "\r", "\n", $raw_value ); 2935 $raw_value = str_replace( "\x00", "\u{FFFD}", $raw_value ); 2936 return WP_HTML_Decoder::decode_attribute( $raw_value ); 2937 } 2938 2939 /** 2940 * Gets lowercase names of all attributes matching a given prefix in the current tag. 2941 * 2942 * Note that matching is case-insensitive. This is in accordance with the spec: 2943 * 2944 * > There must never be two or more attributes on 2945 * > the same start tag whose names are an ASCII 2946 * > case-insensitive match for each other. 2947 * - HTML 5 spec 2948 * 2949 * Example: 2950 * 2951 * $p = new WP_HTML_Tag_Processor( '<div data-ENABLED class="test" DATA-test-id="14">Test</div>' ); 2952 * $p->next_tag( array( 'class_name' => 'test' ) ) === true; 2953 * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' ); 2954 * 2955 * $p->next_tag() === false; 2956 * $p->get_attribute_names_with_prefix( 'data-' ) === null; 2957 * 2958 * @since 6.2.0 2959 * 2960 * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive 2961 * 2962 * @param string $prefix Prefix of requested attribute names. 2963 * @return array|null List of attribute names, or `null` when no tag opener is matched. 2964 * @phpstan-return list<non-empty-string>|null 2965 */ 2966 public function get_attribute_names_with_prefix( $prefix ): ?array { 2967 if ( 2968 self::STATE_MATCHED_TAG !== $this->parser_state || 2969 $this->is_closing_tag 2970 ) { 2971 return null; 2972 } 2973 2974 $comparable = strtolower( $prefix ); 2975 2976 /* 2977 * For the `class` attribute, ensure that enqueued class changes from 2978 * `add_class` and `remove_class` are flushed into attribute updates. 2979 */ 2980 $has_class = isset( $this->attributes['class'] ); 2981 if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) { 2982 foreach ( $this->classname_updates as $update ) { 2983 if ( 2984 ( $has_class && self::REMOVE_CLASS === $update ) || 2985 ( ! $has_class && self::ADD_CLASS === $update ) 2986 ) { 2987 $this->class_name_updates_to_attributes_updates(); 2988 break; 2989 } 2990 } 2991 } 2992 2993 $additions = array(); 2994 $removals = array(); 2995 foreach ( $this->lexical_updates as $update_name => $update ) { 2996 if ( is_int( $update_name ) || 'modifiable text' === $update_name ) { 2997 continue; 2998 } 2999 3000 if ( '' === $update->text ) { 3001 $removals[ $update_name ] = true; 3002 } elseif ( ! isset( $this->attributes[ $update_name ] ) && str_starts_with( $update_name, $comparable ) ) { 3003 $additions[] = $update_name; 3004 } 3005 } 3006 3007 $matches = array(); 3008 foreach ( array_keys( $this->attributes ) as $attr_name ) { 3009 if ( str_starts_with( $attr_name, $comparable ) && ! isset( $removals[ $attr_name ] ) ) { 3010 $matches[] = $attr_name; 3011 } 3012 } 3013 3014 return empty( $additions ) ? $matches : array_merge( $additions, $matches ); 3015 } 3016 3017 /** 3018 * Returns the namespace of the matched token. 3019 * 3020 * @since 6.7.0 3021 * 3022 * @return string One of 'html', 'math', or 'svg'. 3023 */ 3024 public function get_namespace(): string { 3025 return $this->parsing_namespace; 3026 } 3027 3028 /** 3029 * Returns the uppercase name of the matched tag. 3030 * 3031 * Example: 3032 * 3033 * $p = new WP_HTML_Tag_Processor( '<div class="test">Test</div>' ); 3034 * $p->next_tag() === true; 3035 * $p->get_tag() === 'DIV'; 3036 * 3037 * $p->next_tag() === false; 3038 * $p->get_tag() === null; 3039 * 3040 * @since 6.2.0 3041 * 3042 * @return string|null Name of currently matched tag in input HTML, or `null` if none found. 3043 */ 3044 public function get_tag(): ?string { 3045 if ( null === $this->tag_name_starts_at ) { 3046 return null; 3047 } 3048 3049 $tag_name = str_replace( "\x00", "\u{FFFD}", substr( $this->html, $this->tag_name_starts_at, $this->tag_name_length ) ); 3050 3051 if ( self::STATE_MATCHED_TAG === $this->parser_state ) { 3052 return strtoupper( $tag_name ); 3053 } 3054 3055 /* 3056 * Processing instruction targets are case-sensitive 3057 * and returned as they appear in the input HTML. 3058 */ 3059 if ( self::STATE_PROCESSING_INSTRUCTION === $this->parser_state ) { 3060 return $tag_name; 3061 } 3062 3063 if ( 3064 self::STATE_COMMENT === $this->parser_state && 3065 self::COMMENT_AS_PI_NODE_LOOKALIKE === $this->get_comment_type() 3066 ) { 3067 return $tag_name; 3068 } 3069 3070 return null; 3071 } 3072 3073 /** 3074 * Returns the adjusted tag name for a given token, taking into 3075 * account the current parsing context, whether HTML, SVG, or MathML. 3076 * 3077 * @since 6.7.0 3078 * 3079 * @return string|null Name of current tag name. 3080 */ 3081 public function get_qualified_tag_name(): ?string { 3082 $tag_name = $this->get_tag(); 3083 if ( null === $tag_name ) { 3084 return null; 3085 } 3086 3087 if ( 'html' === $this->get_namespace() ) { 3088 return $tag_name; 3089 } 3090 3091 $lower_tag_name = strtolower( $tag_name ); 3092 if ( 'math' === $this->get_namespace() ) { 3093 return $lower_tag_name; 3094 } 3095 3096 if ( 'svg' === $this->get_namespace() ) { 3097 switch ( $lower_tag_name ) { 3098 case 'altglyph': 3099 return 'altGlyph'; 3100 3101 case 'altglyphdef': 3102 return 'altGlyphDef'; 3103 3104 case 'altglyphitem': 3105 return 'altGlyphItem'; 3106 3107 case 'animatecolor': 3108 return 'animateColor'; 3109 3110 case 'animatemotion': 3111 return 'animateMotion'; 3112 3113 case 'animatetransform': 3114 return 'animateTransform'; 3115 3116 case 'clippath': 3117 return 'clipPath'; 3118 3119 case 'feblend': 3120 return 'feBlend'; 3121 3122 case 'fecolormatrix': 3123 return 'feColorMatrix'; 3124 3125 case 'fecomponenttransfer': 3126 return 'feComponentTransfer'; 3127 3128 case 'fecomposite': 3129 return 'feComposite'; 3130 3131 case 'feconvolvematrix': 3132 return 'feConvolveMatrix'; 3133 3134 case 'fediffuselighting': 3135 return 'feDiffuseLighting'; 3136 3137 case 'fedisplacementmap': 3138 return 'feDisplacementMap'; 3139 3140 case 'fedistantlight': 3141 return 'feDistantLight'; 3142 3143 case 'fedropshadow': 3144 return 'feDropShadow'; 3145 3146 case 'feflood': 3147 return 'feFlood'; 3148 3149 case 'fefunca': 3150 return 'feFuncA'; 3151 3152 case 'fefuncb': 3153 return 'feFuncB'; 3154 3155 case 'fefuncg': 3156 return 'feFuncG'; 3157 3158 case 'fefuncr': 3159 return 'feFuncR'; 3160 3161 case 'fegaussianblur': 3162 return 'feGaussianBlur'; 3163 3164 case 'feimage': 3165 return 'feImage'; 3166 3167 case 'femerge': 3168 return 'feMerge'; 3169 3170 case 'femergenode': 3171 return 'feMergeNode'; 3172 3173 case 'femorphology': 3174 return 'feMorphology'; 3175 3176 case 'feoffset': 3177 return 'feOffset'; 3178 3179 case 'fepointlight': 3180 return 'fePointLight'; 3181 3182 case 'fespecularlighting': 3183 return 'feSpecularLighting'; 3184 3185 case 'fespotlight': 3186 return 'feSpotLight'; 3187 3188 case 'fetile': 3189 return 'feTile'; 3190 3191 case 'feturbulence': 3192 return 'feTurbulence'; 3193 3194 case 'foreignobject': 3195 return 'foreignObject'; 3196 3197 case 'glyphref': 3198 return 'glyphRef'; 3199 3200 case 'lineargradient': 3201 return 'linearGradient'; 3202 3203 case 'radialgradient': 3204 return 'radialGradient'; 3205 3206 case 'textpath': 3207 return 'textPath'; 3208 3209 default: 3210 return $lower_tag_name; 3211 } 3212 } 3213 3214 // This unnecessary return prevents tools from inaccurately reporting type errors. 3215 return $tag_name; 3216 } 3217 3218 /** 3219 * Returns the adjusted attribute name for a given attribute, taking into 3220 * account the current parsing context, whether HTML, SVG, or MathML. 3221 * 3222 * In SVG and MathML contexts, adjusted foreign attributes with a namespace 3223 * prefix use a space between the prefix and local name. For example, 3224 * `xlink:href` is returned as `xlink href`, while the unprefixed `xmlns` 3225 * attribute is returned as `xmlns`. Non-adjusted attributes with a colon in 3226 * their name, such as `foo:bar`, are returned unchanged. 3227 * 3228 * @since 6.7.0 3229 * 3230 * @param string $attribute_name Which attribute to adjust. 3231 * @return string|null 3232 */ 3233 public function get_qualified_attribute_name( $attribute_name ): ?string { 3234 if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { 3235 return null; 3236 } 3237 3238 $namespace = $this->get_namespace(); 3239 $lower_name = strtolower( $attribute_name ); 3240 3241 if ( 'math' === $namespace && 'definitionurl' === $lower_name ) { 3242 return 'definitionURL'; 3243 } 3244 3245 if ( 'svg' === $this->get_namespace() ) { 3246 switch ( $lower_name ) { 3247 case 'attributename': 3248 return 'attributeName'; 3249 3250 case 'attributetype': 3251 return 'attributeType'; 3252 3253 case 'basefrequency': 3254 return 'baseFrequency'; 3255 3256 case 'baseprofile': 3257 return 'baseProfile'; 3258 3259 case 'calcmode': 3260 return 'calcMode'; 3261 3262 case 'clippathunits': 3263 return 'clipPathUnits'; 3264 3265 case 'diffuseconstant': 3266 return 'diffuseConstant'; 3267 3268 case 'edgemode': 3269 return 'edgeMode'; 3270 3271 case 'filterunits': 3272 return 'filterUnits'; 3273 3274 case 'glyphref': 3275 return 'glyphRef'; 3276 3277 case 'gradienttransform': 3278 return 'gradientTransform'; 3279 3280 case 'gradientunits': 3281 return 'gradientUnits'; 3282 3283 case 'kernelmatrix': 3284 return 'kernelMatrix'; 3285 3286 case 'kernelunitlength': 3287 return 'kernelUnitLength'; 3288 3289 case 'keypoints': 3290 return 'keyPoints'; 3291 3292 case 'keysplines': 3293 return 'keySplines'; 3294 3295 case 'keytimes': 3296 return 'keyTimes'; 3297 3298 case 'lengthadjust': 3299 return 'lengthAdjust'; 3300 3301 case 'limitingconeangle': 3302 return 'limitingConeAngle'; 3303 3304 case 'markerheight': 3305 return 'markerHeight'; 3306 3307 case 'markerunits': 3308 return 'markerUnits'; 3309 3310 case 'markerwidth': 3311 return 'markerWidth'; 3312 3313 case 'maskcontentunits': 3314 return 'maskContentUnits'; 3315 3316 case 'maskunits': 3317 return 'maskUnits'; 3318 3319 case 'numoctaves': 3320 return 'numOctaves'; 3321 3322 case 'pathlength': 3323 return 'pathLength'; 3324 3325 case 'patterncontentunits': 3326 return 'patternContentUnits'; 3327 3328 case 'patterntransform': 3329 return 'patternTransform'; 3330 3331 case 'patternunits': 3332 return 'patternUnits'; 3333 3334 case 'pointsatx': 3335 return 'pointsAtX'; 3336 3337 case 'pointsaty': 3338 return 'pointsAtY'; 3339 3340 case 'pointsatz': 3341 return 'pointsAtZ'; 3342 3343 case 'preservealpha': 3344 return 'preserveAlpha'; 3345 3346 case 'preserveaspectratio': 3347 return 'preserveAspectRatio'; 3348 3349 case 'primitiveunits': 3350 return 'primitiveUnits'; 3351 3352 case 'refx': 3353 return 'refX'; 3354 3355 case 'refy': 3356 return 'refY'; 3357 3358 case 'repeatcount': 3359 return 'repeatCount'; 3360 3361 case 'repeatdur': 3362 return 'repeatDur'; 3363 3364 case 'requiredextensions': 3365 return 'requiredExtensions'; 3366 3367 case 'requiredfeatures': 3368 return 'requiredFeatures'; 3369 3370 case 'specularconstant': 3371 return 'specularConstant'; 3372 3373 case 'specularexponent': 3374 return 'specularExponent'; 3375 3376 case 'spreadmethod': 3377 return 'spreadMethod'; 3378 3379 case 'startoffset': 3380 return 'startOffset'; 3381 3382 case 'stddeviation': 3383 return 'stdDeviation'; 3384 3385 case 'stitchtiles': 3386 return 'stitchTiles'; 3387 3388 case 'surfacescale': 3389 return 'surfaceScale'; 3390 3391 case 'systemlanguage': 3392 return 'systemLanguage'; 3393 3394 case 'tablevalues': 3395 return 'tableValues'; 3396 3397 case 'targetx': 3398 return 'targetX'; 3399 3400 case 'targety': 3401 return 'targetY'; 3402 3403 case 'textlength': 3404 return 'textLength'; 3405 3406 case 'viewbox': 3407 return 'viewBox'; 3408 3409 case 'viewtarget': 3410 return 'viewTarget'; 3411 3412 case 'xchannelselector': 3413 return 'xChannelSelector'; 3414 3415 case 'ychannelselector': 3416 return 'yChannelSelector'; 3417 3418 case 'zoomandpan': 3419 return 'zoomAndPan'; 3420 } 3421 } 3422 3423 if ( 'html' !== $namespace ) { 3424 switch ( $lower_name ) { 3425 case 'xlink:actuate': 3426 return 'xlink actuate'; 3427 3428 case 'xlink:arcrole': 3429 return 'xlink arcrole'; 3430 3431 case 'xlink:href': 3432 return 'xlink href'; 3433 3434 case 'xlink:role': 3435 return 'xlink role'; 3436 3437 case 'xlink:show': 3438 return 'xlink show'; 3439 3440 case 'xlink:title': 3441 return 'xlink title'; 3442 3443 case 'xlink:type': 3444 return 'xlink type'; 3445 3446 case 'xml:lang': 3447 return 'xml lang'; 3448 3449 case 'xml:space': 3450 return 'xml space'; 3451 3452 case 'xmlns': 3453 return 'xmlns'; 3454 3455 case 'xmlns:xlink': 3456 return 'xmlns xlink'; 3457 } 3458 } 3459 3460 return $attribute_name; 3461 } 3462 3463 /** 3464 * Indicates if the currently matched tag contains the self-closing flag. 3465 * 3466 * No HTML elements ought to have the self-closing flag and for those, the self-closing 3467 * flag will be ignored. For void elements this is benign because they "self close" 3468 * automatically. For non-void HTML elements though problems will appear if someone 3469 * intends to use a self-closing element in place of that element with an empty body. 3470 * For HTML foreign elements and custom elements the self-closing flag determines if 3471 * they self-close or not. 3472 * 3473 * This function does not determine if a tag is self-closing, 3474 * but only if the self-closing flag is present in the syntax. 3475 * 3476 * @since 6.3.0 3477 * 3478 * @return bool Whether the currently matched tag contains the self-closing flag. 3479 */ 3480 public function has_self_closing_flag(): bool { 3481 if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { 3482 return false; 3483 } 3484 3485 return $this->has_self_closing_flag; 3486 } 3487 3488 /** 3489 * Indicates if the current tag token is a tag closer. 3490 * 3491 * Example: 3492 * 3493 * $p = new WP_HTML_Tag_Processor( '<div></div>' ); 3494 * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); 3495 * $p->is_tag_closer() === false; 3496 * 3497 * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); 3498 * $p->is_tag_closer() === true; 3499 * 3500 * @since 6.2.0 3501 * @since 6.7.0 Reports all BR tags as opening tags. 3502 * 3503 * @return bool Whether the current tag is a tag closer. 3504 */ 3505 public function is_tag_closer(): bool { 3506 return ( 3507 self::STATE_MATCHED_TAG === $this->parser_state && 3508 $this->is_closing_tag && 3509 3510 /* 3511 * The BR tag can only exist as an opening tag. If something like `</br>` 3512 * appears then the HTML parser will treat it as an opening tag with no 3513 * attributes. The BR tag is unique in this way. 3514 * 3515 * @see https://html.spec.whatwg.org/#parsing-main-inbody 3516 */ 3517 'BR' !== $this->get_tag() 3518 ); 3519 } 3520 3521 /** 3522 * Indicates the kind of matched token, if any. 3523 * 3524 * This differs from `get_token_name()` in that it always 3525 * returns a static string indicating the type, whereas 3526 * `get_token_name()` may return values derived from the 3527 * token itself, such as a tag name or processing 3528 * instruction tag. 3529 * 3530 * Possible values: 3531 * - `#tag` when matched on a tag. 3532 * - `#text` when matched on a text node. 3533 * - `#cdata-section` when matched on a CDATA node. 3534 * - `#comment` when matched on a comment. 3535 * - `#doctype` when matched on a DOCTYPE declaration. 3536 * - `#presumptuous-tag` when matched on an empty tag closer. 3537 * - `#funky-comment` when matched on a funky comment. 3538 * - `#processing-instruction` when matched on a processing instruction. 3539 * 3540 * @since 6.5.0 3541 * @since 7.1.0 Recognizes processing instructions. 3542 * 3543 * @return string|null What kind of token is matched, or null. 3544 * @phpstan-return '#tag'|'#text'|'#cdata-section'|'#comment'|'#doctype'|'#presumptuous-tag'|'#funky-comment'|'#processing-instruction'|null 3545 */ 3546 public function get_token_type(): ?string { 3547 switch ( $this->parser_state ) { 3548 case self::STATE_MATCHED_TAG: 3549 return '#tag'; 3550 3551 case self::STATE_DOCTYPE: 3552 return '#doctype'; 3553 3554 default: 3555 return $this->get_token_name(); 3556 } 3557 } 3558 3559 /** 3560 * Returns the node name represented by the token. 3561 * 3562 * This matches the DOM API value `nodeName`. Some values 3563 * are static, such as `#text` for a text node, while others 3564 * are dynamically generated from the token itself. 3565 * 3566 * Dynamic names: 3567 * - Uppercase tag name for tag matches. 3568 * - `html` for DOCTYPE declarations. 3569 * 3570 * Note that if the Tag Processor is not matched on a token 3571 * then this function will return `null`, either because it 3572 * hasn't yet found a token or because it reached the end 3573 * of the document without matching a token. 3574 * 3575 * @since 6.5.0 3576 * 3577 * @return string|null Name of the matched token. 3578 */ 3579 public function get_token_name(): ?string { 3580 switch ( $this->parser_state ) { 3581 case self::STATE_MATCHED_TAG: 3582 return $this->get_tag(); 3583 3584 case self::STATE_TEXT_NODE: 3585 return '#text'; 3586 3587 case self::STATE_CDATA_NODE: 3588 return '#cdata-section'; 3589 3590 case self::STATE_COMMENT: 3591 return '#comment'; 3592 3593 case self::STATE_DOCTYPE: 3594 return 'html'; 3595 3596 case self::STATE_PRESUMPTUOUS_TAG: 3597 return '#presumptuous-tag'; 3598 3599 case self::STATE_FUNKY_COMMENT: 3600 return '#funky-comment'; 3601 3602 case self::STATE_PROCESSING_INSTRUCTION: 3603 return '#processing-instruction'; 3604 } 3605 3606 return null; 3607 } 3608 3609 /** 3610 * Indicates what kind of comment produced the comment node. 3611 * 3612 * Because there are different kinds of HTML syntax which produce 3613 * comments, the Tag Processor tracks and exposes this as a type 3614 * for the comment. Nominally only regular HTML comments exist as 3615 * they are commonly known, but a number of unrelated syntax errors 3616 * also produce comments. 3617 * 3618 * @see self::COMMENT_AS_ABRUPTLY_CLOSED_COMMENT 3619 * @see self::COMMENT_AS_CDATA_LOOKALIKE 3620 * @see self::COMMENT_AS_INVALID_HTML 3621 * @see self::COMMENT_AS_HTML_COMMENT 3622 * @see self::COMMENT_AS_PI_NODE_LOOKALIKE 3623 * 3624 * @since 6.5.0 3625 * 3626 * @return string|null 3627 */ 3628 public function get_comment_type(): ?string { 3629 if ( self::STATE_COMMENT !== $this->parser_state ) { 3630 return null; 3631 } 3632 3633 return $this->comment_type; 3634 } 3635 3636 /** 3637 * Returns the text of a matched comment or null if not on a comment type node. 3638 * 3639 * This method returns the entire text content of a comment node as it 3640 * would appear in the browser. 3641 * 3642 * This differs from {@see ::get_modifiable_text()} in that certain comment 3643 * types in the HTML API cannot allow their entire comment text content to 3644 * be modified. Namely, "bogus comments" of the form `<?xml not allowed in html>` 3645 * will create a comment whose text content starts with `?`. Note that if 3646 * that character were modified, it would be possible to change the node 3647 * type. 3648 * 3649 * @since 6.7.0 3650 * 3651 * @return string|null The comment text as it would appear in the browser or null 3652 * if not on a comment type node. 3653 */ 3654 public function get_full_comment_text(): ?string { 3655 if ( self::STATE_FUNKY_COMMENT === $this->parser_state ) { 3656 return $this->get_modifiable_text(); 3657 } 3658 3659 if ( self::STATE_COMMENT !== $this->parser_state ) { 3660 return null; 3661 } 3662 3663 switch ( $this->get_comment_type() ) { 3664 case self::COMMENT_AS_HTML_COMMENT: 3665 case self::COMMENT_AS_ABRUPTLY_CLOSED_COMMENT: 3666 return $this->get_modifiable_text(); 3667 3668 case self::COMMENT_AS_CDATA_LOOKALIKE: 3669 return "[CDATA[{$this->get_modifiable_text()}]]"; 3670 3671 case self::COMMENT_AS_PI_NODE_LOOKALIKE: 3672 return "?{$this->get_tag()}{$this->get_modifiable_text()}?"; 3673 3674 /* 3675 * This represents "bogus comments state" from HTML tokenization. 3676 * This can be entered by `<?` or `<!`, where `?` is included in 3677 * the comment text but `!` is not. 3678 */ 3679 case self::COMMENT_AS_INVALID_HTML: 3680 $preceding_character = $this->html[ $this->text_starts_at - 1 ]; 3681 $comment_start = '?' === $preceding_character ? '?' : ''; 3682 return "{$comment_start}{$this->get_modifiable_text()}"; 3683 } 3684 3685 return null; 3686 } 3687 3688 /** 3689 * Subdivides a matched text node, splitting NULL byte sequences and decoded whitespace as 3690 * distinct nodes prefixes. 3691 * 3692 * Note that once anything that's neither a NULL byte nor decoded whitespace is 3693 * encountered, then the remainder of the text node is left intact as generic text. 3694 * 3695 * - The HTML Processor uses this to apply distinct rules for different kinds of text. 3696 * - Inter-element whitespace can be detected and skipped with this method. 3697 * 3698 * Text nodes aren't eagerly subdivided because there's no need to split them unless 3699 * decisions are being made on NULL byte sequences or whitespace-only text. 3700 * 3701 * Example: 3702 * 3703 * $processor = new WP_HTML_Tag_Processor( "\x00Apples & Oranges" ); 3704 * true === $processor->next_token(); // Text is "Apples & Oranges". 3705 * true === $processor->subdivide_text_appropriately(); // Text is "". 3706 * true === $processor->next_token(); // Text is "Apples & Oranges". 3707 * false === $processor->subdivide_text_appropriately(); 3708 * 3709 * $processor = new WP_HTML_Tag_Processor( "
 \r\n\tMore" ); 3710 * true === $processor->next_token(); // Text is "␍ ␊␉More". 3711 * true === $processor->subdivide_text_appropriately(); // Text is "␍ ␊␉". 3712 * true === $processor->next_token(); // Text is "More". 3713 * false === $processor->subdivide_text_appropriately(); 3714 * 3715 * @since 6.7.0 3716 * 3717 * @return bool Whether the text node was subdivided. 3718 */ 3719 public function subdivide_text_appropriately(): bool { 3720 if ( self::STATE_TEXT_NODE !== $this->parser_state ) { 3721 return false; 3722 } 3723 3724 $this->text_node_classification = self::TEXT_IS_GENERIC; 3725 3726 /* 3727 * NULL bytes are treated categorically different than numeric character 3728 * references whose number is zero. `�` is not the same as `"\x00"`. 3729 */ 3730 $leading_nulls = strspn( $this->html, "\x00", $this->text_starts_at, $this->text_length ); 3731 if ( $leading_nulls > 0 ) { 3732 $this->token_length = $leading_nulls; 3733 $this->text_length = $leading_nulls; 3734 $this->bytes_already_parsed = $this->token_starts_at + $leading_nulls; 3735 $this->text_node_classification = self::TEXT_IS_NULL_SEQUENCE; 3736 return true; 3737 } 3738 3739 /* 3740 * Start a decoding loop to determine the point at which the 3741 * text subdivides. This entails raw whitespace bytes and any 3742 * character reference that decodes to the same. 3743 */ 3744 $at = $this->text_starts_at; 3745 $end = $this->text_starts_at + $this->text_length; 3746 while ( $at < $end ) { 3747 $skipped = strspn( $this->html, " \t\f\r\n", $at, $end - $at ); 3748 $at += $skipped; 3749 3750 if ( $at < $end && '&' === $this->html[ $at ] ) { 3751 $matched_byte_length = null; 3752 $replacement = WP_HTML_Decoder::read_character_reference( 'data', $this->html, $at, $matched_byte_length ); 3753 if ( isset( $replacement ) && 1 === strspn( $replacement, " \t\f\r\n" ) ) { 3754 $at += $matched_byte_length; 3755 continue; 3756 } 3757 } 3758 3759 break; 3760 } 3761 3762 if ( $at > $this->text_starts_at ) { 3763 $new_length = $at - $this->text_starts_at; 3764 $this->text_length = $new_length; 3765 $this->token_length = $new_length; 3766 $this->bytes_already_parsed = $at; 3767 $this->text_node_classification = self::TEXT_IS_WHITESPACE; 3768 return true; 3769 } 3770 3771 return false; 3772 } 3773 3774 /** 3775 * Returns the modifiable text for a matched token, or an empty string. 3776 * 3777 * Modifiable text is text content that may be read and changed without 3778 * changing the HTML structure of the document around it. This includes 3779 * the contents of `#text` nodes in the HTML as well as the inner 3780 * contents of HTML comments, Processing Instructions, and others, even 3781 * though these nodes aren't part of a parsed DOM tree. They also contain 3782 * the contents of SCRIPT and STYLE tags, of TEXTAREA tags, and of any 3783 * other section in an HTML document which cannot contain HTML markup (DATA). 3784 * 3785 * If a token has no modifiable text then an empty string is returned to 3786 * avoid needless crashing or type errors. An empty string does not mean 3787 * that a token has modifiable text, and a token with modifiable text may 3788 * have an empty string (e.g. a comment with no contents). 3789 * 3790 * Limitations: 3791 * 3792 * - This function will not strip the leading newline appropriately 3793 * after seeking into a LISTING or PRE element. To ensure that the 3794 * newline is treated properly, seek to the LISTING or PRE opening 3795 * tag instead of to the first text node inside the element. 3796 * 3797 * @since 6.5.0 3798 * @since 6.7.0 Replaces NULL bytes (U+0000) and newlines appropriately. 3799 * 3800 * @return string 3801 */ 3802 public function get_modifiable_text(): string { 3803 $has_enqueued_update = isset( $this->lexical_updates['modifiable text'] ); 3804 3805 if ( ! $has_enqueued_update && ( null === $this->text_starts_at || 0 === $this->text_length ) ) { 3806 return ''; 3807 } 3808 3809 $text = $has_enqueued_update 3810 ? $this->lexical_updates['modifiable text']->text 3811 : substr( $this->html, $this->text_starts_at, $this->text_length ); 3812 3813 /** 3814 * An enqueued processing instruction update holds normalized raw 3815 * syntax spanning from the end of the target through the end of 3816 * the token: a separating space, the data, and the `?>` closer. 3817 * The data is found by skipping the leading whitespace and 3818 * dropping the two bytes of the closer. 3819 * 3820 * @see WP_HTML_Tag_Processor::set_modifiable_text() 3821 */ 3822 if ( $has_enqueued_update && self::STATE_PROCESSING_INSTRUCTION === $this->parser_state ) { 3823 $text = substr( $text, strspn( $text, " \t\f\r\n" ), -2 ); 3824 } 3825 3826 /* 3827 * Pre-processing the input stream would normally happen before 3828 * any parsing is done, but deferring it means it's possible to 3829 * skip in most cases. When getting the modifiable text, however 3830 * it's important to apply the pre-processing steps, which is 3831 * normalizing newlines. 3832 * 3833 * @see https://html.spec.whatwg.org/#preprocessing-the-input-stream 3834 * @see https://infra.spec.whatwg.org/#normalize-newlines 3835 */ 3836 $text = str_replace( "\r\n", "\n", $text ); 3837 $text = str_replace( "\r", "\n", $text ); 3838 3839 // Comment and processing instruction data is not decoded. 3840 if ( 3841 self::STATE_CDATA_NODE === $this->parser_state || 3842 self::STATE_COMMENT === $this->parser_state || 3843 self::STATE_DOCTYPE === $this->parser_state || 3844 self::STATE_FUNKY_COMMENT === $this->parser_state || 3845 self::STATE_PROCESSING_INSTRUCTION === $this->parser_state 3846 ) { 3847 return str_replace( "\x00", "\u{FFFD}", $text ); 3848 } 3849 3850 $tag_name = $this->get_token_name(); 3851 if ( 3852 // Script data is not decoded. 3853 'SCRIPT' === $tag_name || 3854 3855 // RAWTEXT data is not decoded. 3856 'IFRAME' === $tag_name || 3857 'NOEMBED' === $tag_name || 3858 'NOFRAMES' === $tag_name || 3859 'STYLE' === $tag_name || 3860 'XMP' === $tag_name 3861 ) { 3862 return str_replace( "\x00", "\u{FFFD}", $text ); 3863 } 3864 3865 $decoded = WP_HTML_Decoder::decode_text_node( $text ); 3866 3867 /* 3868 * Skip the first line feed after LISTING, PRE, and TEXTAREA opening tags. 3869 * 3870 * Note that this first newline may come in the form of a character 3871 * reference, such as `
`, and so it's important to perform 3872 * this transformation only after decoding the raw text content. 3873 */ 3874 if ( 3875 ( "\n" === ( $decoded[0] ?? '' ) ) && 3876 ( ( $this->skip_newline_at === $this->token_starts_at && '#text' === $tag_name ) || 'TEXTAREA' === $tag_name ) 3877 ) { 3878 $decoded = substr( $decoded, 1 ); 3879 } 3880 3881 /* 3882 * Only in normative text nodes does the NULL byte (U+0000) get removed. 3883 * In all other contexts it's replaced by the replacement character (U+FFFD) 3884 * for security reasons (to avoid joining together strings that were safe 3885 * when separated, but not when joined). 3886 * 3887 * @todo Inside HTML integration points and MathML integration points, the 3888 * text is processed according to the insertion mode, not according 3889 * to the foreign content rules. This should strip the NULL bytes. 3890 */ 3891 return ( '#text' === $tag_name && 'html' === $this->get_namespace() ) 3892 ? str_replace( "\x00", '', $decoded ) 3893 : str_replace( "\x00", "\u{FFFD}", $decoded ); 3894 } 3895 3896 /** 3897 * Sets the modifiable text for the matched token, if matched. 3898 * 3899 * Modifiable text is text content that may be read and changed without 3900 * changing the HTML structure of the document around it. This includes 3901 * the contents of `#text` nodes in the HTML as well as the inner 3902 * contents of HTML comments, Processing Instructions, and others, even 3903 * though these nodes aren't part of a parsed DOM tree. They also contain 3904 * the contents of SCRIPT and STYLE tags, of TEXTAREA tags, and of any 3905 * other section in an HTML document which cannot contain HTML markup (DATA). 3906 * 3907 * Not all modifiable text may be set by this method, and not all content 3908 * may be set as modifiable text. In the case that this fails it will return 3909 * `false` indicating as much. For instance, if the contents of a SCRIPT 3910 * element are neither JavaScript nor JSON, it’s not possible to guarantee 3911 * that escaping strings like `</script>` won’t break the script; in these 3912 * cases, updates will be rejected and it’s up to calling code to perform 3913 * language-specific escaping or workarounds. Similarly, it will not allow 3914 * setting content into a comment which would prematurely terminate the comment, 3915 * or processing instruction data which cannot be represented: data containing 3916 * a `>`, which would prematurely terminate the processing instruction, or data 3917 * with leading whitespace, which is indistinguishable from the whitespace 3918 * separating the data from its target. 3919 * 3920 * Example: 3921 * 3922 * // Add a preface to all STYLE contents. 3923 * while ( $processor->next_tag( 'STYLE' ) ) { 3924 * $style = $processor->get_modifiable_text(); 3925 * $processor->set_modifiable_text( "// Made with love on the World Wide Web\n{$style}" ); 3926 * } 3927 * 3928 * // Replace smiley text with Emoji smilies. 3929 * while ( $processor->next_token() ) { 3930 * if ( '#text' !== $processor->get_token_name() ) { 3931 * continue; 3932 * } 3933 * 3934 * $chunk = $processor->get_modifiable_text(); 3935 * if ( ! str_contains( $chunk, ':)' ) ) { 3936 * continue; 3937 * } 3938 * 3939 * $processor->set_modifiable_text( str_replace( ':)', '🙂', $chunk ) ); 3940 * } 3941 * 3942 * This function handles all necessary HTML encoding. Provide normal, unescaped string values. 3943 * The HTML API will encode the strings appropriately so that the browser will interpret them 3944 * as the intended value. 3945 * 3946 * Example: 3947 * 3948 * // Renders as “Eggs & Milk” in a browser, encoded as `<p>Eggs & Milk</p>`. 3949 * $processor->set_modifiable_text( 'Eggs & Milk' ); 3950 * 3951 * // Renders as “Eggs & Milk” in a browser, encoded as `<p>Eggs &amp; Milk</p>`. 3952 * $processor->set_modifiable_text( 'Eggs & Milk' ); 3953 * 3954 * @since 6.7.0 3955 * @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping. 3956 * @since 7.1.0 Supports setting processing instruction data. 3957 * @since 7.2.0 Escapes content inside TITLE and TEXTAREA elements. 3958 * 3959 * @param string $plaintext_content New text content to represent in the matched token. 3960 * @return bool Whether the text was able to update. 3961 */ 3962 public function set_modifiable_text( string $plaintext_content ): bool { 3963 if ( self::STATE_TEXT_NODE === $this->parser_state ) { 3964 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 3965 $this->text_starts_at, 3966 $this->text_length, 3967 strtr( 3968 $plaintext_content, 3969 array( 3970 '<' => '<', 3971 '>' => '>', 3972 '&' => '&', 3973 '"' => '"', 3974 "'" => ''', 3975 ) 3976 ) 3977 ); 3978 3979 return true; 3980 } 3981 3982 // Comment data is not encoded. 3983 if ( 3984 self::STATE_COMMENT === $this->parser_state && 3985 self::COMMENT_AS_HTML_COMMENT === $this->comment_type 3986 ) { 3987 // Check if the text could close the comment. 3988 if ( 1 === preg_match( '/--!?>/', $plaintext_content ) ) { 3989 _doing_it_wrong( 3990 __METHOD__, 3991 __( 'Comment text cannot contain a comment closer.' ), 3992 '7.1.0' 3993 ); 3994 return false; 3995 } 3996 3997 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 3998 $this->text_starts_at, 3999 $this->text_length, 4000 $plaintext_content 4001 ); 4002 4003 return true; 4004 } 4005 4006 // Processing instruction data is not encoded. 4007 if ( self::STATE_PROCESSING_INSTRUCTION === $this->parser_state ) { 4008 /* 4009 * A processing instruction ends at the first `>` in its 4010 * raw syntax: data containing one cannot be represented. 4011 */ 4012 if ( str_contains( $plaintext_content, '>' ) ) { 4013 _doing_it_wrong( 4014 __METHOD__, 4015 __( 'Processing instruction data cannot contain ">".' ), 4016 '7.1.0' 4017 ); 4018 return false; 4019 } 4020 4021 /* 4022 * All whitespace between the target and the data is skipped when 4023 * parsing: data with leading whitespace cannot be represented. 4024 */ 4025 if ( 0 !== strspn( $plaintext_content, " \t\f\r\n" ) ) { 4026 _doing_it_wrong( 4027 __METHOD__, 4028 __( 'Processing instruction data cannot start with whitespace. Try ltrim( $plaintext_content, " \t\f\r\n" ).' ), 4029 '7.1.0' 4030 ); 4031 return false; 4032 } 4033 4034 /** 4035 * A single replacement spans from the end of the target through 4036 * the end of the token, normalizing the raw syntax for that 4037 * region into a fixed form: a separating space, the data, and 4038 * the `?>` closer. 4039 * 4040 * {@see self::get_modifiable_text()} performs necessary parsing to 4041 * return the correct processing instruction data based 4042 * on the modifiable text lexical update. 4043 */ 4044 $data_at = $this->tag_name_starts_at + $this->tag_name_length; 4045 4046 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 4047 $data_at, 4048 $this->token_starts_at + $this->token_length - $data_at, 4049 " {$plaintext_content}?>" 4050 ); 4051 4052 return true; 4053 } 4054 4055 /* 4056 * The rest of this function handles modifiable text for special "atomic" HTML elements. 4057 * Only tags in the HTML namespace should be processed. 4058 */ 4059 if ( 4060 self::STATE_MATCHED_TAG !== $this->parser_state || 4061 'html' !== $this->get_namespace() 4062 ) { 4063 _doing_it_wrong( 4064 __METHOD__, 4065 __( 'This token does not support setting modifiable text.' ), 4066 '7.1.0' 4067 ); 4068 return false; 4069 } 4070 4071 switch ( $this->get_tag() ) { 4072 case 'SCRIPT': 4073 $script_content_type = $this->get_script_content_type(); 4074 4075 switch ( $script_content_type ) { 4076 case 'javascript': 4077 case 'json': 4078 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 4079 $this->text_starts_at, 4080 $this->text_length, 4081 self::escape_javascript_script_contents( $plaintext_content ) 4082 ); 4083 return true; 4084 } 4085 4086 /* 4087 * If the script’s content type isn’t recognized and understandable then it’s 4088 * impossible to guarantee that escaping the content won’t cause runtime breakage. 4089 * For instance, if the script content type were PHP code then escaping with 4090 * `\u0073` would not be met by unescaping; rather, it could result in corrupted 4091 * data or even syntax errors. 4092 * 4093 * Because of this, content which could potentially modify the SCRIPT tag’s 4094 * HTML structure is rejected here. It’s the responsibility of calling code to 4095 * perform whatever semantic escaping is necessary to avoid problematic strings. 4096 * 4097 * Both the start tag `<script` and the end tag `</script` are rejected. It’s 4098 * easy to assume that only an end tag can alter the HTML structure, but a 4099 * start tag which follows `<!--` moves the tokenizer into the double-escaped 4100 * states, where a later `</script>` no longer closes the element. 4101 * 4102 * In both cases the tag name ends only at one of the characters matched 4103 * below, so text such as `</scriptx>` cannot change that structure and is 4104 * safe to set. 4105 * 4106 * @link https://html.spec.whatwg.org/#script-data-end-tag-name-state 4107 * @link https://html.spec.whatwg.org/#script-data-double-escape-start-state 4108 */ 4109 if ( 1 === preg_match( '~</?script[ \t\f\r\n/>]~i', $plaintext_content ) ) { 4110 _doing_it_wrong( 4111 __METHOD__, 4112 __( 'SCRIPT text with an unrecognized content type cannot contain a SCRIPT tag. Apply the escaping appropriate for the content type.' ), 4113 '7.1.0' 4114 ); 4115 return false; 4116 } 4117 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 4118 $this->text_starts_at, 4119 $this->text_length, 4120 $plaintext_content 4121 ); 4122 return true; 4123 4124 case 'IFRAME': 4125 case 'NOEMBED': 4126 case 'NOFRAMES': 4127 case 'XMP': 4128 $tag_name = $this->get_tag(); 4129 4130 /* 4131 * A tag name ends only at one of the characters matched below, so text 4132 * such as `</xmp-tag>` cannot close the element and is safe to set. 4133 * 4134 * @link https://html.spec.whatwg.org/#rawtext-end-tag-name-state 4135 */ 4136 if ( 1 === preg_match( '~</' . preg_quote( $tag_name, '~' ) . '[ \t\f\r\n/>]~i', $plaintext_content ) ) { 4137 _doing_it_wrong( 4138 __METHOD__, 4139 sprintf( 4140 /* translators: %s: HTML tag name. */ 4141 __( '%s text cannot contain its own closing tag.' ), 4142 $tag_name 4143 ), 4144 '7.1.0' 4145 ); 4146 return false; 4147 } 4148 4149 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 4150 $this->text_starts_at, 4151 $this->text_length, 4152 $plaintext_content 4153 ); 4154 4155 return true; 4156 4157 case 'STYLE': 4158 $plaintext_content = preg_replace_callback( 4159 '~</(?P<TAG_NAME>style)~i', 4160 static function ( $tag_match ) { 4161 return "\\3c\\2f{$tag_match['TAG_NAME']}"; 4162 }, 4163 $plaintext_content 4164 ); 4165 4166 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 4167 $this->text_starts_at, 4168 $this->text_length, 4169 $plaintext_content 4170 ); 4171 4172 return true; 4173 4174 case 'TEXTAREA': 4175 case 'TITLE': 4176 /** 4177 * While not expressly required, escaping syntax characters in these 4178 * elements will help avoid problems with downstream parser which 4179 * attempt to parse tags and other markup within. {@see \DOMDocument}, 4180 * for example, will claim to find elements as children of a `TITLE`. 4181 */ 4182 $plaintext_content = strtr( 4183 $plaintext_content, 4184 array( 4185 '<' => '<', 4186 '&' => '&', 4187 '>' => '>', 4188 ) 4189 ); 4190 4191 /* 4192 * HTML ignores a single leading newline in this context. If a leading newline 4193 * is intended, preserve it by adding an extra newline. 4194 */ 4195 if ( 4196 'TEXTAREA' === $this->get_tag() && 4197 1 === strspn( $plaintext_content, "\n\r", 0, 1 ) 4198 ) { 4199 $plaintext_content = "\n{$plaintext_content}"; 4200 } 4201 4202 /* 4203 * These don't _need_ to be escaped, but since they are decoded it's 4204 * safe to leave them escaped and this can prevent other code from 4205 * naively detecting tags within the contents. 4206 * 4207 * @todo It would be useful to prefix a multiline replacement text 4208 * with a newline, but not necessary. This is for aesthetics. 4209 */ 4210 $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( 4211 $this->text_starts_at, 4212 $this->text_length, 4213 $plaintext_content 4214 ); 4215 4216 return true; 4217 } 4218 4219 _doing_it_wrong( 4220 __METHOD__, 4221 __( 'Only the IFRAME, NOEMBED, NOFRAMES, SCRIPT, STYLE, TEXTAREA, TITLE, and XMP tags support setting modifiable text.' ), 4222 '7.1.0' 4223 ); 4224 return false; 4225 } 4226 4227 /** 4228 * Returns the content type of the currently-matched HTML SCRIPT tag, if matched and 4229 * recognized, otherwise returns `null` to indicate an unrecognized content type. 4230 * 4231 * An HTML SCRIPT tag is a normal SCRIPT tag, but there can be SCRIPT elements inside 4232 * SVG and MathML elements as well, and these have different parsing rules than those 4233 * in general HTML. For this reason, no content-type inference is performed on those. 4234 * 4235 * Note! This concept is related but distinct from the MIME type of the script. 4236 * Parsing MUST match the specific algorithm in the HTML specification, which 4237 * relies on exact string comparison in some cases. MIME type decoding may be 4238 * performed on SVG or MathML SCRIPT tags. 4239 * 4240 * Only 'javascript' and 'json' content types are currently recognized. 4241 * 4242 * @see https://html.spec.whatwg.org/multipage/scripting.html#prepare-the-script-element 4243 * 4244 * @since 7.0.0 4245 * @ignore 4246 * 4247 * @return 'javascript'|'json'|null Type of script element content if matched and recognized. 4248 */ 4249 private function get_script_content_type(): ?string { 4250 // SVG and MathML SCRIPT elements are not recognized. 4251 if ( 'SCRIPT' !== $this->get_tag() || $this->get_namespace() !== 'html' ) { 4252 return null; 4253 } 4254 4255 /* 4256 * > If any of the following are true: 4257 * > - el has a type attribute whose value is the empty string; 4258 * > - el has no type attribute but it has a language attribute and that attribute's 4259 * > value is the empty string; or 4260 * > - el has neither a type attribute nor a language attribute, 4261 * > then let the script block's type string for this script element be "text/javascript". 4262 */ 4263 $type = $this->get_attribute( 'type' ); 4264 $lang = $this->get_attribute( 'language' ); 4265 4266 if ( true === $type || '' === $type ) { 4267 return 'javascript'; 4268 } 4269 4270 if ( null === $type && ( null === $lang || true === $lang || '' === $lang ) ) { 4271 return 'javascript'; 4272 } 4273 4274 /* 4275 * > Otherwise, if el has a type attribute, then let the script block's type string be 4276 * > the value of that attribute with leading and trailing ASCII whitespace stripped. 4277 * > Otherwise, el has a non-empty language attribute; let the script block's type string 4278 * > be the concatenation of "text/" and the value of el's language attribute. 4279 */ 4280 $type_string = is_string( $type ) ? trim( $type, " \t\f\r\n" ) : "text/{$lang}"; 4281 4282 // All matches are ASCII case-insensitive; eagerly lower-case for comparison. 4283 $type_string = strtolower( $type_string ); 4284 4285 /* 4286 * > If the script block's type string is a JavaScript MIME type essence match, then 4287 * > set el's type to "classic". 4288 * 4289 * > A string is a JavaScript MIME type essence match if it is an ASCII case-insensitive 4290 * > match for one of the JavaScript MIME type essence strings. 4291 * 4292 * > A JavaScript MIME type is any MIME type whose essence is one of the following: 4293 * > 4294 * > - application/ecmascript 4295 * > - application/javascript 4296 * > - application/x-ecmascript 4297 * > - application/x-javascript 4298 * > - text/ecmascript 4299 * > - text/javascript 4300 * > - text/javascript1.0 4301 * > - text/javascript1.1 4302 * > - text/javascript1.2 4303 * > - text/javascript1.3 4304 * > - text/javascript1.4 4305 * > - text/javascript1.5 4306 * > - text/jscript 4307 * > - text/livescript 4308 * > - text/x-ecmascript 4309 * > - text/x-javascript 4310 * 4311 * @see https://mimesniff.spec.whatwg.org/#javascript-mime-type-essence-match 4312 * @see https://mimesniff.spec.whatwg.org/#javascript-mime-type 4313 */ 4314 switch ( $type_string ) { 4315 case 'application/ecmascript': 4316 case 'application/javascript': 4317 case 'application/x-ecmascript': 4318 case 'application/x-javascript': 4319 case 'text/ecmascript': 4320 case 'text/javascript': 4321 case 'text/javascript1.0': 4322 case 'text/javascript1.1': 4323 case 'text/javascript1.2': 4324 case 'text/javascript1.3': 4325 case 'text/javascript1.4': 4326 case 'text/javascript1.5': 4327 case 'text/jscript': 4328 case 'text/livescript': 4329 case 'text/x-ecmascript': 4330 case 'text/x-javascript': 4331 return 'javascript'; 4332 4333 /* 4334 * > Otherwise, if the script block's type string is an ASCII case-insensitive match for 4335 * > the string "module", then set el's type to "module". 4336 * 4337 * A module is evaluated as JavaScript. 4338 */ 4339 case 'module': 4340 return 'javascript'; 4341 4342 /* 4343 * > Otherwise, if the script block's type string is an ASCII case-insensitive match for the string "importmap", then set el's type to "importmap". 4344 * > Otherwise, if the script block's type string is an ASCII case-insensitive match for the string "speculationrules", then set el's type to "speculationrules". 4345 * 4346 * These conditions indicate JSON content. 4347 */ 4348 case 'importmap': 4349 case 'speculationrules': 4350 return 'json'; 4351 4352 /** @todo Rely on a full MIME parser for determining JSON content. */ 4353 case 'application/json': 4354 case 'text/json': 4355 return 'json'; 4356 } 4357 4358 /* 4359 * > Otherwise, return. (No script is executed, and el's type is left as null.) 4360 */ 4361 return null; 4362 } 4363 4364 /** 4365 * Escape JavaScript and JSON script tag contents. 4366 * 4367 * Ensure that the script contents cannot modify the HTML structure or break out 4368 * of its containing SCRIPT element. JavaScript and JSON may both be escaped with 4369 * the same rules, even though there are additional escaping measures available 4370 * to JavaScript source code which aren’t applicable to serialized JSON data. 4371 * 4372 * A simple method safely escapes all content except for a few extremely rare and 4373 * unlikely exceptions: prevent the appearance of `<script` and `</script` within 4374 * the contents by replacing the first letter of the tag name with a Unicode escape. 4375 * 4376 * Example: 4377 * 4378 * $plaintext = '<script>document.write( "A </script> closes a script." );</script>'; 4379 * $escaped = '<script>document.write( "A </\u0073cript> closes a script." );</script>'; 4380 * 4381 * This works because of how parsing changes after encountering an opening SCRIPT 4382 * tag. The actual parsing comprises a complicated state machine, the result of 4383 * legacy behaviors and diverse browser support. However, without these two strings 4384 * in the script contents, two key things are ensured: `</script>` cannot appear to 4385 * prematurely close the tag, and the problematic double-escaped state becomes 4386 * unreachable. A JavaScript engine or JSON decoder will then decode the Unicode 4387 * escape (`\u0073`) back into its original plaintext value, but only after having 4388 * been safely extracted from the HTML. 4389 * 4390 * While it may seem tempting to replace the `<` character instead, doing so would 4391 * break JavaScript syntax. The `<` character is used in comparison operators and 4392 * other JavaScript syntax; replacing it would break valid JavaScript. Replacing 4393 * only the `s` in `<script` and `</script` avoids modifying JavaScript syntax. 4394 * 4395 * ### Exceptions 4396 * 4397 * This _should_ work everywhere, but there are some extreme exceptions. 4398 * 4399 * - Comments. 4400 * - Tagged templates, such as `String.raw()`, which provide access to “raw” strings. 4401 * - The `source` property of a RegExp object. 4402 * 4403 * Each of these exceptions appear at the source code level, not at the semantic or 4404 * evaluation level. Normal JavaScript will remain semantically equivalent after escaping, 4405 * but any JavaScript which analyzes the raw source code will see potentially-different 4406 * values. 4407 * 4408 * #### Comments 4409 * 4410 * Comments are never unescaped because they aren’t parsed by the JavaScript engine. 4411 * When viewing the source in a browser’s developer tools, the comments will retain 4412 * their escaped text. 4413 * 4414 * Example: 4415 * 4416 * // A comment: "</script>" 4417 * …becomes… 4418 * // A comment: "</\u0073cript>" 4419 * 4420 * #### Tagged templates. 4421 * 4422 * Tagged templates “enable the embedding of arbitrary string content, where escape 4423 * sequences may follow a different syntax.” For example, they can aid representing 4424 * a RegExp pattern or LaTex snippet within a JavaScript string, where the string 4425 * escape characters might get noisy and distracting. 4426 * 4427 * Example: 4428 * 4429 * console.log( 'A \notin B' ); // Prints a newline because of the "\n". 4430 * console.log( 'A \\notin B' ); // Prints "A \notin B". 4431 * console.log( String.raw`A \notin B` ); // Prints "A \notin B". 4432 * 4433 * This means that if `<script` transforms into `<\u0073cript` _inside_ a raw string 4434 * or tagged template literal which relies on its `.raw` property, the output of the 4435 * code will be different after escaping. 4436 * 4437 * Example: 4438 * 4439 * console.log( String.raw`</script>` ); // Prematurely closes the SCRIPT element. 4440 * console.log( String.raw`</\u0073cript>` ); // Prints "</\u0073cript". 4441 * 4442 * #### RegExp sources. 4443 * 4444 * The RegExp object exposes its raw source in a similar way to how tagged templates and raw 4445 * strings do. Thankfully, because escape sequences are decoded when compiling the pattern, 4446 * escaped RegExp patterns will match the same way as the plaintext sequences would. 4447 * 4448 * Example: 4449 * 4450 * true === /<script>/.test( '<script>' ); 4451 * true === /<\u0073cript>/.test( '<script>' ); 4452 * 4453 * However, as with raw strings, any code which reads the source will see the escaped value 4454 * instead of the decoded one. 4455 * 4456 * Example: 4457 * 4458 * console.log( /<script>/.source ); // Prints "<script>". 4459 * console.log( /<\u0073cript>/.source ); // Prints "<\u0073cript>". 4460 * 4461 * #### Unsupported escaping. 4462 * 4463 * It is not possible to properly represent every possible JavaScript source file 4464 * inside a SCRIPT element. As with CSS stylesheets, SVG images, and MathML, the 4465 * only 100% reliable way to represent all possible inputs is to link to external 4466 * files of the given content-type. 4467 * 4468 * In some cases it’s possible to manually prevent escaping issues. These are not 4469 * automatically handled by this function because doing so would require a full 4470 * JavaScript tokenizer. Consider the following example listing various ways to 4471 * manually escape a closing script tag. 4472 * 4473 * Example: 4474 * 4475 * console.log( String.raw`</script>` ); // !!UNSAFE!! Will be escaped. 4476 * console.log( String.raw`</\u0073cript>` ); // "</\u0073cript>" 4477 * console.log( String.raw`</scr` + String.raw`ipt>` ); // "</script>" 4478 * console.log( String.raw`</${"script"}>` ); // "</script>" 4479 * console.log( '</scr' + 'ipt>' ); // "</script>" 4480 * console.log( "\x3C/script>" ); // "</script>" 4481 * console.log( "<\/script>" ); // "</script>" 4482 * 4483 * The following graph is a simplified interpretation of how HTML interprets the contents 4484 * of a SCRIPT tag and identifies the closing tag. It is useful to understand what text 4485 * is dangerous inside of a SCRIPT tag and why different approaches to escaping work. 4486 * 4487 * Open script 4488 * │ 4489 * ▼ 4490 * ╔═════════════════════════════════════════╗ <!--(…)> 4491 * ║ ║ (all dashes) 4492 * ║ script ╟────────────────╮ 4493 * ║ data ║ │ 4494 * ╭───────────╢ ║ ◀──────────────╯ 4495 * │ ╚═╤═══════════════════════════════════════╝ 4496 * │ │ ▲ ▲ 4497 * │ │ <!-- │ --> ╰─────╮ 4498 * │ ▼ │ │ 4499 * │ ┌─────────────────┴───────────────────────┐ │ 4500 * │ </script¹ │ escaped │ │ 4501 * │ └─┬─────────────────────────────┬─────────┘ │ 4502 * │ │ ▲ │ │ --> 4503 * │ │ </script¹ │ </script¹ │ <script¹ │ 4504 * │ ▼ │ ▼ │ 4505 * │ ╔══════════════╗ │ ┌───────────┐ │ 4506 * │ ║ Close script ║ │ │ double │ │ 4507 * ╰──────────▶║ ║ ╰───────────┤ escaped ├──╯ 4508 * ╚══════════════╝ └───────────┘ 4509 * 4510 * ¹ = Case insensitive 'script' followed by one of ' \t\f\r\n/>', known 4511 * as “tag-name-terminating characters.” This sequence forms the start 4512 * of what could be a SCRIPT opening or closing tag. 4513 * 4514 * @see https://html.spec.whatwg.org/#restrictions-for-contents-of-script-elements 4515 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#specifications 4516 * @see wp_html_api_script_element_escaping_diagram_source() 4517 * 4518 * @since 7.0.0 4519 * @ignore 4520 * 4521 * @param string $sourcecode Raw contents intended to be serialized into an HTML SCRIPT element. 4522 * @return string Escaped form of input contents which will not lead to premature closing of the containing SCRIPT element. 4523 */ 4524 private static function escape_javascript_script_contents( string $sourcecode ): string { 4525 $at = 0; 4526 $was_at = 0; 4527 $end = strlen( $sourcecode ); 4528 $escaped = ''; 4529 4530 /* 4531 * Replace all instances of the ASCII case-insensitive match of "<script" 4532 * and "</script", when followed by whitespace or "/" or ">", by using a 4533 * character replacement for the "s" (or the "S"). 4534 */ 4535 while ( $at < $end ) { 4536 $tag_at = strpos( $sourcecode, '<', $at ); 4537 if ( false === $tag_at ) { 4538 break; 4539 } 4540 4541 $tag_name_at = $tag_at + 1; 4542 $has_closing_slash = $tag_name_at < $end && '/' === $sourcecode[ $tag_name_at ]; 4543 $tag_name_at += $has_closing_slash ? 1 : 0; 4544 4545 if ( 0 !== substr_compare( $sourcecode, 'script', $tag_name_at, 6, true ) ) { 4546 $at = $tag_at + 1; 4547 continue; 4548 } 4549 4550 if ( 1 !== strspn( $sourcecode, " \t\f\r\n/>", $tag_name_at + 6, 1 ) ) { 4551 $at = $tag_name_at + 6; 4552 continue; 4553 } 4554 4555 $escaped .= substr( $sourcecode, $was_at, $tag_name_at - $was_at ); 4556 $escaped .= 's' === $sourcecode[ $tag_name_at ] ? '\u0073' : '\u0053'; 4557 $was_at = $tag_name_at + 1; 4558 $at = $tag_name_at + 7; 4559 } 4560 4561 if ( '' === $escaped ) { 4562 return $sourcecode; 4563 } 4564 4565 if ( $was_at < $end ) { 4566 $escaped .= substr( $sourcecode, $was_at ); 4567 } 4568 4569 return $escaped; 4570 } 4571 4572 /** 4573 * Updates or creates a new attribute on the currently matched tag with the passed value. 4574 * 4575 * This function handles all necessary HTML encoding. Provide normal, unescaped string values. 4576 * The HTML API will encode the strings appropriately so that the browser will interpret them 4577 * as the intended value. 4578 * 4579 * Example: 4580 * 4581 * // Renders “Eggs & Milk” in a browser, encoded as `<abbr title="Eggs & Milk">`. 4582 * $processor->set_attribute( 'title', 'Eggs & Milk' ); 4583 * 4584 * // Renders “Eggs & Milk” in a browser, encoded as `<abbr title="Eggs &amp; Milk">`. 4585 * $processor->set_attribute( 'title', 'Eggs & Milk' ); 4586 * 4587 * // Renders `true` as `<abbr title>`. 4588 * $processor->set_attribute( 'title', true ); 4589 * 4590 * // Renders without the attribute for `false` as `<abbr>`. 4591 * $processor->set_attribute( 'title', false ); 4592 * 4593 * Special handling is provided for boolean attribute values: 4594 * - When `true` is passed as the value, then only the attribute name is added to the tag. 4595 * - When `false` is passed, the attribute gets removed if it existed before. 4596 * 4597 * @since 6.2.0 4598 * @since 6.2.1 Fix: Only create a single update for multiple calls with case-variant attribute names. 4599 * @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping. 4600 * 4601 * @param string $name The attribute name to target. 4602 * @param string|bool $value The new attribute value. 4603 * @return bool Whether an attribute value was set. 4604 */ 4605 public function set_attribute( $name, $value ): bool { 4606 if ( 4607 self::STATE_MATCHED_TAG !== $this->parser_state || 4608 $this->is_closing_tag 4609 ) { 4610 return false; 4611 } 4612 4613 $name_length = strlen( $name ); 4614 4615 /** 4616 * WordPress rejects more characters than are strictly forbidden 4617 * in HTML5. This is to prevent additional security risks deeper 4618 * in the WordPress and plugin stack. Specifically the following 4619 * are not allowed to be set as part of an HTML attribute name: 4620 * 4621 * - greater-than “>” 4622 * - ampersand “&” 4623 * 4624 * @see https://html.spec.whatwg.org/#attributes-2 4625 */ 4626 if ( 4627 0 === $name_length || 4628 // Syntax-like characters. 4629 strcspn( $name, '"\'>&</ =' ) !== $name_length || 4630 // Control characters. 4631 strcspn( 4632 $name, 4633 "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F" . 4634 "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F" 4635 ) !== $name_length || 4636 // Unicode noncharacters. 4637 wp_has_noncharacters( $name ) 4638 ) { 4639 _doing_it_wrong( 4640 __METHOD__, 4641 __( 'Invalid attribute name.' ), 4642 '6.2.0' 4643 ); 4644 4645 return false; 4646 } 4647 4648 /* 4649 * > The values "true" and "false" are not allowed on boolean attributes. 4650 * > To represent a false value, the attribute has to be omitted altogether. 4651 * - HTML5 spec, https://html.spec.whatwg.org/#boolean-attributes 4652 */ 4653 if ( false === $value ) { 4654 return $this->remove_attribute( $name ); 4655 } 4656 4657 if ( true === $value ) { 4658 $updated_attribute = $name; 4659 } else { 4660 $comparable_name = strtolower( $name ); 4661 4662 /** 4663 * Escape attribute values appropriately. 4664 * 4665 * @see https://html.spec.whatwg.org/#attributes-3 4666 */ 4667 $escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true ) 4668 ? esc_url( $value ) 4669 : strtr( 4670 $value, 4671 array( 4672 '<' => '<', 4673 '>' => '>', 4674 '&' => '&', 4675 '"' => '"', 4676 "'" => ''', 4677 ) 4678 ); 4679 4680 // If the escaping functions wiped out the update, reject it and indicate it was rejected. 4681 if ( '' === $escaped_new_value && '' !== $value ) { 4682 return false; 4683 } 4684 4685 $updated_attribute = "{$name}=\"{$escaped_new_value}\""; 4686 } 4687 4688 /* 4689 * > There must never be two or more attributes on 4690 * > the same start tag whose names are an ASCII 4691 * > case-insensitive match for each other. 4692 * - HTML 5 spec 4693 * 4694 * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive 4695 */ 4696 $comparable_name = strtolower( $name ); 4697 4698 if ( isset( $this->attributes[ $comparable_name ] ) ) { 4699 /* 4700 * Update an existing attribute. 4701 * 4702 * Example – set attribute id to "new" in <div id="initial_id" />: 4703 * 4704 * <div id="initial_id"/> 4705 * ^-------------^ 4706 * start end 4707 * replacement: `id="new"` 4708 * 4709 * Result: <div id="new"/> 4710 */ 4711 $existing_attribute = $this->attributes[ $comparable_name ]; 4712 $this->lexical_updates[ $comparable_name ] = new WP_HTML_Text_Replacement( 4713 $existing_attribute->start, 4714 $existing_attribute->length, 4715 $updated_attribute 4716 ); 4717 } else { 4718 /* 4719 * Create a new attribute at the tag's name end. 4720 * 4721 * Example – add attribute id="new" to <div />: 4722 * 4723 * <div/> 4724 * ^ 4725 * start and end 4726 * replacement: ` id="new"` 4727 * 4728 * Result: <div id="new"/> 4729 */ 4730 $this->lexical_updates[ $comparable_name ] = new WP_HTML_Text_Replacement( 4731 $this->tag_name_starts_at + $this->tag_name_length, 4732 0, 4733 ' ' . $updated_attribute 4734 ); 4735 } 4736 4737 /* 4738 * Any calls to update the `class` attribute directly should wipe out any 4739 * enqueued class changes from `add_class` and `remove_class`. 4740 */ 4741 if ( 'class' === $comparable_name && ! empty( $this->classname_updates ) ) { 4742 $this->classname_updates = array(); 4743 } 4744 4745 return true; 4746 } 4747 4748 /** 4749 * Remove an attribute from the currently-matched tag. 4750 * 4751 * @since 6.2.0 4752 * 4753 * @param string $name The attribute name to remove. 4754 * @return bool Whether an attribute was removed. 4755 */ 4756 public function remove_attribute( $name ): bool { 4757 if ( 4758 self::STATE_MATCHED_TAG !== $this->parser_state || 4759 $this->is_closing_tag 4760 ) { 4761 return false; 4762 } 4763 4764 /* 4765 * > There must never be two or more attributes on 4766 * > the same start tag whose names are an ASCII 4767 * > case-insensitive match for each other. 4768 * - HTML 5 spec 4769 * 4770 * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive 4771 */ 4772 $name = strtolower( $name ); 4773 4774 /* 4775 * Any calls to update the `class` attribute directly should wipe out any 4776 * enqueued class changes from `add_class` and `remove_class`. 4777 */ 4778 if ( 'class' === $name && count( $this->classname_updates ) !== 0 ) { 4779 $this->classname_updates = array(); 4780 } 4781 4782 /* 4783 * If updating an attribute that didn't exist in the input 4784 * document, then remove the enqueued update and move on. 4785 * 4786 * For example, this might occur when calling `remove_attribute()` 4787 * after calling `set_attribute()` for the same attribute 4788 * and when that attribute wasn't originally present. 4789 */ 4790 if ( ! isset( $this->attributes[ $name ] ) ) { 4791 if ( isset( $this->lexical_updates[ $name ] ) ) { 4792 unset( $this->lexical_updates[ $name ] ); 4793 } 4794 return false; 4795 } 4796 4797 /* 4798 * Removes an existing tag attribute. 4799 * 4800 * Example – remove the attribute id from <div id="main"/>: 4801 * <div id="initial_id"/> 4802 * ^-------------^ 4803 * start end 4804 * replacement: `` 4805 * 4806 * Result: <div /> 4807 */ 4808 $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( 4809 $this->attributes[ $name ]->start, 4810 $this->attributes[ $name ]->length, 4811 '' 4812 ); 4813 4814 // Removes any duplicated attributes if they were also present. 4815 foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) { 4816 $this->lexical_updates[] = new WP_HTML_Text_Replacement( 4817 $attribute_token->start, 4818 $attribute_token->length, 4819 '' 4820 ); 4821 } 4822 4823 return true; 4824 } 4825 4826 /** 4827 * Adds a new class name to the currently matched tag. 4828 * 4829 * @since 6.2.0 4830 * 4831 * @param string $class_name The class name to add. 4832 * @return bool Whether the class was set to be added. 4833 */ 4834 public function add_class( $class_name ): bool { 4835 if ( 4836 self::STATE_MATCHED_TAG !== $this->parser_state || 4837 $this->is_closing_tag 4838 ) { 4839 return false; 4840 } 4841 4842 if ( self::QUIRKS_MODE !== $this->compat_mode ) { 4843 $this->classname_updates[ $class_name ] = self::ADD_CLASS; 4844 return true; 4845 } 4846 4847 /* 4848 * Because class names are matched ASCII-case-insensitively in quirks mode, 4849 * this needs to see if a case variant of the given class name is already 4850 * enqueued and update that existing entry, if so. This picks the casing of 4851 * the first-provided class name for all lexical variations. 4852 */ 4853 $class_name_length = strlen( $class_name ); 4854 foreach ( $this->classname_updates as $updated_name => $action ) { 4855 if ( 4856 strlen( $updated_name ) === $class_name_length && 4857 0 === substr_compare( $updated_name, $class_name, 0, $class_name_length, true ) 4858 ) { 4859 $this->classname_updates[ $updated_name ] = self::ADD_CLASS; 4860 return true; 4861 } 4862 } 4863 4864 $this->classname_updates[ $class_name ] = self::ADD_CLASS; 4865 return true; 4866 } 4867 4868 /** 4869 * Removes a class name from the currently matched tag. 4870 * 4871 * @since 6.2.0 4872 * 4873 * @param string $class_name The class name to remove. 4874 * @return bool Whether the class was set to be removed. 4875 */ 4876 public function remove_class( $class_name ): bool { 4877 if ( 4878 self::STATE_MATCHED_TAG !== $this->parser_state || 4879 $this->is_closing_tag 4880 ) { 4881 return false; 4882 } 4883 4884 if ( self::QUIRKS_MODE !== $this->compat_mode ) { 4885 $this->classname_updates[ $class_name ] = self::REMOVE_CLASS; 4886 return true; 4887 } 4888 4889 /* 4890 * Because class names are matched ASCII-case-insensitively in quirks mode, 4891 * this needs to see if a case variant of the given class name is already 4892 * enqueued and update that existing entry, if so. This picks the casing of 4893 * the first-provided class name for all lexical variations. 4894 */ 4895 $class_name_length = strlen( $class_name ); 4896 foreach ( $this->classname_updates as $updated_name => $action ) { 4897 if ( 4898 strlen( $updated_name ) === $class_name_length && 4899 0 === substr_compare( $updated_name, $class_name, 0, $class_name_length, true ) 4900 ) { 4901 $this->classname_updates[ $updated_name ] = self::REMOVE_CLASS; 4902 return true; 4903 } 4904 } 4905 4906 $this->classname_updates[ $class_name ] = self::REMOVE_CLASS; 4907 return true; 4908 } 4909 4910 /** 4911 * Returns the string representation of the HTML Tag Processor. 4912 * 4913 * @since 6.2.0 4914 * 4915 * @see WP_HTML_Tag_Processor::get_updated_html() 4916 * 4917 * @return string The processed HTML. 4918 */ 4919 public function __toString(): string { 4920 return $this->get_updated_html(); 4921 } 4922 4923 /** 4924 * Returns the string representation of the HTML Tag Processor. 4925 * 4926 * @since 6.2.0 4927 * @since 6.2.1 Shifts the internal cursor corresponding to the applied updates. 4928 * @since 6.4.0 No longer calls subclass method `next_tag()` after updating HTML. 4929 * 4930 * @return string The processed HTML. 4931 */ 4932 public function get_updated_html(): string { 4933 $requires_no_updating = 0 === count( $this->classname_updates ) && 0 === count( $this->lexical_updates ); 4934 4935 /* 4936 * When there is nothing more to update and nothing has already been 4937 * updated, return the original document and avoid a string copy. 4938 */ 4939 if ( $requires_no_updating ) { 4940 return $this->html; 4941 } 4942 4943 /* 4944 * Keep track of the position right before the current tag. This will 4945 * be necessary for reparsing the current tag after updating the HTML. 4946 */ 4947 $before_current_tag = $this->token_starts_at ?? 0; 4948 4949 /* 4950 * 1. Apply the enqueued edits and update all the pointers to reflect those changes. 4951 */ 4952 $this->class_name_updates_to_attributes_updates(); 4953 $before_current_tag += $this->apply_attributes_updates( $before_current_tag ); 4954 4955 /* 4956 * 2. Rewind to before the current tag and reparse to get updated attributes. 4957 * 4958 * At this point the internal cursor points to the end of the tag name. 4959 * Rewind before the tag name starts so that it's as if the cursor didn't 4960 * move; a call to `next_tag()` will reparse the recently-updated attributes 4961 * and additional calls to modify the attributes will apply at this same 4962 * location, but in order to avoid issues with subclasses that might add 4963 * behaviors to `next_tag()`, the internal methods should be called here 4964 * instead. 4965 * 4966 * It's important to note that in this specific place there will be no change 4967 * because the processor was already at a tag when this was called and it's 4968 * rewinding only to the beginning of this very tag before reprocessing it 4969 * and its attributes. 4970 * 4971 * <p>Previous HTML<em>More HTML</em></p> 4972 * ↑ │ back up by the length of the tag name plus the opening < 4973 * └←─┘ back up by strlen("em") + 1 ==> 3 4974 */ 4975 $this->bytes_already_parsed = $before_current_tag; 4976 $this->base_class_next_token(); 4977 4978 return $this->html; 4979 } 4980 4981 /** 4982 * Parses tag query input into internal search criteria. 4983 * 4984 * @since 6.2.0 4985 * @ignore 4986 * 4987 * @param array|string|null $query { 4988 * Optional. Which tag name to find, having which class, etc. Default is to find any tag. 4989 * 4990 * @type string|null $tag_name Which tag to find, or `null` for "any tag." 4991 * @type int|null $match_offset Find the Nth tag matching all search criteria. 4992 * 1 for "first" tag, 3 for "third," etc. 4993 * Defaults to first tag. 4994 * @type string|null $class_name Tag must contain this class name to match. 4995 * @type string $tag_closers "visit" or "skip": whether to stop on tag closers, e.g. </div>. 4996 * } 4997 */ 4998 private function parse_query( $query ) { 4999 if ( null !== $query && $query === $this->last_query ) { 5000 return; 5001 } 5002 5003 $this->last_query = $query; 5004 $this->sought_tag_name = null; 5005 $this->sought_class_name = null; 5006 $this->sought_match_offset = 1; 5007 $this->stop_on_tag_closers = false; 5008 5009 // A single string value means "find the tag of this name". 5010 if ( is_string( $query ) ) { 5011 $this->sought_tag_name = $query; 5012 return; 5013 } 5014 5015 // An empty query parameter applies no restrictions on the search. 5016 if ( null === $query ) { 5017 return; 5018 } 5019 5020 // If not using the string interface, an associative array is required. 5021 if ( ! is_array( $query ) ) { 5022 _doing_it_wrong( 5023 __METHOD__, 5024 __( 'The query argument must be an array or a tag name.' ), 5025 '6.2.0' 5026 ); 5027 return; 5028 } 5029 5030 if ( isset( $query['tag_name'] ) && is_string( $query['tag_name'] ) ) { 5031 $this->sought_tag_name = $query['tag_name']; 5032 } 5033 5034 if ( isset( $query['class_name'] ) && is_string( $query['class_name'] ) ) { 5035 $this->sought_class_name = $query['class_name']; 5036 } 5037 5038 if ( isset( $query['match_offset'] ) && is_int( $query['match_offset'] ) && 0 < $query['match_offset'] ) { 5039 $this->sought_match_offset = $query['match_offset']; 5040 } 5041 5042 if ( isset( $query['tag_closers'] ) ) { 5043 $this->stop_on_tag_closers = 'visit' === $query['tag_closers']; 5044 } 5045 } 5046 5047 5048 /** 5049 * Checks whether a given tag and its attributes match the search criteria. 5050 * 5051 * @since 6.2.0 5052 * @ignore 5053 * 5054 * @return bool Whether the given tag and its attribute match the search criteria. 5055 */ 5056 private function matches(): bool { 5057 if ( $this->is_closing_tag && ! $this->stop_on_tag_closers ) { 5058 return false; 5059 } 5060 5061 // Does the tag name match the requested tag name in a case-insensitive manner? 5062 if ( isset( $this->sought_tag_name ) ) { 5063 $tag_name = $this->get_tag(); 5064 if ( 5065 strlen( $this->sought_tag_name ) !== strlen( $tag_name ) || 5066 0 !== substr_compare( $tag_name, $this->sought_tag_name, 0, null, true ) 5067 ) { 5068 return false; 5069 } 5070 } 5071 5072 if ( null !== $this->sought_class_name && ! $this->has_class( $this->sought_class_name ) ) { 5073 return false; 5074 } 5075 5076 return true; 5077 } 5078 5079 /** 5080 * Gets DOCTYPE declaration info from a DOCTYPE token. 5081 * 5082 * DOCTYPE tokens may appear in many places in an HTML document. In most places, they are 5083 * simply ignored. The main parsing functions find the basic shape of DOCTYPE tokens but 5084 * do not perform detailed parsing. 5085 * 5086 * This method can be called to perform a full parse of the DOCTYPE token and retrieve 5087 * its information. 5088 * 5089 * @return WP_HTML_Doctype_Info|null The DOCTYPE declaration information or `null` if not 5090 * currently at a DOCTYPE node. 5091 */ 5092 public function get_doctype_info(): ?WP_HTML_Doctype_Info { 5093 if ( self::STATE_DOCTYPE !== $this->parser_state ) { 5094 return null; 5095 } 5096 5097 return WP_HTML_Doctype_Info::from_doctype_token( substr( $this->html, $this->token_starts_at, $this->token_length ) ); 5098 } 5099 5100 /** 5101 * Parser Ready State. 5102 * 5103 * Indicates that the parser is ready to run and waiting for a state transition. 5104 * It may not have started yet, or it may have just finished parsing a token and 5105 * is ready to find the next one. 5106 * 5107 * @since 6.5.0 5108 * 5109 * @access private 5110 */ 5111 const STATE_READY = 'STATE_READY'; 5112 5113 /** 5114 * Parser Complete State. 5115 * 5116 * Indicates that the parser has reached the end of the document and there is 5117 * nothing left to scan. It finished parsing the last token completely. 5118 * 5119 * @since 6.5.0 5120 * 5121 * @access private 5122 */ 5123 const STATE_COMPLETE = 'STATE_COMPLETE'; 5124 5125 /** 5126 * Parser Incomplete Input State. 5127 * 5128 * Indicates that the parser has reached the end of the document before finishing 5129 * a token. It started parsing a token but there is a possibility that the input 5130 * HTML document was truncated in the middle of a token. 5131 * 5132 * The parser is reset at the start of the incomplete token and has paused. There 5133 * is nothing more than can be scanned unless provided a more complete document. 5134 * 5135 * @since 6.5.0 5136 * 5137 * @access private 5138 */ 5139 const STATE_INCOMPLETE_INPUT = 'STATE_INCOMPLETE_INPUT'; 5140 5141 /** 5142 * Parser Matched Tag State. 5143 * 5144 * Indicates that the parser has found an HTML tag and it's possible to get 5145 * the tag name and read or modify its attributes (if it's not a closing tag). 5146 * 5147 * @since 6.5.0 5148 * 5149 * @access private 5150 */ 5151 const STATE_MATCHED_TAG = 'STATE_MATCHED_TAG'; 5152 5153 /** 5154 * Parser Text Node State. 5155 * 5156 * Indicates that the parser has found a text node and it's possible 5157 * to read and modify that text. 5158 * 5159 * @since 6.5.0 5160 * 5161 * @access private 5162 */ 5163 const STATE_TEXT_NODE = 'STATE_TEXT_NODE'; 5164 5165 /** 5166 * Parser CDATA Node State. 5167 * 5168 * Indicates that the parser has found a CDATA node and it's possible 5169 * to read and modify its modifiable text. Note that in HTML there are 5170 * no CDATA nodes outside of foreign content (SVG and MathML). Outside 5171 * of foreign content, they are treated as HTML comments. 5172 * 5173 * @since 6.5.0 5174 * 5175 * @access private 5176 */ 5177 const STATE_CDATA_NODE = 'STATE_CDATA_NODE'; 5178 5179 /** 5180 * Indicates that the parser has found an HTML comment and it's 5181 * possible to read and modify its modifiable text. 5182 * 5183 * @since 6.5.0 5184 * 5185 * @access private 5186 */ 5187 const STATE_COMMENT = 'STATE_COMMENT'; 5188 5189 /** 5190 * Indicates that the parser has found a DOCTYPE node and it's 5191 * possible to read its DOCTYPE information via `get_doctype_info()`. 5192 * 5193 * @since 6.5.0 5194 * 5195 * @access private 5196 */ 5197 const STATE_DOCTYPE = 'STATE_DOCTYPE'; 5198 5199 /** 5200 * Indicates that the parser has found an empty tag closer `</>`. 5201 * 5202 * Note that in HTML there are no empty tag closers, and they 5203 * are ignored. Nonetheless, the Tag Processor still 5204 * recognizes them as they appear in the HTML stream. 5205 * 5206 * These were historically discussed as a "presumptuous tag 5207 * closer," which would close the nearest open tag, but were 5208 * dismissed in favor of explicitly-closing tags. 5209 * 5210 * @since 6.5.0 5211 * 5212 * @access private 5213 */ 5214 const STATE_PRESUMPTUOUS_TAG = 'STATE_PRESUMPTUOUS_TAG'; 5215 5216 /** 5217 * Indicates that the parser has found a "funky comment" 5218 * and it's possible to read and modify its modifiable text. 5219 * 5220 * Example: 5221 * 5222 * </%url> 5223 * </{"wp-bit":"query/post-author"}> 5224 * </2> 5225 * 5226 * Funky comments are tag closers with invalid tag names. Note 5227 * that in HTML these are turned into bogus comments. Nonetheless, 5228 * the Tag Processor recognizes them in a stream of HTML and 5229 * exposes them for inspection and modification. 5230 * 5231 * @since 6.5.0 5232 * 5233 * @access private 5234 */ 5235 const STATE_FUNKY_COMMENT = 'STATE_WP_FUNKY'; 5236 5237 /** 5238 * Indicates that the parser has found a processing instruction 5239 * and it's possible to read its target and data. 5240 * 5241 * Example: 5242 * 5243 * <?wp-bit {"just": "kidding"}> 5244 * 5245 * Processing instructions with an allowable target are parsed 5246 * into processing instruction nodes. The reserved `xml` and 5247 * `xml-stylesheet` targets, and targets with characters outside 5248 * an ASCII-representable subset, are turned into bogus comments. 5249 * 5250 * @link https://html.spec.whatwg.org/multipage/parsing.html#processing-instruction-open-state 5251 * 5252 * @since 7.1.0 5253 * 5254 * @access private 5255 */ 5256 const STATE_PROCESSING_INSTRUCTION = 'STATE_PROCESSING_INSTRUCTION'; 5257 5258 /** 5259 * Indicates that a comment was created when encountering abruptly-closed HTML comment. 5260 * 5261 * Example: 5262 * 5263 * <!--> 5264 * <!---> 5265 * 5266 * @since 6.5.0 5267 */ 5268 const COMMENT_AS_ABRUPTLY_CLOSED_COMMENT = 'COMMENT_AS_ABRUPTLY_CLOSED_COMMENT'; 5269 5270 /** 5271 * Indicates that a comment would be parsed as a CDATA node, 5272 * were HTML to allow CDATA nodes outside of foreign content. 5273 * 5274 * Example: 5275 * 5276 * <![CDATA[This is a CDATA node.]]> 5277 * 5278 * This is an HTML comment, but it looks like a CDATA node. 5279 * 5280 * @since 6.5.0 5281 */ 5282 const COMMENT_AS_CDATA_LOOKALIKE = 'COMMENT_AS_CDATA_LOOKALIKE'; 5283 5284 /** 5285 * Indicates that a comment was created when encountering 5286 * normative HTML comment syntax. 5287 * 5288 * Example: 5289 * 5290 * <!-- this is a comment --> 5291 * 5292 * @since 6.5.0 5293 */ 5294 const COMMENT_AS_HTML_COMMENT = 'COMMENT_AS_HTML_COMMENT'; 5295 5296 /** 5297 * Indicates that a comment would be parsed as a Processing 5298 * Instruction node, were its target allowed within HTML. 5299 * 5300 * Example: 5301 * 5302 * <?xml version="1.0" ?> 5303 * <?wp.like count=5 ?> 5304 * 5305 * These are HTML comments, but they look like processing 5306 * instructions. HTML parses processing instructions with 5307 * an allowable target into processing instruction nodes, 5308 * but the reserved `xml` and `xml-stylesheet` targets and 5309 * XML-valid targets with characters outside of the allowed 5310 * set become bogus comments instead. 5311 * 5312 * @since 6.5.0 5313 * @since 7.1.0 Only applies to reserved and XML-specific target names; 5314 * other processing instructions produce their own token. 5315 */ 5316 const COMMENT_AS_PI_NODE_LOOKALIKE = 'COMMENT_AS_PI_NODE_LOOKALIKE'; 5317 5318 /** 5319 * Indicates that a comment was created when encountering invalid 5320 * HTML input, a so-called "bogus comment." 5321 * 5322 * Example: 5323 * 5324 * <?= nothing special ?> 5325 * <!{nothing special}> 5326 * 5327 * @since 6.5.0 5328 */ 5329 const COMMENT_AS_INVALID_HTML = 'COMMENT_AS_INVALID_HTML'; 5330 5331 /** 5332 * No-quirks mode document compatibility mode. 5333 * 5334 * > In no-quirks mode, the behavior is (hopefully) the desired behavior 5335 * > described by the modern HTML and CSS specifications. 5336 * 5337 * @see self::$compat_mode 5338 * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Quirks_Mode_and_Standards_Mode 5339 * 5340 * @since 6.7.0 5341 * 5342 * @var string 5343 */ 5344 const NO_QUIRKS_MODE = 'no-quirks-mode'; 5345 5346 /** 5347 * Quirks mode document compatibility mode. 5348 * 5349 * > In quirks mode, layout emulates behavior in Navigator 4 and Internet 5350 * > Explorer 5. This is essential in order to support websites that were 5351 * > built before the widespread adoption of web standards. 5352 * 5353 * @see self::$compat_mode 5354 * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Quirks_Mode_and_Standards_Mode 5355 * 5356 * @since 6.7.0 5357 * 5358 * @var string 5359 */ 5360 const QUIRKS_MODE = 'quirks-mode'; 5361 5362 /** 5363 * Indicates that a span of text may contain any combination of significant 5364 * kinds of characters: NULL bytes, whitespace, and others. 5365 * 5366 * @see self::$text_node_classification 5367 * @see self::subdivide_text_appropriately 5368 * 5369 * @since 6.7.0 5370 */ 5371 const TEXT_IS_GENERIC = 'TEXT_IS_GENERIC'; 5372 5373 /** 5374 * Indicates that a span of text comprises a sequence only of NULL bytes. 5375 * 5376 * @see self::$text_node_classification 5377 * @see self::subdivide_text_appropriately 5378 * 5379 * @since 6.7.0 5380 */ 5381 const TEXT_IS_NULL_SEQUENCE = 'TEXT_IS_NULL_SEQUENCE'; 5382 5383 /** 5384 * Indicates that a span of decoded text comprises only whitespace. 5385 * 5386 * @see self::$text_node_classification 5387 * @see self::subdivide_text_appropriately 5388 * 5389 * @since 6.7.0 5390 */ 5391 const TEXT_IS_WHITESPACE = 'TEXT_IS_WHITESPACE'; 5392 5393 /** 5394 * Wakeup magic method. 5395 * 5396 * @since 6.9.2 5397 */ 5398 public function __wakeup() { 5399 throw new \LogicException( __CLASS__ . ' should never be unserialized' ); 5400 } 5401 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Sat Sep 12 08:20:32 2026 | Cross-referenced by PHPXref |