Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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 @@ -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
130 changes: 129 additions & 1 deletion tests/phpunit/tests/html-api/wpHtmlProcessorModifiableText.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ class Tests_HtmlApi_WpHtmlProcessorModifiableText extends WP_UnitTestCase {
* 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.
* 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
*
Expand Down Expand Up @@ -68,4 +69,131 @@ public static function data_modifiable_text_special_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}>",
);
}
}
}
77 changes: 77 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
Original file line number Diff line number Diff line change
Expand Up @@ -664,4 +664,81 @@ public function test_modifiable_text_special_textarea() {
'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.'
);
}

/**
* Ensures that the special newline is inserted even when seeking to the associated
* text node, especially when seeking back to it.
*
* @ticket 64609
*/
public function test_modifiable_text_special_newline_after_reverse_seek() {
$processor = new WP_HTML_Tag_Processor( '<pre>blah</pre><div><pre class="target">Replace Me!</pre><pre>blah</pre></div><img>' );

$processor->next_tag( array( 'class_name' => 'target' ) );
$processor->next_token();
$processor->set_bookmark( 'text' );

$processor->next_tag( 'IMG' );
$processor->seek( 'text' );

$processor->set_modifiable_text( "\nNewline" );

$this->assertSame(
"<pre>blah</pre><div><pre class=\"target\">\n\nNewline</pre><pre>blah</pre></div><img>",
$processor->get_updated_html(),
'Should insert a protective newline before the first text node in a PRE element.'
);
}
}
Loading