<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[58985] trunk: HTML API: Respect document compat mode when handling CSS class names.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/58985">58985</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/58985","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>dmsnell</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2024-09-04 04:32:37 +0000 (Wed, 04 Sep 2024)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>HTML API: Respect document compat mode when handling CSS class names.

The HTML API has been behaving as if CSS class name selectors matched class names in an ASCII case-insensitive manner. This is only true if the document in question is set to quirks mode. Unfortunately most documents processed will be set to no-quirks mode, meaning that some CSS behaviors have been matching incorrectly when provided with case variants of class names.

In this patch, the CSS methods have been audited and updated to adhere to the rules governing ASCII case sensitivity when matching classes. This includes `add_class()`, `remove_class()`, `has_class()`, and `class_list()`. Now, it is assumed that a document is in no-quirks mode unless a full HTML parser infers quirks mode, and these methods will treat class names in a byte-for-byte manner. Otherwise, when a document is in quirks mode, the methods will compare the provided class names against existing class names for the tag in an ASCII case insensitive way, while `class_list()` will return a lower-cased version of the existing class names.

The lower-casing in `class_list()` is performed for consistency, since it's possible that multiple case variants of the same comparable class name exists on a tag in the input HTML.

Developed in https://github.com/WordPress/wordpress-develop/pull/7169
Discussed in https://core.trac.wordpress.org/ticket/61531

Props dmsnell, jonsurrell.
See <a href="https://core.trac.wordpress.org/ticket/61531">#61531</a>.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludeshtmlapiclasswphtmlprocessorstatephp">trunk/src/wp-includes/html-api/class-wp-html-processor-state.php</a></li>
<li><a href="#trunksrcwpincludeshtmlapiclasswphtmlprocessorphp">trunk/src/wp-includes/html-api/class-wp-html-processor.php</a></li>
<li><a href="#trunksrcwpincludeshtmlapiclasswphtmltagprocessorphp">trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php</a></li>
<li><a href="#trunktestsphpunittestshtmlapiwpHtmlProcessorphp">trunk/tests/phpunit/tests/html-api/wpHtmlProcessor.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludeshtmlapiclasswphtmlprocessorstatephp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/html-api/class-wp-html-processor-state.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/html-api/class-wp-html-processor-state.php  2024-09-04 04:15:16 UTC (rev 58984)
+++ trunk/src/wp-includes/html-api/class-wp-html-processor-state.php    2024-09-04 04:32:37 UTC (rev 58985)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -300,31 +300,6 @@
</span><span class="cx" style="display: block; padding: 0 10px">        const INSERTION_MODE_AFTER_AFTER_FRAMESET = 'insertion-mode-after-after-frameset';
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">        /**
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-         * No-quirks mode document compatability mode.
-        *
-        * > In no-quirks mode, the behavior is (hopefully) the desired behavior
-        * > described by the modern HTML and CSS specifications.
-        *
-        * @since 6.7.0
-        *
-        * @var string
-        */
-       const NO_QUIRKS_MODE = 'no-quirks-mode';
-
-       /**
-        * Quirks mode document compatability mode.
-        *
-        * > In quirks mode, layout emulates behavior in Navigator 4 and Internet
-        * > Explorer 5. This is essential in order to support websites that were
-        * > built before the widespread adoption of web standards.
-        *
-        * @since 6.7.0
-        *
-        * @var string
-        */
-       const QUIRKS_MODE = 'quirks-mode';
-
-       /**
</del><span class="cx" style="display: block; padding: 0 10px">          * The stack of template insertion modes.
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span><span class="cx" style="display: block; padding: 0 10px">         * @since 6.7.0
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -382,30 +357,6 @@
</span><span class="cx" style="display: block; padding: 0 10px">        public $insertion_mode = self::INSERTION_MODE_INITIAL;
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">        /**
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-         * Indicates if the document is in quirks mode or no-quirks mode.
-        *
-        * Impact on HTML parsing:
-        *
-        *  - In `NO_QUIRKS_MODE` CSS class and ID selectors match in a byte-for-byte
-        *    manner, otherwise for backwards compatability, class selectors are to
-        *    match in an ASCII case-insensitive manner.
-        *
-        *  - When not in `QUIRKS_MODE`, a TABLE start tag implicitly closes an open P tag
-        *    if one is in scope and open, otherwise the TABLE becomes a child of the P.
-        *
-        * `QUIRKS_MODE` impacts many styling-related aspects of an HTML document, but
-        * none of the other changes modifies how the HTML is parsed or selected.
-        *
-        * @see self::QUIRKS_MODE
-        * @see self::NO_QUIRKS_MODE
-        *
-        * @since 6.7.0
-        *
-        * @var string
-        */
-       public $document_mode = self::NO_QUIRKS_MODE;
-
-       /**
</del><span class="cx" style="display: block; padding: 0 10px">          * Context node initializing fragment parser, if created as a fragment parser.
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span><span class="cx" style="display: block; padding: 0 10px">         * @since 6.4.0
</span></span></pre></div>
<a id="trunksrcwpincludeshtmlapiclasswphtmlprocessorphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/html-api/class-wp-html-processor.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/html-api/class-wp-html-processor.php        2024-09-04 04:15:16 UTC (rev 58984)
+++ trunk/src/wp-includes/html-api/class-wp-html-processor.php  2024-09-04 04:32:37 UTC (rev 58985)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1080,7 +1080,7 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        case 'html':
</span><span class="cx" style="display: block; padding: 0 10px">                                $doctype = $this->get_doctype_info();
</span><span class="cx" style="display: block; padding: 0 10px">                                if ( null !== $doctype && 'quirks' === $doctype->indicated_compatability_mode ) {
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                                        $this->state->document_mode = WP_HTML_Processor_State::QUIRKS_MODE;
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                                 $this->compat_mode = WP_HTML_Tag_Processor::QUIRKS_MODE;
</ins><span class="cx" style="display: block; padding: 0 10px">                                 }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                                /*
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1095,7 +1095,7 @@
</span><span class="cx" style="display: block; padding: 0 10px">                 * > Anything else
</span><span class="cx" style="display: block; padding: 0 10px">                 */
</span><span class="cx" style="display: block; padding: 0 10px">                initial_anything_else:
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                $this->state->document_mode  = WP_HTML_Processor_State::QUIRKS_MODE;
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         $this->compat_mode           = WP_HTML_Tag_Processor::QUIRKS_MODE;
</ins><span class="cx" style="display: block; padding: 0 10px">                 $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_BEFORE_HTML;
</span><span class="cx" style="display: block; padding: 0 10px">                return $this->step( self::REPROCESS_CURRENT_NODE );
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -2448,7 +2448,7 @@
</span><span class="cx" style="display: block; padding: 0 10px">                                 * > has a p element in button scope, then close a p element.
</span><span class="cx" style="display: block; padding: 0 10px">                                 */
</span><span class="cx" style="display: block; padding: 0 10px">                                if (
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                                        WP_HTML_Processor_State::QUIRKS_MODE !== $this->state->document_mode &&
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                                 WP_HTML_Tag_Processor::QUIRKS_MODE !== $this->compat_mode &&
</ins><span class="cx" style="display: block; padding: 0 10px">                                         $this->state->stack_of_open_elements->has_p_in_button_scope()
</span><span class="cx" style="display: block; padding: 0 10px">                                ) {
</span><span class="cx" style="display: block; padding: 0 10px">                                        $this->close_a_p_element();
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -4938,6 +4938,10 @@
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span><span class="cx" style="display: block; padding: 0 10px">         * @since 6.6.0 Subclassed for the HTML Processor.
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         * @todo When reconstructing active formatting elements with attributes, find a way
+        *       to indicate if the virtually-reconstructed formatting elements contain the
+        *       wanted class name.
+        *
</ins><span class="cx" style="display: block; padding: 0 10px">          * @param string $wanted_class Look for this CSS class name, ASCII case-insensitive.
</span><span class="cx" style="display: block; padding: 0 10px">         * @return bool|null Whether the matched tag contains the given class name, or null if not matched.
</span><span class="cx" style="display: block; padding: 0 10px">         */
</span></span></pre></div>
<a id="trunksrcwpincludeshtmlapiclasswphtmltagprocessorphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php    2024-09-04 04:15:16 UTC (rev 58984)
+++ trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php      2024-09-04 04:32:37 UTC (rev 58985)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -512,6 +512,32 @@
</span><span class="cx" style="display: block; padding: 0 10px">        protected $parser_state = self::STATE_READY;
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">        /**
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         * Indicates if the document is in quirks mode or no-quirks mode.
+        *
+        *  Impact on HTML parsing:
+        *
+        *   - In `NO_QUIRKS_MODE` (also known as "standard mode"):
+        *       - CSS class and ID selectors match byte-for-byte (case-sensitively).
+        *       - A TABLE start tag `<table>` implicitly closes any open `P` element.
+        *
+        *   - In `QUIRKS_MODE`:
+        *       - CSS class and ID selectors match match in an ASCII case-insensitive manner.
+        *       - A TABLE start tag `<table>` opens a `TABLE` element as a child of a `P`
+        *         element if one is open.
+        *
+        * Quirks and no-quirks mode are thus mostly about styling, but have an impact when
+        * tables are found inside paragraph elements.
+        *
+        * @see self::QUIRKS_MODE
+        * @see self::NO_QUIRKS_MODE
+        *
+        * @since 6.7.0
+        *
+        * @var string
+        */
+       protected $compat_mode = self::NO_QUIRKS_MODE;
+
+       /**
</ins><span class="cx" style="display: block; padding: 0 10px">          * Indicates whether the parser is inside foreign content,
</span><span class="cx" style="display: block; padding: 0 10px">         * e.g. inside an SVG or MathML element.
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1155,6 +1181,8 @@
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                $seen = array();
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                $is_quirks = self::QUIRKS_MODE === $this->compat_mode;
+
</ins><span class="cx" style="display: block; padding: 0 10px">                 $at = 0;
</span><span class="cx" style="display: block; padding: 0 10px">                while ( $at < strlen( $class ) ) {
</span><span class="cx" style="display: block; padding: 0 10px">                        // Skip past any initial boundary characters.
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1169,13 +1197,11 @@
</span><span class="cx" style="display: block; padding: 0 10px">                                return;
</span><span class="cx" style="display: block; padding: 0 10px">                        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        /*
-                        * CSS class names are case-insensitive in the ASCII range.
-                        *
-                        * @see https://www.w3.org/TR/CSS2/syndata.html#x1
-                        */
-                       $name = str_replace( "\x00", "\u{FFFD}", strtolower( substr( $class, $at, $length ) ) );
-                       $at  += $length;
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 $name = str_replace( "\x00", "\u{FFFD}", substr( $class, $at, $length ) );
+                       if ( $is_quirks ) {
+                               $name = strtolower( $name );
+                       }
+                       $at += $length;
</ins><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                        /*
</span><span class="cx" style="display: block; padding: 0 10px">                         * It's expected that the number of class names for a given tag is relatively small.
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1205,10 +1231,14 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        return null;
</span><span class="cx" style="display: block; padding: 0 10px">                }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                $wanted_class = strtolower( $wanted_class );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         $case_insensitive = self::QUIRKS_MODE === $this->compat_mode;
</ins><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                $wanted_length = strlen( $wanted_class );
</ins><span class="cx" style="display: block; padding: 0 10px">                 foreach ( $this->class_list() as $class_name ) {
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        if ( $class_name === $wanted_class ) {
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 if (
+                               strlen( $class_name ) === $wanted_length &&
+                               0 === substr_compare( $class_name, $wanted_class, 0, strlen( $wanted_class ), $case_insensitive )
+                       ) {
</ins><span class="cx" style="display: block; padding: 0 10px">                                 return true;
</span><span class="cx" style="display: block; padding: 0 10px">                        }
</span><span class="cx" style="display: block; padding: 0 10px">                }
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -2296,6 +2326,23 @@
</span><span class="cx" style="display: block; padding: 0 10px">                 */
</span><span class="cx" style="display: block; padding: 0 10px">                $modified = false;
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                $seen      = array();
+               $to_remove = array();
+               $is_quirks = self::QUIRKS_MODE === $this->compat_mode;
+               if ( $is_quirks ) {
+                       foreach ( $this->classname_updates as $updated_name => $action ) {
+                               if ( self::REMOVE_CLASS === $action ) {
+                                       $to_remove[] = strtolower( $updated_name );
+                               }
+                       }
+               } else {
+                       foreach ( $this->classname_updates as $updated_name => $action ) {
+                               if ( self::REMOVE_CLASS === $action ) {
+                                       $to_remove[] = $updated_name;
+                               }
+                       }
+               }
+
</ins><span class="cx" style="display: block; padding: 0 10px">                 // Remove unwanted classes by only copying the new ones.
</span><span class="cx" style="display: block; padding: 0 10px">                $existing_class_length = strlen( $existing_class );
</span><span class="cx" style="display: block; padding: 0 10px">                while ( $at < $existing_class_length ) {
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -2311,25 +2358,23 @@
</span><span class="cx" style="display: block; padding: 0 10px">                                break;
</span><span class="cx" style="display: block; padding: 0 10px">                        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        $name = substr( $existing_class, $at, $name_length );
-                       $at  += $name_length;
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 $name                  = substr( $existing_class, $at, $name_length );
+                       $comparable_class_name = $is_quirks ? strtolower( $name ) : $name;
+                       $at                   += $name_length;
</ins><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        // If this class is marked for removal, start processing the next one.
-                       $remove_class = (
-                               isset( $this->classname_updates[ $name ] ) &&
-                               self::REMOVE_CLASS === $this->classname_updates[ $name ]
-                       );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 // If this class is marked for removal, remove it and move on to the next one.
+                       if ( in_array( $comparable_class_name, $to_remove, true ) ) {
+                               $modified = true;
+                               continue;
+                       }
</ins><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                        // If a class has already been seen then skip it; it should not be added twice.
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        if ( ! $remove_class ) {
-                               $this->classname_updates[ $name ] = self::SKIP_CLASS;
-                       }
-
-                       if ( $remove_class ) {
-                               $modified = true;
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 if ( in_array( $comparable_class_name, $seen, true ) ) {
</ins><span class="cx" style="display: block; padding: 0 10px">                                 continue;
</span><span class="cx" style="display: block; padding: 0 10px">                        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                        $seen[] = $comparable_class_name;
+
</ins><span class="cx" style="display: block; padding: 0 10px">                         /*
</span><span class="cx" style="display: block; padding: 0 10px">                         * Otherwise, append it to the new "class" attribute value.
</span><span class="cx" style="display: block; padding: 0 10px">                         *
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -2350,7 +2395,8 @@
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                // Add new classes by appending those which haven't already been seen.
</span><span class="cx" style="display: block; padding: 0 10px">                foreach ( $this->classname_updates as $name => $operation ) {
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        if ( self::ADD_CLASS === $operation ) {
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 $comparable_name = $is_quirks ? strtolower( $name ) : $name;
+                       if ( self::ADD_CLASS === $operation && ! in_array( $comparable_name, $seen, true ) ) {
</ins><span class="cx" style="display: block; padding: 0 10px">                                 $modified = true;
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                                $class .= strlen( $class ) > 0 ? ' ' : '';
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -3932,8 +3978,29 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        return false;
</span><span class="cx" style="display: block; padding: 0 10px">                }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                if ( self::QUIRKS_MODE !== $this->compat_mode ) {
+                       $this->classname_updates[ $class_name ] = self::ADD_CLASS;
+                       return true;
+               }
+
+               /*
+                * Because class names are matched ASCII-case-insensitively in quirks mode,
+                * this needs to see if a case variant of the given class name is already
+                * enqueued and update that existing entry, if so. This picks the casing of
+                * the first-provided class name for all lexical variations.
+                */
+               $class_name_length = strlen( $class_name );
+               foreach ( $this->classname_updates as $updated_name => $action ) {
+                       if (
+                               strlen( $updated_name ) === $class_name_length &&
+                               0 === substr_compare( $updated_name, $class_name, 0, $class_name_length, true )
+                       ) {
+                               $this->classname_updates[ $updated_name ] = self::ADD_CLASS;
+                               return true;
+                       }
+               }
+
</ins><span class="cx" style="display: block; padding: 0 10px">                 $this->classname_updates[ $class_name ] = self::ADD_CLASS;
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-
</del><span class="cx" style="display: block; padding: 0 10px">                 return true;
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -3953,10 +4020,29 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        return false;
</span><span class="cx" style="display: block; padding: 0 10px">                }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                if ( null !== $this->tag_name_starts_at ) {
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         if ( self::QUIRKS_MODE !== $this->compat_mode ) {
</ins><span class="cx" style="display: block; padding: 0 10px">                         $this->classname_updates[ $class_name ] = self::REMOVE_CLASS;
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                        return true;
</ins><span class="cx" style="display: block; padding: 0 10px">                 }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                /*
+                * Because class names are matched ASCII-case-insensitively in quirks mode,
+                * this needs to see if a case variant of the given class name is already
+                * enqueued and update that existing entry, if so. This picks the casing of
+                * the first-provided class name for all lexical variations.
+                */
+               $class_name_length = strlen( $class_name );
+               foreach ( $this->classname_updates as $updated_name => $action ) {
+                       if (
+                               strlen( $updated_name ) === $class_name_length &&
+                               0 === substr_compare( $updated_name, $class_name, 0, $class_name_length, true )
+                       ) {
+                               $this->classname_updates[ $updated_name ] = self::REMOVE_CLASS;
+                               return true;
+                       }
+               }
+
+               $this->classname_updates[ $class_name ] = self::REMOVE_CLASS;
</ins><span class="cx" style="display: block; padding: 0 10px">                 return true;
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -4351,6 +4437,37 @@
</span><span class="cx" style="display: block; padding: 0 10px">        const COMMENT_AS_INVALID_HTML = 'COMMENT_AS_INVALID_HTML';
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">        /**
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         * No-quirks mode document compatability mode.
+        *
+        * > In no-quirks mode, the behavior is (hopefully) the desired behavior
+        * > described by the modern HTML and CSS specifications.
+        *
+        * @see self::$compat_mode
+        * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Quirks_Mode_and_Standards_Mode
+        *
+        * @since 6.7.0
+        *
+        * @var string
+        */
+       const NO_QUIRKS_MODE = 'no-quirks-mode';
+
+       /**
+        * Quirks mode document compatability mode.
+        *
+        * > In quirks mode, layout emulates behavior in Navigator 4 and Internet
+        * > Explorer 5. This is essential in order to support websites that were
+        * > built before the widespread adoption of web standards.
+        *
+        * @see self::$compat_mode
+        * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Quirks_Mode_and_Standards_Mode
+        *
+        * @since 6.7.0
+        *
+        * @var string
+        */
+       const QUIRKS_MODE = 'quirks-mode';
+
+       /**
</ins><span class="cx" style="display: block; padding: 0 10px">          * Indicates that a span of text may contain any combination of significant
</span><span class="cx" style="display: block; padding: 0 10px">         * kinds of characters: NULL bytes, whitespace, and others.
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span></span></pre></div>
<a id="trunktestsphpunittestshtmlapiwpHtmlProcessorphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/phpunit/tests/html-api/wpHtmlProcessor.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/html-api/wpHtmlProcessor.php    2024-09-04 04:15:16 UTC (rev 58984)
+++ trunk/tests/phpunit/tests/html-api/wpHtmlProcessor.php      2024-09-04 04:32:37 UTC (rev 58985)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -519,4 +519,155 @@
</span><span class="cx" style="display: block; padding: 0 10px">                $processor = WP_HTML_Processor::create_fragment( '<svg><script />' );
</span><span class="cx" style="display: block; padding: 0 10px">                $this->assertTrue( $processor->next_tag( 'script' ) );
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+       /**
+        * Ensures that the tag processor is case sensitive when removing CSS classes in no-quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::remove_class
+        */
+       public function test_remove_class_no_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser( '<!DOCTYPE html><span class="UPPER">' );
+               $processor->next_tag( 'SPAN' );
+               $processor->remove_class( 'upper' );
+               $this->assertSame( '<!DOCTYPE html><span class="UPPER">', $processor->get_updated_html() );
+
+               $processor->remove_class( 'UPPER' );
+               $this->assertSame( '<!DOCTYPE html><span >', $processor->get_updated_html() );
+       }
+
+       /**
+        * Ensures that the tag processor is case sensitive when adding CSS classes in no-quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::add_class
+        */
+       public function test_add_class_no_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser( '<!DOCTYPE html><span class="UPPER">' );
+               $processor->next_tag( 'SPAN' );
+               $processor->add_class( 'UPPER' );
+               $this->assertSame( '<!DOCTYPE html><span class="UPPER">', $processor->get_updated_html() );
+
+               $processor->add_class( 'upper' );
+               $this->assertSame( '<!DOCTYPE html><span class="UPPER upper">', $processor->get_updated_html() );
+       }
+
+       /**
+        * Ensures that the tag processor is case sensitive when checking has CSS classes in no-quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::has_class
+        */
+       public function test_has_class_no_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser( '<!DOCTYPE html><span class="UPPER">' );
+               $processor->next_tag( 'SPAN' );
+               $this->assertFalse( $processor->has_class( 'upper' ) );
+               $this->assertTrue( $processor->has_class( 'UPPER' ) );
+       }
+
+       /**
+        * Ensures that the tag processor lists unique CSS class names in no-quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::class_list
+        */
+       public function test_class_list_no_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser(
+                       /*
+                        * U+00C9 is LATIN CAPITAL LETTER E WITH ACUTE
+                        * U+0045 is LATIN CAPITAL LETTER E
+                        * U+0301 is COMBINING ACUTE ACCENT
+                        *
+                        * This tests not only that the class matching deduplicates the É, but also
+                        * that it treats the same character in different normalization forms as
+                        * distinct, since matching occurs on a byte-for-byte basis.
+                        */
+                       "<!DOCTYPE html><span class='A A a B b \u{C9} \u{45}\u{0301} \u{C9} é'>"
+               );
+               $processor->next_tag( 'SPAN' );
+               $class_list = iterator_to_array( $processor->class_list() );
+               $this->assertSame(
+                       array( 'A', 'a', 'B', 'b', 'É', "E\u{0301}", 'é' ),
+                       $class_list
+               );
+       }
+
+       /**
+        * Ensures that the tag processor is case insensitive when removing CSS classes in quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::remove_class
+        */
+       public function test_remove_class_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser( '<span class="uPPER">' );
+               $processor->next_tag( 'SPAN' );
+               $processor->remove_class( 'upPer' );
+               $this->assertSame( '<span >', $processor->get_updated_html() );
+       }
+
+       /**
+        * Ensures that the tag processor is case insensitive when adding CSS classes in quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::add_class
+        */
+       public function test_add_class_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser( '<span class="UPPER">' );
+               $processor->next_tag( 'SPAN' );
+               $processor->add_class( 'upper' );
+
+               $this->assertSame( '<span class="UPPER">', $processor->get_updated_html() );
+
+               $processor->add_class( 'ANOTHER-UPPER' );
+               $this->assertSame( '<span class="UPPER ANOTHER-UPPER">', $processor->get_updated_html() );
+       }
+
+       /**
+        * Ensures that the tag processor is case sensitive when checking has CSS classes in quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::has_class
+        */
+       public function test_has_class_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser( '<span class="UPPER">' );
+               $processor->next_tag( 'SPAN' );
+               $this->assertTrue( $processor->has_class( 'upper' ) );
+               $this->assertTrue( $processor->has_class( 'UPPER' ) );
+       }
+
+       /**
+        * Ensures that the tag processor lists unique CSS class names in quirks mode.
+        *
+        * @ticket 61531
+        *
+        * @covers ::class_list
+        */
+       public function test_class_list_quirks_mode() {
+               $processor = WP_HTML_Processor::create_full_parser(
+                       /*
+                        * U+00C9 is LATIN CAPITAL LETTER E WITH ACUTE
+                        * U+0045 is LATIN CAPITAL LETTER E
+                        * U+0065 is LATIN SMALL LETTER E
+                        * U+0301 is COMBINING ACUTE ACCENT
+                        *
+                        * This tests not only that the class matching deduplicates the É, but also
+                        * that it treats the same character in different normalization forms as
+                        * distinct, since matching occurs on a byte-for-byte basis.
+                        */
+                       "<span class='A A a B b \u{C9} \u{45}\u{301} \u{C9} é \u{65}\u{301}'>"
+               );
+               $processor->next_tag( 'SPAN' );
+               $class_list = iterator_to_array( $processor->class_list() );
+               $this->assertSame(
+                       array( 'a', 'b', 'É', "e\u{301}", 'é' ),
+                       $class_list
+               );
+       }
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span></span></pre>
</div>
</div>

</body>
</html>