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