diff --git a/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php b/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php index c7e38e6da00c5..bdfd1e5e501c4 100644 --- a/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php +++ b/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php @@ -43,6 +43,17 @@ class WP_HTML_Active_Formatting_Elements { */ private $stack = array(); + /** + * Holds a stack of hashes representing uniquely representing the active formatting element. + * + * This is important to efficiently track and remove duplicate elements when pushing. + * + * @since 7.0.0 + * + * @var string[] + */ + private $hash_stack = array(); + /** * Returns the node at the given 1-offset index in the list of active formatting elements. * @@ -111,7 +122,52 @@ public function current_node() { * @since 6.7.0 */ public function insert_marker(): void { - $this->push( new WP_HTML_Token( null, 'marker', false ) ); + $this->stack[] = new WP_HTML_Token( null, 'marker', false ); + $this->hash_stack[] = 'marker'; + } + + /** + * Generates a hash string for a given token, based on its + * tag name, namespace, and attributes. + * + * @since 7.0.0 + * + * @param WP_HTML_Token $token Token to generate a hash for. + * @param string $token_html The original HTML of the token. + * @return string Generated hash string. + */ + private function get_token_hash( WP_HTML_Token $token, string $token_html ): string { + $processor = new WP_HTML_Tag_Processor( $token_html ); + $processor->change_parsing_namespace( $token->namespace ); + $processor->next_tag(); + + $node_name = $processor->get_qualified_tag_name(); + $hash_string = "{$token->namespace}::<{$node_name}"; + + $attribute_names = $processor->get_attribute_names_with_prefix( '' ); + if ( ! empty( $attribute_names ) ) { + $attr_parts = []; + sort( $attribute_names, SORT_STRING ); + foreach ( $attribute_names as $attribute_name ) { + $display_name = $processor->get_qualified_attribute_name( $attribute_name ); + $val = $processor->get_attribute( $attribute_name ); + + /* + * Attributes with no value are `true` with the HTML API, + * We map use the empty string value in the tree structure. + */ + if ( true === $val ) { + $val = ''; + } + $val = strtr( $val, '"', '"' ); + + $attr_parts[] = "{$display_name}=\"{$val}\""; + } + $hash_string .= ' ' . implode( ' ', $attr_parts ); + } + $hash_string .= '>'; + + return dechex( crc32( $hash_string ) ); } /** @@ -124,7 +180,7 @@ public function insert_marker(): void { * @param WP_HTML_Token $token Push this node onto the stack. * @return bool Whether a node was pushed onto the stack of active formatting elements. */ - public function push( WP_HTML_Token $token ): bool { + public function push( WP_HTML_Token $token, string $token_html ): bool { /* * > If there are already three elements in the list of active formatting elements after the last marker, * > if any, or anywhere in the list if there are no markers, that have the same tag name, namespace, and @@ -135,29 +191,35 @@ public function push( WP_HTML_Token $token ): bool { * > (the order of the attributes does not matter). */ - if ( 'marker' !== $token->node_name ) { - $existing_count = 0; - foreach ( $this->walk_up() as $item ) { - if ( 'marker' === $item->node_name ) { - break; - } + if ( 'marker' === $token->node_name ) { + _doing_it_wrong( + __METHOD__, + 'Markers must be added using the WP_HTML_Active_Formatting_Elements::insert_marker() method.', + '7.0.0' + ); + return false; + } - if ( - $item->node_name === $token->node_name && - $item->namespace === $token->namespace - // @todo Compare attributes. For now, bail if there are three matching tag names + namespaces. - ) { - ++$existing_count; - if ( $existing_count >= 3 ) { - // @todo Implement removing the earliest element and moving forward. - return false; - } + $token_hash = $this->get_token_hash( $token, $token_html ); + $existing_count = 0; + for ( $i = count( $this->hash_stack ) - 1; $i >= 0; $i-- ) { + $item_hash = $this->hash_stack[ $i ]; + + if ( 'marker' === $item_hash ) { + break; + } + + if ( $item_hash === $token_hash ) { + if ( ++$existing_count >= 3 ) { + $this->remove_node( $this->stack[ $i ] ); + break; } } } // > Add element to the list of active formatting elements. - $this->stack[] = $token; + $this->stack[] = $token; + $this->hash_stack[] = $token_hash; return true; } @@ -177,6 +239,7 @@ public function remove_node( WP_HTML_Token $token ) { $position_from_start = $this->count() - $position_from_end - 1; array_splice( $this->stack, $position_from_start, 1 ); + array_splice( $this->hash_stack, $position_from_start, 1 ); return true; } @@ -255,6 +318,7 @@ public function walk_up() { public function clear_up_to_last_marker(): void { foreach ( $this->walk_up() as $item ) { array_pop( $this->stack ); + array_pop( $this->hash_stack ); if ( 'marker' === $item->node_name ) { break; } diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 991923ba870e4..fdb2263ba31e7 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -2778,7 +2778,9 @@ private function step_in_body(): bool { $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); - if ( false === $this->state->active_formatting_elements->push( $this->state->current_token ) ) { + $bookmark = $this->bookmarks[ $this->state->current_token->bookmark_name ]; + $token_html = substr( $this->html, $bookmark->start, $bookmark->length ); + if ( false === $this->state->active_formatting_elements->push( $this->state->current_token, $token_html ) ) { $this->bail( 'Cannot track formatting elements when encountering a fourth identical token.' ); } $this->actively_reconstructed_formatting_attributes[ $this->state->current_token->bookmark_name ] = $this->attributes; @@ -2802,7 +2804,9 @@ private function step_in_body(): bool { case '+U': $this->reconstruct_active_formatting_elements(); $this->insert_html_element( $this->state->current_token ); - if ( false === $this->state->active_formatting_elements->push( $this->state->current_token ) ) { + $bookmark = $this->bookmarks[ $this->state->current_token->bookmark_name ]; + $token_html = substr( $this->html, $bookmark->start, $bookmark->length ); + if ( false === $this->state->active_formatting_elements->push( $this->state->current_token, $token_html ) ) { $this->bail( 'Cannot track formatting elements when encountering a fourth identical token.' ); } $this->actively_reconstructed_formatting_attributes[ $this->state->current_token->bookmark_name ] = $this->attributes; @@ -2821,7 +2825,9 @@ private function step_in_body(): bool { } $this->insert_html_element( $this->state->current_token ); - if ( false === $this->state->active_formatting_elements->push( $this->state->current_token ) ) { + $bookmark = $this->bookmarks[ $this->state->current_token->bookmark_name ]; + $token_html = substr( $this->html, $bookmark->start, $bookmark->length ); + if ( false === $this->state->active_formatting_elements->push( $this->state->current_token, $token_html ) ) { $this->bail( 'Cannot track formatting elements when encountering a fourth identical token.' ); } $this->actively_reconstructed_formatting_attributes[ $this->state->current_token->bookmark_name ] = $this->attributes; diff --git a/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php b/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php index 838fdb6494450..6f3e657d3e024 100644 --- a/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php +++ b/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php @@ -49,31 +49,34 @@ public function test_get_depth() { } $processor = new WP_Block_Processor( $html ); - $n = new NumberFormatter( 'en-US', NumberFormatter::ORDINAL ); for ( $i = 0; $i < $max_depth; $i++ ) { + $nth = $i + 1; + $this->assertTrue( $processor->next_delimiter(), - "Should have found {$n->format( $i + 1 )} opening delimiter: check test setup." + "Should have found opening delimiter #{$nth}: check test setup." ); $this->assertSame( $i + 1, $processor->get_depth(), - "Should have identified the proper depth of the {$n->format( $i + 1 )} opening delimiter." + "Should have identified the proper depth of opening delimiter #{$nth}." ); } for ( $i = 0; $i < $max_depth; $i++ ) { + $nth = $i + 1; + $this->assertTrue( $processor->next_delimiter(), - "Should have found {$n->format( $i + 1 )} closing delimiter: check test setup." + "Should have found closing delimiter #{$nth}: check test setup." ); $this->assertSame( $max_depth - $i - 1, $processor->get_depth(), - "Should have identified the proper depth of the {$n->format( $i + 1 )} closing delimiter." + "Should have identified the proper depth of closing delimiter #{$nth}." ); } }