[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/html-api/ -> class-wp-html-tag-processor.php (source)

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


Generated : Sat Jul 18 08:20:16 2026 Cross-referenced by PHPXref