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 69e3e5d2c7557..7843a83b3932c 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 @@ -3770,6 +3770,17 @@ public function get_modifiable_text(): string { */ public function set_modifiable_text( string $plaintext_content ): bool { if ( self::STATE_TEXT_NODE === $this->parser_state ) { + /* + * HTML ignores a single leading newline in this context. If a leading newline + * is intended, preserve it by adding an extra newline. + */ + if ( + $this->skip_newline_at === $this->text_starts_at && + 1 === strspn( $plaintext_content, "\n\r", 0, 1 ) + ) { + $plaintext_content = "\n{$plaintext_content}"; + } + $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( $this->text_starts_at, $this->text_length, @@ -3877,6 +3888,17 @@ static function ( $tag_match ) { $plaintext_content ); + /* + * HTML ignores a single leading newline in this context. If a leading newline + * is intended, preserve it by adding an extra newline. + */ + if ( + 'TEXTAREA' === $this->get_tag() && + 1 === strspn( $plaintext_content, "\n\r", 0, 1 ) + ) { + $plaintext_content = "\n{$plaintext_content}"; + } + /* * These don't _need_ to be escaped, but since they are decoded it's * safe to leave them escaped and this can prevent other code from diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php new file mode 100644 index 0000000000000..5a4bcfe606459 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php @@ -0,0 +1,199 @@ +' ); + $processor->next_token(); + $processor->set_modifiable_text( $set_text ); + $this->assertSame( + strtr( + $set_text, + array( + "\r\n" => "\n", + "\r" => "\n", + ) + ), + $processor->get_modifiable_text(), + 'Should have preserved or normalized the leading newline in the TEXTAREA content.' + ); + $this->assertEqualHTML( + $expected_html, + $processor->get_updated_html(), + '
', + 'Should have correctly output the TEXTAREA HTML.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_modifiable_text_special_textarea() { + return array( + 'Leading newline' => array( + "\nAFTER NEWLINE", + "", + ), + 'Leading carriage return' => array( + "\rCR", + "", + ), + 'Leading carriage return + newline' => array( + "\r\nCR-N", + "", + ), + ); + } + + /** + * PRE and LISTING elements ignore the first newline in their content. + * Leading whitespace may split into multiple text nodes in the HTML Processor. + * Setting the modifiable text with a leading newline should ensure that the + * leading newline is present in the resulting element. + * + * The HTML Processor has special behavior when a text node starts with whitespace. + * Test that PRE and LISTING `::set_modifiable_text()` handling works correctly + * with leading whitespace. + * + * @ticket 64609 + * + * @dataProvider data_modifiable_text_special_leading_whitespace + * + * @param string $html HTML containing the element to test. + * @param int $advance_n_tokens Count of times to run `next_token()` after `next_tag()`. + * @param string $stopped_on_text Expected modifiable text before the update. + * @param string $set_text Text to set. + * @param string $expected_html Expected HTML output after setting modifiable text. + */ + public function test_modifiable_text_special_leading_whitespace( + string $html, + int $advance_n_tokens, + string $stopped_on_text, + string $set_text, + string $expected_html + ) { + $processor = WP_HTML_Processor::create_fragment( $html ); + $processor->next_tag(); + while ( --$advance_n_tokens >= 0 ) { + $processor->next_token(); + } + $this->assertSame( '#text', $processor->get_token_type() ); + $this->assertSame( $stopped_on_text, $processor->get_modifiable_text() ); + $processor->set_modifiable_text( $set_text ); + + // Newline normalization transforms \r and \r\n into \n. + $this->assertSame( + strtr( + $set_text, + array( + "\r\n" => "\n", + "\r" => "\n", + ) + ), + $processor->get_modifiable_text() + ); + $this->assertEqualHTML( + $expected_html, + $processor->get_updated_html(), + '', + 'Should have preserved the leading newline in the element content.' + ); + } + + /** + * Data provider. + */ + public static function data_modifiable_text_special_leading_whitespace() { + $tags = array( 'pre', 'listing' ); + foreach ( $tags as $tag_name ) { + yield "<{$tag_name}> with no leading newline" => array( + "<{$tag_name}>REPLACEME{$tag_name}>", + 1, + 'REPLACEME', + "\nAFTER NEWLINE.", + "<{$tag_name}>\n\nAFTER NEWLINE.{$tag_name}>", + ); + + yield "<{$tag_name}> with leading newline, first text node" => array( + "<{$tag_name}>\nREPLACEME{$tag_name}>", + 1, + '', + "\nAFTER NEWLINE.", + "<{$tag_name}>\n\nAFTER NEWLINE.REPLACEME{$tag_name}>", + ); + + yield "<{$tag_name}> with leading newline, second text node" => array( + "<{$tag_name}>\nREPLACEME{$tag_name}>", + 2, + 'REPLACEME', + "\nAFTER NEWLINE.", + "<{$tag_name}>\n\nAFTER NEWLINE.{$tag_name}>", + ); + + yield "<{$tag_name}> with leading space, first text node" => array( + "<{$tag_name}> REPLACEME{$tag_name}>", + 1, + ' ', + "\nAFTER NEWLINE.", + "<{$tag_name}>\n\nAFTER NEWLINE.REPLACEME{$tag_name}>", + ); + + yield "<{$tag_name}> with leading space, second text node" => array( + "<{$tag_name}> REPLACEME{$tag_name}>", + 2, + 'REPLACEME', + "\nAFTER NEWLINE.", + "<{$tag_name}>\n \nAFTER NEWLINE.{$tag_name}>", + ); + + yield "<{$tag_name}> insert with leading carriage return" => array( + "<{$tag_name}>REPLACEME{$tag_name}>", + 1, + 'REPLACEME', + "\rCR", + "<{$tag_name}>\n\nCR{$tag_name}>", + ); + + yield "<{$tag_name}> insert with leading carriage return + newline" => array( + "<{$tag_name}>REPLACEME{$tag_name}>", + 1, + 'REPLACEME', + "\r\nCR-N", + "<{$tag_name}>\n\nCR-N{$tag_name}>", + ); + + yield "<{$tag_name}> clear text" => array( + "<{$tag_name}>REPLACEME{$tag_name}>", + 1, + 'REPLACEME', + '', + "<{$tag_name}>{$tag_name}>", + ); + } + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php index 9e0d94aecd17e..b4262f80ea3d9 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php @@ -636,4 +636,74 @@ public function test_json_auto_escaping() { $decoded_json_from_html ); } + + /** + * TEXTAREA elements ignore the first newline in their content. + * Setting the modifiable text with a leading newline should ensure that the leading newline + * is present in the resulting element. + * + * @ticket 64609 + */ + public function test_modifiable_text_special_textarea() { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_token(); + $processor->set_modifiable_text( "\nAFTER NEWLINE" ); + $this->assertSame( + "\nAFTER NEWLINE", + $processor->get_modifiable_text(), + 'Should have preserved the leading newline in the content.' + ); + } + + /** + * PRE elements ignore the first newline in their content. + * Setting the modifiable text with a leading newline should ensure that the leading newline + * is present in the resulting element. + * + * @ticket 64609 + */ + public function test_modifiable_text_special_pre() { + $set_text = "\nAFTER NEWLINE"; + $processor = new WP_HTML_Tag_Processor( 'REPLACEME' ); + $processor->next_tag(); + $processor->next_token(); + $this->assertSame( '#text', $processor->get_token_type() ); + $processor->set_modifiable_text( $set_text ); + $this->assertSame( $set_text, $processor->get_modifiable_text() ); + $this->assertEqualHTML( + << + {$set_text} + HTML, + $processor->get_updated_html(), + '', + 'Should have preserved the leading newline in the content.' + ); + } + + /** + * LISTING elements ignore the first newline in their content. + * Setting the modifiable text with a leading newline should ensure that the leading newline + * is present in the resulting element. + * + * @ticket 64609 + */ + public function test_modifiable_text_special_listing() { + $set_text = "\nAFTER NEWLINE"; + $processor = new WP_HTML_Tag_Processor( '