Skip to content
Open
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}>",
);
}
}
}
80 changes: 80 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,84 @@ 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",
$processor->get_modifiable_text(),
'Should have preserved the leading newline in the content.'
);
$this->assertEqualHTML(
<<<'HTML'
<textarea>

AFTER NEWLINE</textarea>
HTML,
$processor->get_updated_html(),
'<body>',
'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( '<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