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 e41e1120550b5..615f2491d4d4a 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 @@ -3793,6 +3793,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->token_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, diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php index 5d093ae05dd07..868e392387fd1 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php @@ -69,6 +69,102 @@ public static function data_modifiable_text_special_textarea(): array { ); } + /** + * Ensures that text node updates preserve a leading newline where the HTML parser + * ignores one newline after the opening tag. + * + * @ticket 64609 + * + * @dataProvider data_modifiable_text_ignores_leading_newline_text_nodes + * + * @param string $tag_name Expected tag name. + * @param string $set_text Text to set. + * @param string $expected_html Expected HTML output. + */ + public function test_modifiable_text_preserves_ignored_leading_newline_in_text_nodes( string $tag_name, string $set_text, string $expected_html ): void { + $html = "<{$tag_name}>initial{$tag_name}>"; + $processor = WP_HTML_Processor::create_fragment( $html ); + $this->assertNotNull( $processor, 'Failed to create a processor.' ); + $this->assertTrue( $processor->next_tag( $tag_name ), 'Failed to find target tag.' ); + $this->assertTrue( $processor->next_token(), 'Failed to find text node.' ); + $this->assertSame( '#text', $processor->get_token_name(), 'Should have found a text node.' ); + + $this->assertTrue( $processor->set_modifiable_text( $set_text ) ); + $expected_text = strtr( + $set_text, + array( + "\r\n" => "\n", + "\r" => "\n", + ) + ); + $this->assertSame( + $expected_text, + $processor->get_modifiable_text(), + 'Should have preserved the leading newline when reading the updated text.' + ); + + $updated_html = $processor->get_updated_html(); + $this->assertEqualHTML( + $expected_html, + $updated_html, + '
', + 'Should have preserved the leading newline in the serialized HTML.' + ); + + $processor = WP_HTML_Processor::create_fragment( $updated_html ); + $this->assertNotNull( $processor, 'Failed to reprocess updated HTML.' ); + $this->assertTrue( $processor->next_tag( $tag_name ), 'Failed to find target tag after update.' ); + $roundtrip_text = ''; + while ( $processor->next_token() && '#text' === $processor->get_token_name() ) { + $roundtrip_text .= $processor->get_modifiable_text(); + } + $this->assertSame( + $expected_text, + $roundtrip_text, + 'Should have preserved the leading newline after reprocessing the updated HTML.' + ); + } + + /** + * Data provider. + * + * @return array\n\nAFTER NEWLINE", + ), + 'PRE leading carriage return' => array( + 'PRE', + "\rAFTER CR", + "
\n\nAFTER CR", + ), + 'PRE leading carriage return + newline' => array( + 'PRE', + "\r\nAFTER CRLF", + "
\n\nAFTER CRLF", + ), + 'LISTING leading newline' => array( + 'LISTING', + "\nAFTER NEWLINE", + "
\n\nAFTER NEWLINE", + ), + 'PRE leading carriage return' => array( + 'PRE', + "\rAFTER CR", + "
\n\nAFTER CR", + ), + 'PRE leading carriage return + newline' => array( + 'PRE', + "\r\nAFTER CRLF", + "
\n\nAFTER CRLF", + ), + 'LISTING leading newline' => array( + 'LISTING', + "\nAFTER NEWLINE", + "