Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
'<body>',
'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<string, array{0: string, 1: string, 2: string}>
*/
public static function data_modifiable_text_ignores_leading_newline_text_nodes(): array {
return array(
'PRE leading newline' => array(
'PRE',
"\nAFTER NEWLINE",
"<pre>\n\nAFTER NEWLINE</pre>",
),
'PRE leading carriage return' => array(
'PRE',
"\rAFTER CR",
"<pre>\n\nAFTER CR</pre>",
),
'PRE leading carriage return + newline' => array(
'PRE',
"\r\nAFTER CRLF",
"<pre>\n\nAFTER CRLF</pre>",
),
'LISTING leading newline' => array(
'LISTING',
"\nAFTER NEWLINE",
"<listing>\n\nAFTER NEWLINE</listing>",
),
'LISTING leading carriage return' => array(
'LISTING',
"\rAFTER CR",
"<listing>\n\nAFTER CR</listing>",
),
'LISTING leading carriage return + newline' => array(
'LISTING',
"\r\nAFTER CRLF",
"<listing>\n\nAFTER CRLF</listing>",
),
);
}

/**
* Ensures that `set_modifiable_text()` returns false for elements that are not special "atomic" elements.
*
Expand Down
94 changes: 94 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,100 @@ public function test_modifiable_text_special_textarea(): void {
);
}

/**
* 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 = new WP_HTML_Tag_Processor( $html );
$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,
'<body>',
'Should have preserved the leading newline in the serialized HTML.'
);

$processor = new WP_HTML_Tag_Processor( $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<string, array{0: string, 1: string, 2: string}>
*/
public static function data_modifiable_text_ignores_leading_newline_text_nodes(): array {
return array(
'PRE leading newline' => array(
'PRE',
"\nAFTER NEWLINE",
"<pre>\n\nAFTER NEWLINE</pre>",
),
'PRE leading carriage return' => array(
'PRE',
"\rAFTER CR",
"<pre>\n\nAFTER CR</pre>",
),
'PRE leading carriage return + newline' => array(
'PRE',
"\r\nAFTER CRLF",
"<pre>\n\nAFTER CRLF</pre>",
),
'LISTING leading newline' => array(
'LISTING',
"\nAFTER NEWLINE",
"<listing>\n\nAFTER NEWLINE</listing>",
),
'LISTING leading carriage return' => array(
'LISTING',
"\rAFTER CR",
"<listing>\n\nAFTER CR</listing>",
),
'LISTING leading carriage return + newline' => array(
'LISTING',
"\r\nAFTER CRLF",
"<listing>\n\nAFTER CRLF</listing>",
),
);
}

/**
* Ensures that `set_modifiable_text()` returns false for elements that are
* not special "atomic" elements.
Expand Down
Loading