[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

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


Generated : Sat Jun 20 08:20:11 2026 Cross-referenced by PHPXref