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


Generated : Thu Nov 21 08:20:01 2024 Cross-referenced by PHPXref