From ae846193a1b1f44d46b908220303b62e97e3a919 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 25 Sep 2025 13:39:10 -0500 Subject: [PATCH 1/4] HTML API: Introduce wp_split_class_names(). This patch introduces a new CSS helper module containing a new function, `wp_split_class_names()`. This function wraps some code to rely on the HTML API to take an HTML `class` attribute value and return a `Generator` to iterate over the classes in that value. Many existing functions perform ad-hoc parsing of CSS class names, usually by splitting on a space character. However, there are issues with this approach: - There is no decoding of HTML character references, which is normative inside HTML attributes. - There is no handling of null bytes. - Class names can be split by more than just the space character. - There is no handling of duplicates, and while mostly benign, code forgetting to account for duplicates can lead to defects. The new function handles the nuances to let developers focus on reading CSS class names, adding new class names, and removing class names. This serves a middleground between legacy code interacting with CSS class names in isolation and code processing full HTML documents. --- src/wp-includes/html-api/css-helpers.php | 58 ++++++++++++++++++++++++ src/wp-settings.php | 1 + 2 files changed, 59 insertions(+) create mode 100644 src/wp-includes/html-api/css-helpers.php diff --git a/src/wp-includes/html-api/css-helpers.php b/src/wp-includes/html-api/css-helpers.php new file mode 100644 index 0000000000000..d07ae4576290e --- /dev/null +++ b/src/wp-includes/html-api/css-helpers.php @@ -0,0 +1,58 @@ + Use this in a foreach loop to iterate over the class names. + */ +function wp_split_css_class_list( $class_attribute_string ): Generator { + if ( '' === $class_attribute_string || ! is_string( $class_attribute_string ) ) { + return; + } + + // Get these from the HTML API to avoid ad-hoc parsing HTML or CSS class names. + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_token(); + $processor->set_attribute( 'class', $class_attribute_string ); + + foreach ( $processor->class_list() as $class_name ) { + yield $class_name; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 8ad02ffe8fd8b..93bdffa013db8 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -270,6 +270,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; +require ABSPATH . WPINC . '/html-api/css-helpers.php'; require ABSPATH . WPINC . '/class-wp-block-processor.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; From e5a3a6282cbfaabdc6fd0453856e565ba5640467 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 9 Oct 2025 20:19:12 -0700 Subject: [PATCH 2/4] Reimagine as HTML API export. --- .../html-api/class-wp-html-tag-processor.php | 61 ++++++++++++++++--- src/wp-includes/html-api/css-helpers.php | 58 ------------------ src/wp-settings.php | 1 - 3 files changed, 53 insertions(+), 67 deletions(-) delete mode 100644 src/wp-includes/html-api/css-helpers.php diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 31c4bc8a10654..3c9f247790822 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -1187,25 +1187,70 @@ public function class_list() { return; } - $seen = array(); + return self::parse_class_list( $class, $this->compat_mode ); + } - $is_quirks = self::QUIRKS_MODE === $this->compat_mode; + /** + * Generator for a foreach loop to step through each class name for the matched tag. + * + * This generator function is designed to be used inside a "foreach" loop. + * + * Example: + * + * $class_list = 'free <egg<\tlang-en'; + * foreach ( WP_HTML_Tag_Processor::parse_class_list( $class_list ) as $class_name ) { + * echo "{$class_name} "; + * } + * // Outputs: "free lang-en " + * + * The default behavior is normative for HTML5 documents in “no-quirks” mode. For + * rare documents with “quirks mode” DOCTYPE declarations, pass {@see self::QUIRKS_MODE} + * as the compatibility mode for ASCII-case-insensitive comparison of class names. Use + * this only when certain that the containing document is in no-quirks mode. + * + * Example: + * + * $class_list = 'wide naRRow WIDE Wide narrow'; + * $classes = WP_HTML_Tag_Processor::parse_class_list( $class_list ); + * $classes = iterator_to_array( $classes ); + * $classes === array( 'wide', 'naRRow', 'WIDE', 'Wide', 'narrow' ); + * + * $class_list = 'wide WIDE Wide'; + * $classes = WP_HTML_Tag_Processor::parse_class_list( $class_list, WP_HTML_Tag_Processor::QUIRKS_MODE ); + * $classes = iterator_to_array( $classes ); + * $classes === array( 'wide', 'naRRow' ); + * + * @since 6.9.0 + * + * @param string $class_list Contains a full decoded HTML `class` attribute, or plain + * list of space-separated CSS class names. + * @param string|null $compat_mode Optional. Specifies how to compare class names, whether + * byte-for-byte or ASCII-case-insensitively. Default is + * NO_QUIRKS_MODE, which compares byte for byte. + * @return Generator Iterates over each unique CSS class name in the given input list in order. + */ + public static function parse_class_list( $class_list, $compat_mode = self::NO_QUIRKS_MODE ) { + if ( '' === $class_list || ! is_string( $class_list ) ) { + return; + } - $at = 0; - while ( $at < strlen( $class ) ) { + $seen = array(); + $is_quirks = self::QUIRKS_MODE === $compat_mode; + $at = 0; + while ( $at < strlen( $class_list ) ) { // Skip past any initial boundary characters. - $at += strspn( $class, " \t\f\r\n", $at ); - if ( $at >= strlen( $class ) ) { + $at += strspn( $class_list, " \t\f\r\n", $at ); + if ( $at >= strlen( $class_list ) ) { return; } // Find the byte length until the next boundary. - $length = strcspn( $class, " \t\f\r\n", $at ); + $length = strcspn( $class_list, " \t\f\r\n", $at ); if ( 0 === $length ) { return; } - $name = str_replace( "\x00", "\u{FFFD}", substr( $class, $at, $length ) ); + $name = str_replace( "\x00", "\u{FFFD}", substr( $class_list, $at, $length ) ); if ( $is_quirks ) { $name = strtolower( $name ); } diff --git a/src/wp-includes/html-api/css-helpers.php b/src/wp-includes/html-api/css-helpers.php deleted file mode 100644 index d07ae4576290e..0000000000000 --- a/src/wp-includes/html-api/css-helpers.php +++ /dev/null @@ -1,58 +0,0 @@ - Use this in a foreach loop to iterate over the class names. - */ -function wp_split_css_class_list( $class_attribute_string ): Generator { - if ( '' === $class_attribute_string || ! is_string( $class_attribute_string ) ) { - return; - } - - // Get these from the HTML API to avoid ad-hoc parsing HTML or CSS class names. - $processor = new WP_HTML_Tag_Processor( '' ); - $processor->next_token(); - $processor->set_attribute( 'class', $class_attribute_string ); - - foreach ( $processor->class_list() as $class_name ) { - yield $class_name; - } -} diff --git a/src/wp-settings.php b/src/wp-settings.php index 93bdffa013db8..8ad02ffe8fd8b 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -270,7 +270,6 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; -require ABSPATH . WPINC . '/html-api/css-helpers.php'; require ABSPATH . WPINC . '/class-wp-block-processor.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; From b4e58c2f77c93f69a4da43e170a1247ae2185b72 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 9 Oct 2025 20:23:05 -0700 Subject: [PATCH 3/4] HTML API: Refactor get_post_class() to split CSS class names on whitespace. --- src/wp-includes/post-template.php | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index 3d82ba81b641b..9be56fa88972d 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -496,18 +496,15 @@ function get_post_class( $css_class = '', $post = null ) { $classes = array(); - if ( $css_class ) { - if ( ! is_array( $css_class ) ) { - $css_class = preg_split( '#\s+#', $css_class ); - } - $classes = array_map( 'esc_attr', $css_class ); - } else { + if ( is_string( $css_class ) ) { + $classes = iterator_to_array( WP_HTML_Tag_Processor::parse_class_list( $css_class ) ); + } elseif ( ! is_array( $css_class ) ) { // Ensure that we always coerce class to being an array. - $css_class = array(); + $classes = array(); } if ( ! $post ) { - return $classes; + return array_values( array_unique( array_map( 'esc_attr', $classes ) ) ); } $classes[] = 'post-' . $post->ID; @@ -593,8 +590,6 @@ function get_post_class( $css_class = '', $post = null ) { } } - $classes = array_map( 'esc_attr', $classes ); - /** * Filters the list of CSS class names for the current post. * @@ -606,10 +601,9 @@ function get_post_class( $css_class = '', $post = null ) { */ $classes = apply_filters( 'post_class', $classes, $css_class, $post->ID ); - $classes = array_unique( $classes ); - $classes = array_values( $classes ); + $classes = array_map( 'esc_attr', $classes ); - return $classes; + return array_values( array_unique( $classes ) ); } /** From 3949fabb035b69a21554d06e8d36b99b246e3ec2 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 9 Oct 2025 20:54:57 -0700 Subject: [PATCH 4/4] HTML API: Explore refactors of splitting CSS names. --- src/wp-includes/block-supports/dimensions.php | 2 +- src/wp-includes/bookmark-template.php | 6 +++--- src/wp-includes/class-wp-duotone.php | 3 +-- .../customize/class-wp-customize-nav-menu-item-setting.php | 2 +- src/wp-includes/nav-menu.php | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/block-supports/dimensions.php b/src/wp-includes/block-supports/dimensions.php index da68f187c3915..9f8236ac663f2 100644 --- a/src/wp-includes/block-supports/dimensions.php +++ b/src/wp-includes/block-supports/dimensions.php @@ -143,7 +143,7 @@ function wp_render_dimensions_support( $block_content, $block ) { $tags->set_attribute( 'style', $updated_style ); if ( ! empty( $styles['classnames'] ) ) { - foreach ( explode( ' ', $styles['classnames'] ) as $class_name ) { + foreach ( WP_HTML_Tag_Processor::parse_class_list( $styles['classnames'] ) as $class_name ) { if ( str_contains( $class_name, 'aspect-ratio' ) && ! isset( $block_attributes['style']['dimensions']['aspectRatio'] ) diff --git a/src/wp-includes/bookmark-template.php b/src/wp-includes/bookmark-template.php index 893494a7e92cd..09980b8cc8790 100644 --- a/src/wp-includes/bookmark-template.php +++ b/src/wp-includes/bookmark-template.php @@ -234,11 +234,11 @@ function wp_list_bookmarks( $args = '' ) { $output = ''; - if ( ! is_array( $parsed_args['class'] ) ) { - $parsed_args['class'] = explode( ' ', $parsed_args['class'] ); + if ( is_string( $parsed_args['class'] ) ) { + $parsed_args['class'] = iterator_to_array( WP_HTML_Tag_Processor::parse_class_list( $parsed_args['class'] ) ); } $parsed_args['class'] = array_map( 'sanitize_html_class', $parsed_args['class'] ); - $parsed_args['class'] = trim( implode( ' ', $parsed_args['class'] ) ); + $parsed_args['class'] = implode( ' ', $parsed_args['class'] ); if ( $parsed_args['categorize'] ) { $cats = get_terms( diff --git a/src/wp-includes/class-wp-duotone.php b/src/wp-includes/class-wp-duotone.php index 7c7416b4399bc..c0c5a4f1897eb 100644 --- a/src/wp-includes/class-wp-duotone.php +++ b/src/wp-includes/class-wp-duotone.php @@ -1180,8 +1180,7 @@ public static function restore_image_outer_container( $block_content ) { $tags->set_bookmark( 'wrapper-div' ); $tags->next_tag(); - $inner_classnames = explode( ' ', $tags->get_attribute( 'class' ) ); - foreach ( $inner_classnames as $classname ) { + foreach ( WP_HTML_Tag_Processor::parse_class_list( $tags->get_attribute( 'class' ) ) as $classname ) { if ( 0 === strpos( $classname, 'wp-duotone' ) ) { $tags->remove_class( $classname ); $tags->seek( 'wrapper-div' ); diff --git a/src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php b/src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php index 07159e0d54d05..8ca5a20b19a70 100644 --- a/src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php +++ b/src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php @@ -617,7 +617,7 @@ public function value_as_wp_post_nav_menu_item() { // 'classes' should be an array, as in wp_setup_nav_menu_item(). if ( isset( $item->classes ) && is_scalar( $item->classes ) ) { - $item->classes = explode( ' ', $item->classes ); + $item->classes = iterator_to_array( WP_HTML_Tag_Processor::parse_class_list( $item->classes ) ); } $item->ID = $this->post_id; diff --git a/src/wp-includes/nav-menu.php b/src/wp-includes/nav-menu.php index d808c4e212d39..fafd7b246cb89 100644 --- a/src/wp-includes/nav-menu.php +++ b/src/wp-includes/nav-menu.php @@ -594,7 +594,7 @@ function wp_update_nav_menu_item( $menu_id = 0, $menu_item_db_id = 0, $menu_item update_post_meta( $menu_item_db_id, '_menu_item_object', sanitize_key( $args['menu-item-object'] ) ); update_post_meta( $menu_item_db_id, '_menu_item_target', sanitize_key( $args['menu-item-target'] ) ); - $args['menu-item-classes'] = array_map( 'sanitize_html_class', explode( ' ', $args['menu-item-classes'] ) ); + $args['menu-item-classes'] = array_map( 'sanitize_html_class', iterator_to_array( WP_HTML_Tag_Processor::parse_class_list( $args['menu-item-classes'] ) ) ); $args['menu-item-xfn'] = implode( ' ', array_map( 'sanitize_html_class', explode( ' ', $args['menu-item-xfn'] ) ) ); update_post_meta( $menu_item_db_id, '_menu_item_classes', $args['menu-item-classes'] ); update_post_meta( $menu_item_db_id, '_menu_item_xfn', $args['menu-item-xfn'] );