From 1dd4604d3feaf771882789f3d4b0c43bb4351e24 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Fri, 2 Oct 2026 12:39:59 +0600 Subject: [PATCH 1/7] Emoji: Skip ignored tags with attributes in `wp_staticize_emoji()`. `wp_staticize_emoji()` only recognized an ignored tag (code, pre, style, script, textarea) when `>` came right after the tag name. Emoji inside tags with attributes, such as the Code block's `
`, were converted to `` in feeds and
email.

Copying the `[^>]*` pattern from `convert_smilies()` ([45569] / #47489)
would fix that, but it also matches longer tag names that start with an
ignored name, such as ``, `` or ``.
The matching closing tag never appears, so emoji or smilies in the rest
of the content stop being converted.

The opening-tag pattern now requires whitespace, `/` or `>` after the
tag name and ignores case. The closing-tag check now also ignores case
and allows whitespace before `>`. Both changes are applied to
`wp_staticize_emoji()` and `convert_smilies()`.

Props prakritee08, therssoftware, westonruter.
Fixes #66134.
---
 src/wp-includes/formatting.php                | 12 ++--
 .../tests/formatting/convertSmilies.php       | 29 ++++++++
 tests/phpunit/tests/formatting/emoji.php      | 70 +++++++++++++++++++
 3 files changed, 105 insertions(+), 6 deletions(-)

diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php
index c39beaf7b1d68..c180790af6ed1 100644
--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -3621,8 +3621,8 @@ function convert_smilies( $text ) {
 		$content = $textarr[ $i ];
 
 		// If we're in an ignore block, wait until we find its closing tag.
-		if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')[^>]*>/', $content, $matches ) ) {
-			$ignore_block_element = $matches[1];
+		if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')(?:[\s\/][^>]*)?>/i', $content, $matches ) ) {
+			$ignore_block_element = strtolower( $matches[1] );
 		}
 
 		// If it's not a tag and not in ignore block.
@@ -3631,7 +3631,7 @@ function convert_smilies( $text ) {
 		}
 
 		// Did we exit ignore block?
-		if ( '' !== $ignore_block_element && '' === $content ) {
+		if ( '' !== $ignore_block_element && preg_match( '/^<\/' . $ignore_block_element . '\s*>$/i', $content ) ) {
 			$ignore_block_element = '';
 		}
 
@@ -6229,8 +6229,8 @@ function wp_staticize_emoji( $text ) {
 		$content = $textarr[ $i ];
 
 		// If we're in an ignore block, wait until we find its closing tag.
-		if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')>/', $content, $matches ) ) {
-			$ignore_block_element = $matches[1];
+		if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')(?:[\s\/][^>]*)?>/i', $content, $matches ) ) {
+			$ignore_block_element = strtolower( $matches[1] );
 		}
 
 		// If it's not a tag and not in ignore block.
@@ -6250,7 +6250,7 @@ function wp_staticize_emoji( $text ) {
 		}
 
 		// Did we exit ignore block?
-		if ( '' !== $ignore_block_element && '' === $content ) {
+		if ( '' !== $ignore_block_element && preg_match( '/^<\/' . $ignore_block_element . '\s*>$/i', $content ) ) {
 			$ignore_block_element = '';
 		}
 
diff --git a/tests/phpunit/tests/formatting/convertSmilies.php b/tests/phpunit/tests/formatting/convertSmilies.php
index 24c2a8087a93f..2fa6e197c3187 100644
--- a/tests/phpunit/tests/formatting/convertSmilies.php
+++ b/tests/phpunit/tests/formatting/convertSmilies.php
@@ -163,6 +163,35 @@ public function data_ignore_smilies_in_tags() {
 		);
 	}
 
+	/**
+	 * Tests where the ignore block starts and ends, by counting converted smilies.
+	 *
+	 * @ticket 66134
+	 * @dataProvider data_ignore_smilies_block_boundaries
+	 *
+	 * @param string $input    The content to convert.
+	 * @param int    $expected The expected number of converted smilies.
+	 */
+	public function test_ignore_smilies_block_boundaries( $input, $expected ) {
+		$this->assertSame( $expected, substr_count( convert_smilies( $input ), "\xf0\x9f\x99\x82" ) );
+	}
+
+	/**
+	 * Data provider.
+	 *
+	 * @return array[]
+	 */
+	public function data_ignore_smilies_block_boundaries() {
+		return array(
+			'smiley after closing tag is converted'        => array( '
 :) 
:)', 1 ), + 'uppercase tags' => array( '
 :) 
:)', 1 ), + 'whitespace in closing tag' => array( '
 :) 
:)', 1 ), + 'custom element starting with ignored name' => array( ' :) :)', 2 ), + 'unknown element starting with ignored name' => array( ' :) :)', 2 ), + 'element name prefixed by ignored script name' => array( ' :) :)', 2 ), + ); + } + /** * Tests that combinations of smilies separated by a single space * are converted correctly. diff --git a/tests/phpunit/tests/formatting/emoji.php b/tests/phpunit/tests/formatting/emoji.php index d885c49fd7289..c423b01566a26 100644 --- a/tests/phpunit/tests/formatting/emoji.php +++ b/tests/phpunit/tests/formatting/emoji.php @@ -376,4 +376,74 @@ public function data_wp_staticize_emoji() { public function test_wp_staticize_emoji( $emoji, $expected ) { $this->assertSame( $expected, wp_staticize_emoji( $emoji ) ); } + + /** + * Tests that emoji inside ignored tags are not staticized, with or without attributes. + * + * @ticket 66134 + * @dataProvider data_wp_staticize_emoji_ignored_tags + * + * @covers ::wp_staticize_emoji + * + * @param string $element The ignored element name. + */ + public function test_wp_staticize_emoji_ignores_tags_with_attributes( $element ) { + // U+1F642 as the HTML entity produced by _wp_emoji_list( 'entities' ). + $emoji = '🙂'; + + $no_attr = "<$element>$emoji"; + $this->assertSame( $no_attr, wp_staticize_emoji( $no_attr ), "Emoji inside <$element> should not be staticized." ); + + $with_attr = "<$element data-foo=\"bar\">$emoji"; + $this->assertSame( $with_attr, wp_staticize_emoji( $with_attr ), "Emoji inside <$element data-foo=\"...\"> should not be staticized." ); + } + + /** + * Data provider for test_wp_staticize_emoji_ignores_tags_with_attributes(). + * + * @return array[] + */ + public function data_wp_staticize_emoji_ignored_tags() { + return array( + 'code' => array( 'code' ), + 'pre' => array( 'pre' ), + 'style' => array( 'style' ), + 'script' => array( 'script' ), + 'textarea' => array( 'textarea' ), + ); + } + + /** + * Tests where the ignore block starts and ends, by counting staticized emoji. + * + * @ticket 66134 + * @dataProvider data_wp_staticize_emoji_ignore_block_boundaries + * + * @covers ::wp_staticize_emoji + * + * @param string $text The content to staticize. + * @param int $expected The expected number of staticized emoji. + */ + public function test_wp_staticize_emoji_ignore_block_boundaries( $text, $expected ) { + $this->assertSame( $expected, substr_count( wp_staticize_emoji( $text ), 'class="wp-smiley"' ) ); + } + + /** + * Data provider for test_wp_staticize_emoji_ignore_block_boundaries(). + * + * @return array[] + */ + public function data_wp_staticize_emoji_ignore_block_boundaries() { + return array( + 'emoji after closing tag is staticized' => array( '
🙂
🙂', 1 ), + 'code block markup' => array( '
🙂
', 0 ), + 'raw emoji character' => array( "
\u{1F642}
\u{1F642}", 1 ), + 'slash after tag name' => array( '🙂', 1 ), + 'uppercase tags' => array( '
🙂
🙂', 1 ), + 'whitespace in closing tag' => array( '
🙂
🙂', 1 ), + 'custom element starting with ignored name' => array( '🙂🙂', 2 ), + 'unknown element starting with ignored name' => array( '🙂🙂', 2 ), + 'element name prefixed by ignored script name' => array( '🙂🙂', 2 ), + ); + } } From a38c9cb5aa3ae07e4f462c12c0d470c2c82c114b Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 22 Oct 2026 10:12:25 +0600 Subject: [PATCH 2/7] Tests: Make data provider static and add docblock spacing in `convert_smilies()` tests. --- tests/phpunit/tests/formatting/convertSmilies.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/formatting/convertSmilies.php b/tests/phpunit/tests/formatting/convertSmilies.php index 2fa6e197c3187..dd234e0773b00 100644 --- a/tests/phpunit/tests/formatting/convertSmilies.php +++ b/tests/phpunit/tests/formatting/convertSmilies.php @@ -167,6 +167,7 @@ public function data_ignore_smilies_in_tags() { * Tests where the ignore block starts and ends, by counting converted smilies. * * @ticket 66134 + * * @dataProvider data_ignore_smilies_block_boundaries * * @param string $input The content to convert. @@ -181,7 +182,7 @@ public function test_ignore_smilies_block_boundaries( $input, $expected ) { * * @return array[] */ - public function data_ignore_smilies_block_boundaries() { + public static function data_ignore_smilies_block_boundaries() { return array( 'smiley after closing tag is converted' => array( '
 :) 
:)', 1 ), 'uppercase tags' => array( '
 :) 
:)', 1 ), From 8ae84b5ceb6f48b917ff09353829bf555a792546 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 12 Nov 2026 04:52:06 +0600 Subject: [PATCH 3/7] Emoji: Use `WP_HTML_Tag_Processor` to find text in `wp_staticize_emoji()`. Replace the regular expression HTML loop with the Tag Processor, so that emoji are only staticized in text nodes. This handles `>` in attribute values and markup inside HTML comments, neither of which the previous loop accounted for. Text inside `CODE` and `PRE` elements is still skipped, now with nesting taken into account. See #66134. --- src/wp-includes/formatting.php | 105 ++++++++++++++++------- tests/phpunit/tests/formatting/emoji.php | 13 ++- 2 files changed, 87 insertions(+), 31 deletions(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 5942fc670e254..048dc33b9b4fc 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -6228,52 +6228,99 @@ function wp_staticize_emoji( $text ) { /** This filter is documented in wp-includes/formatting.php */ $ext = apply_filters( 'emoji_ext', '.png' ); - $output = ''; /* - * HTML loop taken from smiley function, which was taken from texturize function. - * It'll never be consolidated. - * - * First, capture the tags as well as in between. + * Anonymous subclass exposing raw text replacement, which WP_HTML_Tag_Processor + * does not provide publicly: set_modifiable_text() escapes its input, so it + * cannot be used to replace an emoji with an IMG tag. */ - $textarr = preg_split( '/(<.*>)/U', $text, -1, PREG_SPLIT_DELIM_CAPTURE ); - $stop = count( $textarr ); + $processor = new class( $text ) extends WP_HTML_Tag_Processor { + /** + * Gets the raw, undecoded source of the current text node. + * + * @return string Raw text. + */ + public function get_raw_text(): string { + // Always called while matched on a text node, so the bookmark is set. + $this->set_bookmark( 'here' ); + $span = $this->bookmarks['here']; - // Ignore processing of specific tags. - $tags_to_ignore = 'code|pre|style|script|textarea'; - $ignore_block_element = ''; + return substr( $this->html, $span->start, $span->length ); + } - for ( $i = 0; $i < $stop; $i++ ) { - $content = $textarr[ $i ]; + /** + * Replaces the current text node with the given HTML, without escaping it. + * + * @param string $html Replacement HTML. + */ + public function replace_raw_text( string $html ): void { + $this->set_bookmark( 'here' ); + $span = $this->bookmarks['here']; - // If we're in an ignore block, wait until we find its closing tag. - if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')(?:[\s\/][^>]*)?>/i', $content, $matches ) ) { - $ignore_block_element = strtolower( $matches[1] ); + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, $html ); } + }; - // If it's not a tag and not in ignore block. - if ( '' === $ignore_block_element && strlen( $content ) > 0 && '<' !== $content[0] && str_contains( $content, '&#x' ) ) { - foreach ( $possible_emoji as $emojum => $emoji_char ) { - if ( ! str_contains( $content, $emojum ) ) { - continue; - } + /* + * Only text nodes are visited. Tags and their attributes, comments, and the + * contents of elements which are not parsed as HTML, such as SCRIPT, STYLE + * and TEXTAREA, are never reported as text by the Tag Processor. + * + * The Tag Processor does not track document structure, so the CODE or PRE + * element being ignored is tracked here, along with its nesting depth so + * that it only ends at its own closing tag. + */ + $ignored_tag = null; + $ignored_depth = 0; - $file = str_replace( ';&#x', '-', $emojum ); - $file = str_replace( array( '&#x', ';' ), '', $file ); + while ( $processor->next_token() ) { + $token_type = $processor->get_token_type(); - $entity = sprintf( '%s', $cdn_url . $file . $ext, $emoji_char ); + if ( '#tag' === $token_type ) { + $tag_name = $processor->get_tag(); - $content = str_replace( $emojum, $entity, $content ); + if ( null === $ignored_tag ) { + if ( ! $processor->is_tag_closer() && ( 'CODE' === $tag_name || 'PRE' === $tag_name ) ) { + $ignored_tag = $tag_name; + $ignored_depth = 1; + } + } elseif ( $tag_name === $ignored_tag ) { + $ignored_depth += $processor->is_tag_closer() ? -1 : 1; + + if ( 0 === $ignored_depth ) { + $ignored_tag = null; + } } + continue; } - // Did we exit ignore block? - if ( '' !== $ignore_block_element && preg_match( '/^<\/' . $ignore_block_element . '\s*>$/i', $content ) ) { - $ignore_block_element = ''; + if ( '#text' !== $token_type || null !== $ignored_tag ) { + continue; } - $output .= $content; + $content = $processor->get_raw_text(); + + if ( ! str_contains( $content, '&#x' ) ) { + continue; + } + + foreach ( $possible_emoji as $emojum => $emoji_char ) { + if ( ! str_contains( $content, $emojum ) ) { + continue; + } + + $file = str_replace( ';&#x', '-', $emojum ); + $file = str_replace( array( '&#x', ';' ), '', $file ); + + $entity = sprintf( '%s', $cdn_url . $file . $ext, $emoji_char ); + + $content = str_replace( $emojum, $entity, $content ); + } + + $processor->replace_raw_text( $content ); } + $output = $processor->get_updated_html(); + // Finally, remove any stray U+FE0F characters. $output = str_replace( '️', '', $output ); diff --git a/tests/phpunit/tests/formatting/emoji.php b/tests/phpunit/tests/formatting/emoji.php index 091e040f40c98..66484c535b47f 100644 --- a/tests/phpunit/tests/formatting/emoji.php +++ b/tests/phpunit/tests/formatting/emoji.php @@ -403,7 +403,7 @@ public function test_wp_staticize_emoji_ignores_tags_with_attributes( $element ) * * @return array[] */ - public function data_wp_staticize_emoji_ignored_tags() { + public static function data_wp_staticize_emoji_ignored_tags() { return array( 'code' => array( 'code' ), 'pre' => array( 'pre' ), @@ -433,7 +433,7 @@ public function test_wp_staticize_emoji_ignore_block_boundaries( $text, $expecte * * @return array[] */ - public function data_wp_staticize_emoji_ignore_block_boundaries() { + public static function data_wp_staticize_emoji_ignore_block_boundaries() { return array( 'emoji after closing tag is staticized' => array( '
🙂
🙂', 1 ), 'code block markup' => array( '
🙂
', 0 ), @@ -444,6 +444,15 @@ public function data_wp_staticize_emoji_ignore_block_boundaries() { 'custom element starting with ignored name' => array( '🙂🙂', 2 ), 'unknown element starting with ignored name' => array( '🙂🙂', 2 ), 'element name prefixed by ignored script name' => array( '🙂🙂', 2 ), + 'greater-than sign in attribute value' => array( '1 > 0 🙂🙂', 1 ), + 'ignored tag with greater-than in attribute' => array( '
🙂
🙂', 1 ), + 'closing tag lookalike in attribute value' => array( '
🙂
🙂', 1 ), + 'emoji inside HTML comment' => array( '🙂', 1 ), + 'ignored tag inside HTML comment' => array( '🙂', 1 ), + 'closing tag inside HTML comment' => array( '
🙂
🙂', 1 ), + 'nested ignored tags' => array( '
🙂🙂
🙂', 1 ), + 'same ignored tag nested' => array( '🙂🙂🙂', 1 ), + 'less-than sign in text' => array( '1 < 2 🙂

🙂

', 2 ), ); } } From cae86ebd1758fc7f3d106038741e71817ab6c887 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 12 Nov 2026 11:24:40 +0600 Subject: [PATCH 4/7] Formatting: Use `WP_HTML_Tag_Processor` to find text in `convert_smilies()`. Move the text node loop from `wp_staticize_emoji()` into a private `_wp_replace_in_html_text_nodes()` helper and use it in `convert_smilies()` as well, replacing its regular expression HTML loop. Smilies are now only converted in text nodes, which handles `>` in attribute values and markup inside HTML comments. The helper assembles its output from token spans rather than queueing an update per text node. See #66134. --- src/wp-includes/formatting.php | 217 +++++++++--------- .../tests/formatting/convertSmilies.php | 9 + 2 files changed, 118 insertions(+), 108 deletions(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 048dc33b9b4fc..7a325da912cf4 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -3617,44 +3617,114 @@ function convert_smilies( $text ) { return $text; } - // HTML loop taken from texturize function, could possible be consolidated. - $textarr = preg_split( '/(<[^>]*>)/U', $text, -1, PREG_SPLIT_DELIM_CAPTURE ); // Capture the tags as well as in between. + return _wp_replace_in_html_text_nodes( + $text, + static function ( $content ) use ( $wp_smiliessearch ) { + return preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $content ); + } + ); +} - if ( false === $textarr ) { - // Return default text. - return $text; +/** + * Applies a callback to the text nodes of an HTML string. + * + * The callback receives the raw, undecoded source of each text node and returns + * its replacement, which is placed into the document as HTML without escaping. + * + * Only text nodes are passed to the callback. Tags and their attributes, + * comments, and the contents of elements which are not parsed as HTML, such as + * SCRIPT, STYLE and TEXTAREA, are left alone, as is any text inside CODE and + * PRE elements. + * + * @since 7.3.0 + * @access private + * + * @param string $html HTML to process. + * @param callable $callback Receives the raw source of a text node and returns its + * replacement HTML. If it returns anything other than + * a string, the text node is left unchanged. + * @return string HTML with the callback applied to its text nodes. + */ +function _wp_replace_in_html_text_nodes( string $html, callable $callback ): string { + // Without any markup the whole string is a single text node. + if ( ! str_contains( $html, '<' ) ) { + $replacement = $callback( $html ); + + return is_string( $replacement ) ? $replacement : $html; } - // Loop stuff. - $stop = count( $textarr ); - $output = ''; + // Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans. + $processor = new class( $html ) extends WP_HTML_Tag_Processor { + /** + * Gets the span for the current token. + * + * @return WP_HTML_Span Current token span. + */ + public function get_span(): WP_HTML_Span { + // Always called while matched on a token, so setting the bookmark never fails. + $this->set_bookmark( 'here' ); + return $this->bookmarks['here']; + } + }; + + /* + * The replacements are raw HTML, which set_modifiable_text() would escape, + * so the output is assembled here from the unchanged parts of the input + * and the replaced text nodes. + */ + $output = ''; + $copied_to = 0; + + /* + * The Tag Processor does not track document structure, so the CODE or PRE + * element being ignored is tracked here, along with its nesting depth so + * that it only ends at its own closing tag. + */ + $ignored_tag = null; + $ignored_depth = 0; + + while ( $processor->next_token() ) { + $token_type = $processor->get_token_type(); - // Ignore processing of specific tags. - $tags_to_ignore = 'code|pre|style|script|textarea'; - $ignore_block_element = ''; + if ( '#tag' === $token_type ) { + $tag_name = $processor->get_tag(); - for ( $i = 0; $i < $stop; $i++ ) { - $content = $textarr[ $i ]; + if ( null === $ignored_tag ) { + if ( ! $processor->is_tag_closer() && ( 'CODE' === $tag_name || 'PRE' === $tag_name ) ) { + $ignored_tag = $tag_name; + $ignored_depth = 1; + } + } elseif ( $tag_name === $ignored_tag ) { + $ignored_depth += $processor->is_tag_closer() ? -1 : 1; - // If we're in an ignore block, wait until we find its closing tag. - if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')(?:[\s\/][^>]*)?>/i', $content, $matches ) ) { - $ignore_block_element = strtolower( $matches[1] ); + if ( 0 === $ignored_depth ) { + $ignored_tag = null; + } + } + continue; } - // If it's not a tag and not in ignore block. - if ( '' === $ignore_block_element && strlen( $content ) > 0 && '<' !== $content[0] ) { - $content = preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $content ); + if ( '#text' !== $token_type || null !== $ignored_tag ) { + continue; } - // Did we exit ignore block? - if ( '' !== $ignore_block_element && preg_match( '/^<\/' . $ignore_block_element . '\s*>$/i', $content ) ) { - $ignore_block_element = ''; + $span = $processor->get_span(); + $text = substr( $html, $span->start, $span->length ); + $replacement = $callback( $text ); + + if ( ! is_string( $replacement ) || $replacement === $text ) { + continue; } - $output .= $content; + $output .= substr( $html, $copied_to, $span->start - $copied_to ) . $replacement; + $copied_to = $span->start + $span->length; } - return $output; + if ( 0 === $copied_to ) { + return $html; + } + + return $output . substr( $html, $copied_to ); } /** @@ -6228,98 +6298,29 @@ function wp_staticize_emoji( $text ) { /** This filter is documented in wp-includes/formatting.php */ $ext = apply_filters( 'emoji_ext', '.png' ); - /* - * Anonymous subclass exposing raw text replacement, which WP_HTML_Tag_Processor - * does not provide publicly: set_modifiable_text() escapes its input, so it - * cannot be used to replace an emoji with an IMG tag. - */ - $processor = new class( $text ) extends WP_HTML_Tag_Processor { - /** - * Gets the raw, undecoded source of the current text node. - * - * @return string Raw text. - */ - public function get_raw_text(): string { - // Always called while matched on a text node, so the bookmark is set. - $this->set_bookmark( 'here' ); - $span = $this->bookmarks['here']; - - return substr( $this->html, $span->start, $span->length ); - } - - /** - * Replaces the current text node with the given HTML, without escaping it. - * - * @param string $html Replacement HTML. - */ - public function replace_raw_text( string $html ): void { - $this->set_bookmark( 'here' ); - $span = $this->bookmarks['here']; - - $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, $html ); - } - }; - - /* - * Only text nodes are visited. Tags and their attributes, comments, and the - * contents of elements which are not parsed as HTML, such as SCRIPT, STYLE - * and TEXTAREA, are never reported as text by the Tag Processor. - * - * The Tag Processor does not track document structure, so the CODE or PRE - * element being ignored is tracked here, along with its nesting depth so - * that it only ends at its own closing tag. - */ - $ignored_tag = null; - $ignored_depth = 0; - - while ( $processor->next_token() ) { - $token_type = $processor->get_token_type(); - - if ( '#tag' === $token_type ) { - $tag_name = $processor->get_tag(); - - if ( null === $ignored_tag ) { - if ( ! $processor->is_tag_closer() && ( 'CODE' === $tag_name || 'PRE' === $tag_name ) ) { - $ignored_tag = $tag_name; - $ignored_depth = 1; - } - } elseif ( $tag_name === $ignored_tag ) { - $ignored_depth += $processor->is_tag_closer() ? -1 : 1; - - if ( 0 === $ignored_depth ) { - $ignored_tag = null; - } + $output = _wp_replace_in_html_text_nodes( + $text, + static function ( $content ) use ( $possible_emoji, $cdn_url, $ext ) { + if ( ! str_contains( $content, '&#x' ) ) { + return $content; } - continue; - } - if ( '#text' !== $token_type || null !== $ignored_tag ) { - continue; - } + foreach ( $possible_emoji as $emojum => $emoji_char ) { + if ( ! str_contains( $content, $emojum ) ) { + continue; + } - $content = $processor->get_raw_text(); + $file = str_replace( ';&#x', '-', $emojum ); + $file = str_replace( array( '&#x', ';' ), '', $file ); - if ( ! str_contains( $content, '&#x' ) ) { - continue; - } + $entity = sprintf( '%s', $cdn_url . $file . $ext, $emoji_char ); - foreach ( $possible_emoji as $emojum => $emoji_char ) { - if ( ! str_contains( $content, $emojum ) ) { - continue; + $content = str_replace( $emojum, $entity, $content ); } - $file = str_replace( ';&#x', '-', $emojum ); - $file = str_replace( array( '&#x', ';' ), '', $file ); - - $entity = sprintf( '%s', $cdn_url . $file . $ext, $emoji_char ); - - $content = str_replace( $emojum, $entity, $content ); + return $content; } - - $processor->replace_raw_text( $content ); - } - - $output = $processor->get_updated_html(); + ); // Finally, remove any stray U+FE0F characters. $output = str_replace( '️', '', $output ); diff --git a/tests/phpunit/tests/formatting/convertSmilies.php b/tests/phpunit/tests/formatting/convertSmilies.php index 7fd9c252114db..06b032c885f5d 100644 --- a/tests/phpunit/tests/formatting/convertSmilies.php +++ b/tests/phpunit/tests/formatting/convertSmilies.php @@ -190,6 +190,15 @@ public static function data_ignore_smilies_block_boundaries() { 'custom element starting with ignored name' => array( ' :) :)', 2 ), 'unknown element starting with ignored name' => array( ' :) :)', 2 ), 'element name prefixed by ignored script name' => array( ' :) :)', 2 ), + 'greater-than sign in attribute value' => array( '1 > 0 :) :)', 1 ), + 'ignored tag with greater-than in attribute' => array( '
 :) 
:)', 1 ), + 'closing tag lookalike in attribute value' => array( '
 :) 
:)', 1 ), + 'smiley inside HTML comment' => array( ' :)', 1 ), + 'ignored tag inside HTML comment' => array( ' :)', 1 ), + 'closing tag inside HTML comment' => array( '
 :) 
:)', 1 ), + 'nested ignored tags' => array( '
 :)  :) 
:)', 1 ), + 'same ignored tag nested' => array( ' :) :) :)', 1 ), + 'less-than sign in text' => array( '1 < 2 :)

:)

', 2 ), ); } From a6f708995581b249f46238552b3df03bd23e4558 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 12 Nov 2026 11:39:22 +0600 Subject: [PATCH 5/7] Formatting: Return non-string values unchanged in `convert_smilies()`. `_wp_replace_in_html_text_nodes()` only accepts a string, so passing `null` to `convert_smilies()` caused a `TypeError`, for example through `apply_filters( 'the_excerpt', null )`. Non-string values are now returned as they are, the same as when smilies are disabled. See #66134. --- src/wp-includes/formatting.php | 2 +- .../tests/formatting/convertSmilies.php | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 7a325da912cf4..b8fda26034a9b 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -3612,7 +3612,7 @@ function translate_smiley( $matches ) { function convert_smilies( $text ) { global $wp_smiliessearch; - if ( ! get_option( 'use_smilies' ) || empty( $wp_smiliessearch ) ) { + if ( ! is_string( $text ) || ! get_option( 'use_smilies' ) || empty( $wp_smiliessearch ) ) { // Return default text. return $text; } diff --git a/tests/phpunit/tests/formatting/convertSmilies.php b/tests/phpunit/tests/formatting/convertSmilies.php index 06b032c885f5d..ab7331d511247 100644 --- a/tests/phpunit/tests/formatting/convertSmilies.php +++ b/tests/phpunit/tests/formatting/convertSmilies.php @@ -412,4 +412,31 @@ public function test_smilies_with_large_text_input() { $text = '

:)'; $this->assertStringContainsString( "\xf0\x9f\x99\x82", convert_smilies( $text ) ); } + + /** + * Tests that a non-string value is returned as is, rather than causing a fatal error. + * + * @ticket 66134 + * + * @dataProvider data_non_string_input_is_returned_unchanged + * + * @param mixed $input The non-string value to pass. + */ + public function test_non_string_input_is_returned_unchanged( $input ) { + $this->assertSame( $input, convert_smilies( $input ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_non_string_input_is_returned_unchanged() { + return array( + 'null' => array( null ), + 'false' => array( false ), + 'integer' => array( 123 ), + 'array' => array( array( ':)' ) ), + ); + } } From b8749f201d4e8e6dd8c71550d4248b666615f4c1 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 12 Nov 2026 12:39:13 +0600 Subject: [PATCH 6/7] Formatting: Inline the Tag Processor loops and track ignored tags with a stack. Remove `_wp_replace_in_html_text_nodes()` and write the `next_token()` loop directly in `convert_smilies()` and `wp_staticize_emoji()`, so that skipping text inside CODE and PRE elements is visible where it happens. Open CODE and PRE elements are now kept on a stack instead of counting the depth of one tag name. A closing tag also closes the elements opened inside the one it matches, and text is converted again once the stack is empty. See #66134. --- src/wp-includes/formatting.php | 188 ++++++++++-------- .../tests/formatting/convertSmilies.php | 3 + tests/phpunit/tests/formatting/emoji.php | 3 + 3 files changed, 115 insertions(+), 79 deletions(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index b8fda26034a9b..66f8601752336 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -3617,44 +3617,13 @@ function convert_smilies( $text ) { return $text; } - return _wp_replace_in_html_text_nodes( - $text, - static function ( $content ) use ( $wp_smiliessearch ) { - return preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $content ); - } - ); -} - -/** - * Applies a callback to the text nodes of an HTML string. - * - * The callback receives the raw, undecoded source of each text node and returns - * its replacement, which is placed into the document as HTML without escaping. - * - * Only text nodes are passed to the callback. Tags and their attributes, - * comments, and the contents of elements which are not parsed as HTML, such as - * SCRIPT, STYLE and TEXTAREA, are left alone, as is any text inside CODE and - * PRE elements. - * - * @since 7.3.0 - * @access private - * - * @param string $html HTML to process. - * @param callable $callback Receives the raw source of a text node and returns its - * replacement HTML. If it returns anything other than - * a string, the text node is left unchanged. - * @return string HTML with the callback applied to its text nodes. - */ -function _wp_replace_in_html_text_nodes( string $html, callable $callback ): string { - // Without any markup the whole string is a single text node. - if ( ! str_contains( $html, '<' ) ) { - $replacement = $callback( $html ); - - return is_string( $replacement ) ? $replacement : $html; + // Without any markup the whole text is a single text node. + if ( ! str_contains( $text, '<' ) ) { + return preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $text ) ?? $text; } // Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans. - $processor = new class( $html ) extends WP_HTML_Tag_Processor { + $processor = new class( $text ) extends WP_HTML_Tag_Processor { /** * Gets the span for the current token. * @@ -3668,20 +3637,19 @@ public function get_span(): WP_HTML_Span { }; /* - * The replacements are raw HTML, which set_modifiable_text() would escape, - * so the output is assembled here from the unchanged parts of the input - * and the replaced text nodes. + * The smilies are replaced by raw HTML, which set_modifiable_text() would + * escape, so the output is assembled here from the unchanged parts of the + * text and the converted text nodes. */ $output = ''; $copied_to = 0; /* - * The Tag Processor does not track document structure, so the CODE or PRE - * element being ignored is tracked here, along with its nesting depth so - * that it only ends at its own closing tag. + * Smilies are not converted inside CODE and PRE elements. The Tag Processor + * does not track document structure, so the open CODE and PRE elements are + * tracked here. Text is ignored for as long as any of them is open. */ - $ignored_tag = null; - $ignored_depth = 0; + $ignored_tags = array(); while ( $processor->next_token() ) { $token_type = $processor->get_token_type(); @@ -3689,42 +3657,40 @@ public function get_span(): WP_HTML_Span { if ( '#tag' === $token_type ) { $tag_name = $processor->get_tag(); - if ( null === $ignored_tag ) { - if ( ! $processor->is_tag_closer() && ( 'CODE' === $tag_name || 'PRE' === $tag_name ) ) { - $ignored_tag = $tag_name; - $ignored_depth = 1; - } - } elseif ( $tag_name === $ignored_tag ) { - $ignored_depth += $processor->is_tag_closer() ? -1 : 1; + if ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) { + continue; + } - if ( 0 === $ignored_depth ) { - $ignored_tag = null; - } + if ( ! $processor->is_tag_closer() ) { + $ignored_tags[] = $tag_name; + continue; + } + + // A closing tag also closes every element opened inside the one it matches. + $opener_at = array_search( $tag_name, array_reverse( $ignored_tags, true ), true ); + if ( false !== $opener_at ) { + $ignored_tags = array_slice( $ignored_tags, 0, $opener_at ); } continue; } - if ( '#text' !== $token_type || null !== $ignored_tag ) { + if ( '#text' !== $token_type || count( $ignored_tags ) > 0 ) { continue; } - $span = $processor->get_span(); - $text = substr( $html, $span->start, $span->length ); - $replacement = $callback( $text ); + $span = $processor->get_span(); + $content = substr( $text, $span->start, $span->length ); + $converted = preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $content ); - if ( ! is_string( $replacement ) || $replacement === $text ) { + if ( null === $converted || $converted === $content ) { continue; } - $output .= substr( $html, $copied_to, $span->start - $copied_to ) . $replacement; + $output .= substr( $text, $copied_to, $span->start - $copied_to ) . $converted; $copied_to = $span->start + $span->length; } - if ( 0 === $copied_to ) { - return $html; - } - - return $output . substr( $html, $copied_to ); + return $output . substr( $text, $copied_to ); } /** @@ -6298,29 +6264,93 @@ function wp_staticize_emoji( $text ) { /** This filter is documented in wp-includes/formatting.php */ $ext = apply_filters( 'emoji_ext', '.png' ); - $output = _wp_replace_in_html_text_nodes( - $text, - static function ( $content ) use ( $possible_emoji, $cdn_url, $ext ) { - if ( ! str_contains( $content, '&#x' ) ) { - return $content; + // Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans. + $processor = new class( $text ) extends WP_HTML_Tag_Processor { + /** + * Gets the span for the current token. + * + * @return WP_HTML_Span Current token span. + */ + public function get_span(): WP_HTML_Span { + // Always called while matched on a token, so setting the bookmark never fails. + $this->set_bookmark( 'here' ); + return $this->bookmarks['here']; + } + }; + + /* + * The emoji are replaced by raw HTML, which set_modifiable_text() would + * escape, so the output is assembled here from the unchanged parts of the + * text and the converted text nodes. + */ + $output = ''; + $copied_to = 0; + + /* + * Emoji are not converted inside CODE and PRE elements. The Tag Processor + * does not track document structure, so the open CODE and PRE elements are + * tracked here. Text is ignored for as long as any of them is open. + */ + $ignored_tags = array(); + + while ( $processor->next_token() ) { + $token_type = $processor->get_token_type(); + + if ( '#tag' === $token_type ) { + $tag_name = $processor->get_tag(); + + if ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) { + continue; } - foreach ( $possible_emoji as $emojum => $emoji_char ) { - if ( ! str_contains( $content, $emojum ) ) { - continue; - } + if ( ! $processor->is_tag_closer() ) { + $ignored_tags[] = $tag_name; + continue; + } + + // A closing tag also closes every element opened inside the one it matches. + $opener_at = array_search( $tag_name, array_reverse( $ignored_tags, true ), true ); + if ( false !== $opener_at ) { + $ignored_tags = array_slice( $ignored_tags, 0, $opener_at ); + } + continue; + } - $file = str_replace( ';&#x', '-', $emojum ); - $file = str_replace( array( '&#x', ';' ), '', $file ); + if ( '#text' !== $token_type || count( $ignored_tags ) > 0 ) { + continue; + } - $entity = sprintf( '%s', $cdn_url . $file . $ext, $emoji_char ); + $span = $processor->get_span(); + $content = substr( $text, $span->start, $span->length ); - $content = str_replace( $emojum, $entity, $content ); + if ( ! str_contains( $content, '&#x' ) ) { + continue; + } + + $converted = $content; + + foreach ( $possible_emoji as $emojum => $emoji_char ) { + if ( ! str_contains( $converted, $emojum ) ) { + continue; } - return $content; + $file = str_replace( ';&#x', '-', $emojum ); + $file = str_replace( array( '&#x', ';' ), '', $file ); + + $entity = sprintf( '%s', $cdn_url . $file . $ext, $emoji_char ); + + $converted = str_replace( $emojum, $entity, $converted ); } - ); + + if ( $converted === $content ) { + continue; + } + + $output .= substr( $text, $copied_to, $span->start - $copied_to ) . $converted; + $copied_to = $span->start + $span->length; + } + + $output .= substr( $text, $copied_to ); // Finally, remove any stray U+FE0F characters. $output = str_replace( '️', '', $output ); diff --git a/tests/phpunit/tests/formatting/convertSmilies.php b/tests/phpunit/tests/formatting/convertSmilies.php index ab7331d511247..1862c7be65329 100644 --- a/tests/phpunit/tests/formatting/convertSmilies.php +++ b/tests/phpunit/tests/formatting/convertSmilies.php @@ -198,6 +198,9 @@ public static function data_ignore_smilies_block_boundaries() { 'closing tag inside HTML comment' => array( '
 :) 
:)', 1 ), 'nested ignored tags' => array( '
 :)  :) 
:)', 1 ), 'same ignored tag nested' => array( ' :) :) :)', 1 ), + 'closing tag closes ignored tags inside it' => array( '
 :) 
:) :)
', 1 ), + 'unclosed ignored tag inside ignored tag' => array( '
 :) 
:)', 1 ), + 'unmatched closing tag inside ignored tag' => array( '
 :)  :) 
:)', 1 ), 'less-than sign in text' => array( '1 < 2 :)

:)

', 2 ), ); } diff --git a/tests/phpunit/tests/formatting/emoji.php b/tests/phpunit/tests/formatting/emoji.php index 66484c535b47f..1377f35a48e62 100644 --- a/tests/phpunit/tests/formatting/emoji.php +++ b/tests/phpunit/tests/formatting/emoji.php @@ -452,6 +452,9 @@ public static function data_wp_staticize_emoji_ignore_block_boundaries() { 'closing tag inside HTML comment' => array( '
🙂
🙂', 1 ), 'nested ignored tags' => array( '
🙂🙂
🙂', 1 ), 'same ignored tag nested' => array( '🙂🙂🙂', 1 ), + 'closing tag closes ignored tags inside it' => array( '
🙂
🙂🙂
', 1 ), + 'unclosed ignored tag inside ignored tag' => array( '
🙂
🙂', 1 ), + 'unmatched closing tag inside ignored tag' => array( '
🙂🙂
🙂', 1 ), 'less-than sign in text' => array( '1 < 2 🙂

🙂

', 2 ), ); } From 38ef2a922c3765db47885392b92a14826e9e20e8 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 12 Nov 2026 13:05:47 +0600 Subject: [PATCH 7/7] Formatting: Close only the nearest matching ignored tag. A closing CODE or PRE tag removed every element opened after its opener from the stack of ignored tags. That is not how HTML is parsed: a CODE element which is still open when its containing PRE closes continues into the following text. A closing tag now removes only the nearest open element of its own name, and is skipped when none is open. Rename `$copied_to` to `$was_at` to match existing code. See #66134. --- src/wp-includes/formatting.php | 52 ++++++++++++------- .../tests/formatting/convertSmilies.php | 5 +- tests/phpunit/tests/formatting/emoji.php | 5 +- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 66f8601752336..511dd6fe64af2 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -3641,8 +3641,8 @@ public function get_span(): WP_HTML_Span { * escape, so the output is assembled here from the unchanged parts of the * text and the converted text nodes. */ - $output = ''; - $copied_to = 0; + $output = ''; + $was_at = 0; /* * Smilies are not converted inside CODE and PRE elements. The Tag Processor @@ -3655,21 +3655,27 @@ public function get_span(): WP_HTML_Span { $token_type = $processor->get_token_type(); if ( '#tag' === $token_type ) { - $tag_name = $processor->get_tag(); + $tag_name = $processor->get_tag(); + $is_closer = $processor->is_tag_closer(); - if ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) { + // A closing tag is unexpected when no CODE or PRE element is open. + if ( ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) || ( $is_closer && 0 === count( $ignored_tags ) ) ) { continue; } - if ( ! $processor->is_tag_closer() ) { + if ( ! $is_closer ) { $ignored_tags[] = $tag_name; continue; } - // A closing tag also closes every element opened inside the one it matches. + /* + * A closing tag closes the nearest open element of its own name and + * leaves the others open. A CODE element which is still open when its + * containing PRE closes continues into the text which follows. + */ $opener_at = array_search( $tag_name, array_reverse( $ignored_tags, true ), true ); if ( false !== $opener_at ) { - $ignored_tags = array_slice( $ignored_tags, 0, $opener_at ); + array_splice( $ignored_tags, $opener_at, 1 ); } continue; } @@ -3686,11 +3692,11 @@ public function get_span(): WP_HTML_Span { continue; } - $output .= substr( $text, $copied_to, $span->start - $copied_to ) . $converted; - $copied_to = $span->start + $span->length; + $output .= substr( $text, $was_at, $span->start - $was_at ) . $converted; + $was_at = $span->start + $span->length; } - return $output . substr( $text, $copied_to ); + return $output . substr( $text, $was_at ); } /** @@ -6283,8 +6289,8 @@ public function get_span(): WP_HTML_Span { * escape, so the output is assembled here from the unchanged parts of the * text and the converted text nodes. */ - $output = ''; - $copied_to = 0; + $output = ''; + $was_at = 0; /* * Emoji are not converted inside CODE and PRE elements. The Tag Processor @@ -6297,21 +6303,27 @@ public function get_span(): WP_HTML_Span { $token_type = $processor->get_token_type(); if ( '#tag' === $token_type ) { - $tag_name = $processor->get_tag(); + $tag_name = $processor->get_tag(); + $is_closer = $processor->is_tag_closer(); - if ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) { + // A closing tag is unexpected when no CODE or PRE element is open. + if ( ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) || ( $is_closer && 0 === count( $ignored_tags ) ) ) { continue; } - if ( ! $processor->is_tag_closer() ) { + if ( ! $is_closer ) { $ignored_tags[] = $tag_name; continue; } - // A closing tag also closes every element opened inside the one it matches. + /* + * A closing tag closes the nearest open element of its own name and + * leaves the others open. A CODE element which is still open when its + * containing PRE closes continues into the text which follows. + */ $opener_at = array_search( $tag_name, array_reverse( $ignored_tags, true ), true ); if ( false !== $opener_at ) { - $ignored_tags = array_slice( $ignored_tags, 0, $opener_at ); + array_splice( $ignored_tags, $opener_at, 1 ); } continue; } @@ -6346,11 +6358,11 @@ public function get_span(): WP_HTML_Span { continue; } - $output .= substr( $text, $copied_to, $span->start - $copied_to ) . $converted; - $copied_to = $span->start + $span->length; + $output .= substr( $text, $was_at, $span->start - $was_at ) . $converted; + $was_at = $span->start + $span->length; } - $output .= substr( $text, $copied_to ); + $output .= substr( $text, $was_at ); // Finally, remove any stray U+FE0F characters. $output = str_replace( '️', '', $output ); diff --git a/tests/phpunit/tests/formatting/convertSmilies.php b/tests/phpunit/tests/formatting/convertSmilies.php index 1862c7be65329..23e99e31a08ab 100644 --- a/tests/phpunit/tests/formatting/convertSmilies.php +++ b/tests/phpunit/tests/formatting/convertSmilies.php @@ -198,8 +198,9 @@ public static function data_ignore_smilies_block_boundaries() { 'closing tag inside HTML comment' => array( '
 :) 
:)', 1 ), 'nested ignored tags' => array( '
 :)  :) 
:)', 1 ), 'same ignored tag nested' => array( ' :) :) :)', 1 ), - 'closing tag closes ignored tags inside it' => array( '
 :) 
:) :)
', 1 ), - 'unclosed ignored tag inside ignored tag' => array( '
 :) 
:)', 1 ), + 'ignored tag still open after its parent' => array( '
 :) 
:) :)', 1 ), + 'ignored tag never closed inside its parent' => array( '
 :) 
:)', 0 ), + 'parent still open after ignored tag closes' => array( '
 :)  :) 
:)', 1 ), 'unmatched closing tag inside ignored tag' => array( '
 :)  :) 
:)', 1 ), 'less-than sign in text' => array( '1 < 2 :)

:)

', 2 ), ); diff --git a/tests/phpunit/tests/formatting/emoji.php b/tests/phpunit/tests/formatting/emoji.php index 1377f35a48e62..df47f1ae8698d 100644 --- a/tests/phpunit/tests/formatting/emoji.php +++ b/tests/phpunit/tests/formatting/emoji.php @@ -452,8 +452,9 @@ public static function data_wp_staticize_emoji_ignore_block_boundaries() { 'closing tag inside HTML comment' => array( '
🙂
🙂', 1 ), 'nested ignored tags' => array( '
🙂🙂
🙂', 1 ), 'same ignored tag nested' => array( '🙂🙂🙂', 1 ), - 'closing tag closes ignored tags inside it' => array( '
🙂
🙂🙂
', 1 ), - 'unclosed ignored tag inside ignored tag' => array( '
🙂
🙂', 1 ), + 'ignored tag still open after its parent' => array( '
🙂
🙂🙂', 1 ), + 'ignored tag never closed inside its parent' => array( '
🙂
🙂', 0 ), + 'parent still open after ignored tag closes' => array( '
🙂🙂
🙂', 1 ), 'unmatched closing tag inside ignored tag' => array( '
🙂🙂
🙂', 1 ), 'less-than sign in text' => array( '1 < 2 🙂

🙂

', 2 ), );