[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

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


Generated : Sun Sep 27 08:20:30 2026 Cross-referenced by PHPXref