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 501a623afb10b..7f7806735460d 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
@@ -826,6 +826,23 @@ class WP_HTML_Tag_Processor {
*/
private $skip_newline_at = null;
+ /**
+ * Indicates which bookmarks point to a token which immediately follows
+ * the opening tag of a LISTING or PRE element, where a leading newline
+ * should be ignored when reading modifiable text.
+ *
+ * When seeking to a bookmark, this state must be restored because it
+ * cannot be re-derived from the bookmarked location alone.
+ *
+ * @since 7.1.0
+ *
+ * @see WP_HTML_Tag_Processor::$skip_newline_at
+ * @see WP_HTML_Tag_Processor::seek()
+ *
+ * @var array
+ */
+ private $bookmark_skips_newline = array();
+
/**
* Constructor.
*
@@ -1358,6 +1375,12 @@ public function set_bookmark( $name ): bool {
$this->bookmarks[ $name ] = new WP_HTML_Span( $this->token_starts_at, $this->token_length );
+ if ( $this->token_starts_at === $this->skip_newline_at ) {
+ $this->bookmark_skips_newline[ $name ] = true;
+ } else {
+ unset( $this->bookmark_skips_newline[ $name ] );
+ }
+
return true;
}
@@ -1376,7 +1399,7 @@ public function release_bookmark( $name ): bool {
return false;
}
- unset( $this->bookmarks[ $name ] );
+ unset( $this->bookmarks[ $name ], $this->bookmark_skips_newline[ $name ] );
return true;
}
@@ -2507,6 +2530,7 @@ private function class_name_updates_to_attributes_updates(): void {
* @since 6.2.0
* @since 6.2.1 Accumulates shift for internal cursor and passed pointer.
* @since 6.3.0 Invalidate any bookmarks whose targets are overwritten.
+ * @since 7.1.0 Accumulates shift for the ignored-newline position after LISTING and PRE opening tags.
* @ignore
*
* @param int $shift_this_point Accumulate and return shift for this position.
@@ -2517,7 +2541,8 @@ private function apply_attributes_updates( int $shift_this_point ): int {
return 0;
}
- $accumulated_shift_for_given_point = 0;
+ $accumulated_shift_for_given_point = 0;
+ $accumulated_shift_for_skip_newline = 0;
/*
* Attribute updates can be enqueued in any order but updates
@@ -2541,6 +2566,11 @@ private function apply_attributes_updates( int $shift_this_point ): int {
$this->bytes_already_parsed += $shift;
}
+ // Accumulate shift of the ignored-newline position within this function call.
+ if ( null !== $this->skip_newline_at && $diff->start < $this->skip_newline_at ) {
+ $accumulated_shift_for_skip_newline += $shift;
+ }
+
// Accumulate shift of the given pointer within this function call.
if ( $diff->start < $shift_this_point ) {
$accumulated_shift_for_given_point += $shift;
@@ -2553,6 +2583,11 @@ private function apply_attributes_updates( int $shift_this_point ): int {
$this->html = $output_buffer . substr( $this->html, $bytes_already_copied );
+ // Adjust the ignored-newline position by however much the updates moved it.
+ if ( null !== $this->skip_newline_at ) {
+ $this->skip_newline_at += $accumulated_shift_for_skip_newline;
+ }
+
/*
* Adjust bookmark locations to account for how the text
* replacements adjust offsets in the input document.
@@ -2620,6 +2655,7 @@ public function has_bookmark( $bookmark_name ): bool {
* maximum limit on the number of times seek() can be called.
*
* @since 6.2.0
+ * @since 7.1.0 Restores the ignored-newline state for tokens following LISTING and PRE opening tags.
*
* @param string $bookmark_name Jump to the place in the document identified by this bookmark name.
* @return bool Whether the internal cursor was successfully moved to the bookmark's location.
@@ -2658,6 +2694,18 @@ public function seek( $bookmark_name ): bool {
// Point this tag processor before the sought tag opener and consume it.
$this->bytes_already_parsed = $this->bookmarks[ $bookmark_name ]->start;
$this->parser_state = self::STATE_READY;
+
+ /*
+ * The leading newline after a LISTING or PRE opening tag is ignored
+ * as an authoring convenience. This state is set when scanning past
+ * one of these opening tags, but a later LISTING or PRE tag may have
+ * overwritten it; it must be restored from the bookmark when seeking
+ * to a token which immediately follows such an opening tag.
+ */
+ $this->skip_newline_at = isset( $this->bookmark_skips_newline[ $bookmark_name ] )
+ ? $this->bytes_already_parsed
+ : null;
+
return $this->next_token();
}
@@ -3642,15 +3690,10 @@ public function subdivide_text_appropriately(): bool {
* that a token has modifiable text, and a token with modifiable text may
* have an empty string (e.g. a comment with no contents).
*
- * Limitations:
- *
- * - This function will not strip the leading newline appropriately
- * after seeking into a LISTING or PRE element. To ensure that the
- * newline is treated properly, seek to the LISTING or PRE opening
- * tag instead of to the first text node inside the element.
- *
* @since 6.5.0
* @since 6.7.0 Replaces NULL bytes (U+0000) and newlines appropriately.
+ * @since 7.1.0 Ignores the leading newline after LISTING and PRE opening tags even after
+ * seeking or applying enqueued updates.
*
* @return string
*/
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
index f43d1fffaad0e..09aaa59e48828 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
@@ -275,6 +275,8 @@ public function test_modifiable_text_reads_updates_after_setting() {
/**
* Ensures that when ignoring a newline after LISTING and PRE tags, that this
* happens appropriately after seeking.
+ *
+ * @ticket 65372
*/
public function test_get_modifiable_text_ignores_newlines_after_seeking() {
$processor = new WP_HTML_Tag_Processor(
@@ -324,14 +326,10 @@ public function test_get_modifiable_text_ignores_newlines_after_seeking() {
);
$processor->seek( 'listing' );
- if ( "\ngone" === $processor->get_modifiable_text() ) {
- $this->markTestSkipped( "There's no support currently for handling the leading newline after seeking." );
- }
-
$this->assertSame(
'gone',
$processor->get_modifiable_text(),
- 'Should have remembered to remote leading newline from LISTING element after seeking around it.'
+ 'Should have remembered to remove the leading newline from LISTING element after seeking around it.'
);
$processor->seek( 'div' );
@@ -342,6 +340,189 @@ public function test_get_modifiable_text_ignores_newlines_after_seeking() {
);
}
+ /**
+ * Ensures that seeking directly to a text node immediately following a PRE
+ * or LISTING opener continues to ignore its leading newline, even after
+ * passing another PRE or LISTING tag before seeking.
+ *
+ * @ticket 65372
+ *
+ * @covers WP_HTML_Tag_Processor::seek
+ * @covers WP_HTML_Tag_Processor::get_modifiable_text
+ *
+ * @dataProvider data_pre_and_listing_tags
+ *
+ * @param string $tag_name Tag name of the element which ignores a leading newline.
+ */
+ public function test_get_modifiable_text_ignores_leading_newline_after_seeking_directly_to_text( string $tag_name ) {
+ $tag = strtolower( $tag_name );
+ $processor = new WP_HTML_Tag_Processor( "<{$tag}>\n \n\n><{$tag}>" );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the first tag: check test setup.' );
+ $this->assertSame( $tag_name, $processor->get_token_name(), 'Failed to find the first tag: check test setup.' );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the text node: check test setup.' );
+ $this->assertSame( '#text', $processor->get_token_name(), 'Failed to find the text node: check test setup.' );
+
+ $before_seeking = $processor->get_modifiable_text();
+ $this->assertSame( " \n\n>", $before_seeking, 'Should have ignored the leading newline on the first traversal.' );
+ $this->assertTrue( $processor->set_bookmark( 'text' ), 'Failed to set a bookmark on the text node: check test setup.' );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the second tag: check test setup.' );
+ $this->assertSame( $tag_name, $processor->get_token_name(), 'Failed to find the second tag: check test setup.' );
+
+ $this->assertTrue( $processor->seek( 'text' ), 'Failed to seek to the bookmarked text node.' );
+ $this->assertSame( $before_seeking, $processor->get_modifiable_text(), 'Should have ignored the leading newline after seeking back to the text node.' );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_pre_and_listing_tags() {
+ return array(
+ 'PRE' => array( 'PRE' ),
+ 'LISTING' => array( 'LISTING' ),
+ );
+ }
+
+ /**
+ * Ensures that reading the text node immediately following a PRE or
+ * LISTING opener continues to ignore its leading newline after enqueued
+ * updates have been applied and document offsets have shifted.
+ *
+ * @ticket 65372
+ *
+ * @covers WP_HTML_Tag_Processor::get_updated_html
+ * @covers WP_HTML_Tag_Processor::get_modifiable_text
+ *
+ * @dataProvider data_pre_and_listing_tags
+ *
+ * @param string $tag_name Tag name of the element which ignores a leading newline.
+ */
+ public function test_get_modifiable_text_ignores_leading_newline_after_applying_updates( string $tag_name ) {
+ $tag = strtolower( $tag_name );
+ $processor = new WP_HTML_Tag_Processor( "<{$tag} class=\"pad\">\nfoo" );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the tag: check test setup.' );
+ $this->assertSame( $tag_name, $processor->get_token_name(), 'Failed to find the tag: check test setup.' );
+ $processor->remove_attribute( 'class' );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the text node: check test setup.' );
+ $this->assertSame( '#text', $processor->get_token_name(), 'Failed to find the text node: check test setup.' );
+
+ $before_applying = $processor->get_modifiable_text();
+ $this->assertSame( 'foo', $before_applying, 'Should have ignored the leading newline before applying updates.' );
+
+ $processor->get_updated_html();
+ $this->assertSame( $before_applying, $processor->get_modifiable_text(), 'Should have ignored the leading newline after applying updates.' );
+ }
+
+ /**
+ * Ensures that reading the text node immediately following a PRE or
+ * LISTING opener continues to ignore its leading newline after applying
+ * multiple enqueued updates of different sizes in a single pass.
+ *
+ * @ticket 65372
+ *
+ * @covers WP_HTML_Tag_Processor::get_updated_html
+ * @covers WP_HTML_Tag_Processor::get_modifiable_text
+ *
+ * @dataProvider data_pre_and_listing_tags
+ *
+ * @param string $tag_name Tag name of the element which ignores a leading newline.
+ */
+ public function test_get_modifiable_text_ignores_leading_newline_after_applying_multiple_updates( string $tag_name ) {
+ $tag = strtolower( $tag_name );
+ $processor = new WP_HTML_Tag_Processor( "x
<{$tag} aaaaaaaaaaaaaaaaaaaaaaaa=\"x\" b=\"y\">\nfoo" );
+
+ $this->assertTrue( $processor->next_tag( 'DIV' ), 'Failed to find the DIV: check test setup.' );
+ $processor->remove_attribute( 'class' );
+
+ $this->assertTrue( $processor->next_tag( $tag_name ), 'Failed to find the tag: check test setup.' );
+ $processor->remove_attribute( 'aaaaaaaaaaaaaaaaaaaaaaaa' );
+ $processor->remove_attribute( 'b' );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the text node: check test setup.' );
+ $this->assertSame( '#text', $processor->get_token_name(), 'Failed to find the text node: check test setup.' );
+
+ $before_applying = $processor->get_modifiable_text();
+ $this->assertSame( 'foo', $before_applying, 'Should have ignored the leading newline before applying updates.' );
+
+ $processor->get_updated_html();
+ $this->assertSame( $before_applying, $processor->get_modifiable_text(), 'Should have ignored the leading newline after applying updates.' );
+ }
+
+ /**
+ * Ensures that the text node immediately following a PRE or LISTING opener
+ * continues to ignore its leading newline after applying an attribute
+ * update on the opener together with a replacement of the text itself.
+ *
+ * @ticket 65372
+ *
+ * @covers WP_HTML_Tag_Processor::get_updated_html
+ * @covers WP_HTML_Tag_Processor::get_modifiable_text
+ *
+ * @dataProvider data_pre_and_listing_tags
+ *
+ * @param string $tag_name Tag name of the element which ignores a leading newline.
+ */
+ public function test_get_modifiable_text_ignores_leading_newline_after_growing_opener_and_replacing_text( string $tag_name ) {
+ $tag = strtolower( $tag_name );
+ $processor = new WP_HTML_Tag_Processor( "<{$tag}>\nfoo" );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the tag: check test setup.' );
+ $this->assertSame( $tag_name, $processor->get_token_name(), 'Failed to find the tag: check test setup.' );
+ $processor->set_attribute( 'class', 'wide' );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the text node: check test setup.' );
+ $this->assertSame( '#text', $processor->get_token_name(), 'Failed to find the text node: check test setup.' );
+ $this->assertTrue( $processor->set_modifiable_text( "\nlonger" ), 'Failed to replace the modifiable text: check test setup.' );
+
+ $before_applying = $processor->get_modifiable_text();
+ $this->assertSame( 'longer', $before_applying, 'Should have ignored the leading newline before applying updates.' );
+
+ $processor->get_updated_html();
+ $this->assertSame( $before_applying, $processor->get_modifiable_text(), 'Should have ignored the leading newline after applying updates.' );
+ }
+
+ /**
+ * Ensures that seeking directly to a text node immediately following a PRE
+ * or LISTING opener continues to ignore its leading newline when document
+ * offsets have shifted from applying enqueued attribute updates.
+ *
+ * @ticket 65372
+ *
+ * @covers WP_HTML_Tag_Processor::seek
+ * @covers WP_HTML_Tag_Processor::get_modifiable_text
+ *
+ * @dataProvider data_pre_and_listing_tags
+ *
+ * @param string $tag_name Tag name of the element which ignores a leading newline.
+ */
+ public function test_get_modifiable_text_ignores_leading_newline_after_seeking_when_offsets_have_shifted( string $tag_name ) {
+ $tag = strtolower( $tag_name );
+ $processor = new WP_HTML_Tag_Processor( "<{$tag} class=\"pad\">\nfoo<{$tag}>" );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the first tag: check test setup.' );
+ $this->assertSame( $tag_name, $processor->get_token_name(), 'Failed to find the first tag: check test setup.' );
+ $processor->remove_attribute( 'class' );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the text node: check test setup.' );
+ $this->assertSame( '#text', $processor->get_token_name(), 'Failed to find the text node: check test setup.' );
+
+ $before_seeking = $processor->get_modifiable_text();
+ $this->assertSame( 'foo', $before_seeking, 'Should have ignored the leading newline on the first traversal.' );
+ $this->assertTrue( $processor->set_bookmark( 'text' ), 'Failed to set a bookmark on the text node: check test setup.' );
+
+ $this->assertTrue( $processor->next_token(), 'Failed to find the second tag: check test setup.' );
+ $this->assertSame( $tag_name, $processor->get_token_name(), 'Failed to find the second tag: check test setup.' );
+
+ $this->assertTrue( $processor->seek( 'text' ), 'Failed to seek to the bookmarked text node.' );
+ $this->assertSame( $before_seeking, $processor->get_modifiable_text(), 'Should have ignored the leading newline after seeking back to the text node.' );
+ }
+
/**
* Ensures that modifiable text updates are not applied where they aren't supported.
*