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


Generated : Sun Nov 8 08:20:01 2026 Cross-referenced by PHPXref