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
22 changes: 22 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 @@ -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,
Expand Down Expand Up @@ -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
Expand Down
199 changes: 199 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<?php
/**
* Unit tests covering WP_HTML_Processor modifiable text functionality.
*
* @package WordPress
* @subpackage HTML-API
* @group html-api
*
* @coversDefaultClass WP_HTML_Processor
*/
class Tests_HtmlApi_WpHtmlProcessorModifiableText extends WP_UnitTestCase {
/**
* TEXTAREA elements ignore the first newline in their content.
* Setting the modifiable text with a leading newline (or carriage return variants)
* should ensure that the leading newline is present in the resulting TEXTAREA.
*
* TEXTAREA are treated as atomic tags by the tag processor, so `set_modifiable_text()`
* is called directly on the TEXTAREA token, making them different from PRE and LISTING
* tags that also have special newline handling in HTML.
*
* @ticket 64609
*
* @dataProvider data_modifiable_text_special_textarea
*
* @param string $set_text Text to set.
* @param string $expected_html Expected HTML output.
*/
public function test_modifiable_text_special_textarea( string $set_text, string $expected_html ) {
$processor = WP_HTML_Processor::create_fragment( '<textarea></textarea>' );
$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(),
'<body>',
'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",
"<textarea>\n\nAFTER NEWLINE</textarea>",
),
'Leading carriage return' => array(
"\rCR",
"<textarea>\n\nCR</textarea>",
),
'Leading carriage return + newline' => array(
"\r\nCR-N",
"<textarea>\n\nCR-N</textarea>",
),
);
}

/**
* 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(),
'<body>',
'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<!--x--></{$tag_name}>",
1,
'REPLACEME',
"\nAFTER NEWLINE.",
"<{$tag_name}>\n\nAFTER NEWLINE.<!--x--></{$tag_name}>",
);

yield "<{$tag_name}> with leading newline, first text node" => array(
"<{$tag_name}>\nREPLACEME<!--x--></{$tag_name}>",
1,
'',
"\nAFTER NEWLINE.",
"<{$tag_name}>\n\nAFTER NEWLINE.REPLACEME<!--x--></{$tag_name}>",
);

yield "<{$tag_name}> with leading newline, second text node" => array(
"<{$tag_name}>\nREPLACEME<!--x--></{$tag_name}>",
2,
'REPLACEME',
"\nAFTER NEWLINE.",
"<{$tag_name}>\n\nAFTER NEWLINE.<!--x--></{$tag_name}>",
);

yield "<{$tag_name}> with leading space, first text node" => array(
"<{$tag_name}> REPLACEME<!--x--></{$tag_name}>",
1,
' ',
"\nAFTER NEWLINE.",
"<{$tag_name}>\n\nAFTER NEWLINE.REPLACEME<!--x--></{$tag_name}>",
);

yield "<{$tag_name}> with leading space, second text node" => array(
"<{$tag_name}> REPLACEME<!--x--></{$tag_name}>",
2,
'REPLACEME',
"\nAFTER NEWLINE.",
"<{$tag_name}>\n \nAFTER NEWLINE.<!--x--></{$tag_name}>",
);

yield "<{$tag_name}> insert with leading carriage return" => array(
"<{$tag_name}>REPLACEME<!--x--></{$tag_name}>",
1,
'REPLACEME',
"\rCR",
"<{$tag_name}>\n\nCR<!--x--></{$tag_name}>",
);

yield "<{$tag_name}> insert with leading carriage return + newline" => array(
"<{$tag_name}>REPLACEME<!--x--></{$tag_name}>",
1,
'REPLACEME',
"\r\nCR-N",
"<{$tag_name}>\n\nCR-N<!--x--></{$tag_name}>",
);

yield "<{$tag_name}> clear text" => array(
"<{$tag_name}>REPLACEME<!--x--></{$tag_name}>",
1,
'REPLACEME',
'',
"<{$tag_name}><!--x--></{$tag_name}>",
);
}
}
}
70 changes: 70 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
Original file line number Diff line number Diff line change
Expand Up @@ -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( '<textarea></textarea>' );
$processor->next_token();
$processor->set_modifiable_text( "\nAFTER NEWLINE" );
$this->assertSame(
"\nAFTER NEWLINE",
Comment on lines +648 to +652
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only asserts get_modifiable_text() after setting textarea content, but doesn’t assert the serialized output (get_updated_html()). Since the newline-stripping behavior is an HTML parsing/serialization concern (TEXTAREA drops the first LF), please also assert the updated HTML contains the extra leading newline needed to preserve the intended leading newline (and consider asserting next_token() / set_modifiable_text() return true to validate test setup).

Copilot uses AI. Check for mistakes.
$processor->get_modifiable_text(),
'Should have preserved the leading newline in the content.'
);
}
Comment on lines +647 to +656
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test coverage for TEXTAREA with leading carriage return (\r) and carriage return + newline (\r\n) is missing. Similar to the WP_HTML_Processor tests, this test should also cover these newline variants to ensure consistent behavior across different newline representations.

Copilot uses AI. Check for mistakes.

/**
* 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( '<pre>REPLACEME<!--x--></pre>' );
$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(
<<<HTML
<pre>
{$set_text}<!--x--></pre>
HTML,
$processor->get_updated_html(),
'<body>',
'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( '<listing>REPLACEME<!--x--></listing>' );
$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(
<<<HTML
<listing>
{$set_text}<!--x--></listing>
HTML,
$processor->get_updated_html(),
'<body>',
'Should have preserved the leading newline in the content.'
);
}
}
Loading