diff --git a/src/wp-includes/class-wp-block-processor.php b/src/wp-includes/class-wp-block-processor.php index b62bb1c5110d9..2c8fc6156ffe3 100644 --- a/src/wp-includes/class-wp-block-processor.php +++ b/src/wp-includes/class-wp-block-processor.php @@ -487,61 +487,38 @@ class WP_Block_Processor { */ private $type; - /** - * Whether the last-matched delimiter acts like a void block and should be - * popped from the stack of open blocks as soon as the parser advances. - * - * This applies to void block delimiters and to HTML spans. - * - * @since 6.9.0 - * - * @var bool - */ - private $was_void = false; - /** * For every open block, in hierarchical order, this stores the byte offset - * into the source text where the block type starts, including for HTML spans. + * into the source text where the block type starts. * * To avoid allocating and normalizing block names when they aren’t requested, - * the stack of open blocks is stored as the byte offsets and byte lengths of - * each open block’s block type. This allows for minimal tracking and quick - * reading or comparison of block types when requested. - * - * @since 6.9.0 + * the stack of open blocks is stored as the byte offsets of each open block’s + * block type. This allows for minimal tracking and quick reading or comparison + * of block types when requested. * - * @see self::$open_blocks_length + * Void blocks and HTML spans contain no other tokens, so they are not stored + * on this stack. While the processor is paused on one, it counts as one more + * level of depth. {@see self::get_depth()}. * - * @var int[] - */ - private $open_blocks_at = array(); - - /** - * For every open block, in hierarchical order, this stores the byte length - * of the block’s block type in the source text. For HTML spans this is 0. + * Since HTML spans are discovered after matching the block delimiter which + * follows them, the stack is not updated for that delimiter until the + * processor advances from the HTML span onto the delimiter. * * @since 6.9.0 * - * @see self::$open_blocks_at - * * @var int[] */ - private $open_blocks_length = array(); + private $open_blocks_at = array(); /** - * Indicates which operation should apply to the stack of open blocks after - * processing any pending spans of HTML. + * Whether {@see self::next_token()} should pass over HTML spans instead + * of pausing on them, for searches which cannot match an HTML span. * - * Since HTML spans are discovered after matching block delimiters, those - * delimiters need to defer modifying the stack of open blocks. This value, - * if set, indicates what operation should be applied. The properties - * associated with token boundaries still point to the delimiters even - * when processing HTML spans, so there’s no need to track them independently. + * @since 7.2.0 * - * @since 6.9.0 - * @var 'push'|'void'|'pop'|null + * @var bool */ - private $next_stack_op = null; + private $skip_html_spans = false; /** * Creates a new block processor. @@ -628,6 +605,8 @@ public function __construct( string $source_text ) { * @param string|null $block_type Optional. If provided, advance until a block of this type is found. * Default is to stop at any block regardless of its type. * @return bool Whether an opening delimiter for a block was found. + * + * @phpstan-impure */ public function next_block( ?string $block_type = null ): bool { while ( $this->next_delimiter( $block_type ) ) { @@ -679,11 +658,14 @@ public function next_block( ?string $block_type = null ): bool { * @param string|null $block_name Optional. Keep searching until a block of this name is found. * Defaults to visit every block regardless of type. * @return bool Whether a block delimiter was matched. + * + * @phpstan-impure */ public function next_delimiter( ?string $block_name = null ): bool { - if ( ! isset( $block_name ) ) { + // Only the wildcard and the freeform block types match HTML spans. + if ( '*' === $block_name || 'core/freeform' === $block_name || 'freeform' === $block_name ) { while ( $this->next_token() ) { - if ( ! $this->is_html() ) { + if ( $this->is_block_type( $block_name ) ) { return true; } } @@ -691,13 +673,41 @@ public function next_delimiter( ?string $block_name = null ): bool { return false; } - while ( $this->next_token() ) { - if ( $this->is_block_type( $block_name ) ) { - return true; - } + $this->skip_html_spans = true; + + if ( ! isset( $block_name ) ) { + $found = $this->next_token(); + } else { + // A block type in the document without a namespace is in the implicit `core` namespace. + $text = $this->source_text; + $block_type = self::normalize_block_type( $block_name ); + $core_name = str_starts_with( $block_type, 'core/' ) ? substr( $block_type, 5 ) : null; + + do { + $found = $this->next_token(); + if ( ! $found ) { + break; + } + + if ( $this->namespace_at === $this->name_at ) { + $matches = ( + isset( $core_name ) && + strlen( $core_name ) === $this->name_length && + 0 === substr_compare( $text, $core_name, $this->name_at, $this->name_length ) + ); + } else { + $length = $this->name_at + $this->name_length - $this->namespace_at; + $matches = ( + strlen( $block_type ) === $length && + 0 === substr_compare( $text, $block_type, $this->namespace_at, $length ) + ); + } + } while ( ! $matches ); } - return false; + $this->skip_html_spans = false; + + return $found; } /** @@ -733,19 +743,14 @@ public function next_delimiter( ?string $block_name = null ): bool { * @since 6.9.0 * * @return bool Whether a token was matched or the end of the document was reached without finding any. + * + * @phpstan-impure */ public function next_token(): bool { if ( $this->last_error || self::COMPLETE === $this->state || self::INCOMPLETE_INPUT === $this->state ) { return false; } - // Void tokens automatically pop off the stack of open blocks. - if ( $this->was_void ) { - array_pop( $this->open_blocks_at ); - array_pop( $this->open_blocks_length ); - $this->was_void = false; - } - $text = $this->source_text; $end = strlen( $text ); @@ -762,26 +767,13 @@ public function next_token(): bool { return false; } - switch ( $this->next_stack_op ) { - case 'void': - $this->was_void = true; - $this->open_blocks_at[] = $this->namespace_at; - $this->open_blocks_length[] = $this->name_at + $this->name_length - $this->namespace_at; - break; - - case 'push': - $this->open_blocks_at[] = $this->namespace_at; - $this->open_blocks_length[] = $this->name_at + $this->name_length - $this->namespace_at; - break; - - case 'pop': - array_pop( $this->open_blocks_at ); - array_pop( $this->open_blocks_length ); - break; + if ( self::OPENER === $this->type ) { + $this->open_blocks_at[] = $this->namespace_at; + } elseif ( self::CLOSER === $this->type ) { + array_pop( $this->open_blocks_at ); } - $this->next_stack_op = null; - $this->state = self::MATCHED; + $this->state = self::MATCHED; return true; } @@ -804,9 +796,17 @@ public function next_token(): bool { * comments once they are matched to see if they are also block delimiters. In * practice, this nuance has not caused any known problems since developing blocks. * + * Searching for the `!` skips past the many `<` which open HTML tags. + * * <⃨!⃨-⃨-⃨ /wp:core/paragraph {"dropCap":true} /--> */ - $comment_opening_at = strpos( $text, '' ) && ! str_ends_with( $text, '--!>' ) ) { - goto incomplete; - } + $dashes_at = '!' === $text[ $closer_at - 1 ] ? $closer_at - 3 : $closer_at - 2; + } while ( '-' !== $text[ $dashes_at ] || '-' !== $text[ $dashes_at + 1 ] ); + + $after_comment_end = $closer_at + 1; /* * Only normative comment closers are recognized block delimiters, @@ -1083,11 +1091,10 @@ public function next_token(): bool { $this->after_previous_delimiter = $after_prev_delimiter; $this->matched_delimiter_at = $end; $this->matched_delimiter_length = 0; - $this->open_blocks_at[] = $after_prev_delimiter; - $this->open_blocks_length[] = 0; - $this->was_void = true; - return true; + if ( ! $this->skip_html_spans ) { + return true; + } } $this->state = self::COMPLETE; @@ -1116,54 +1123,36 @@ public function next_token(): bool { * they shall be interpreted as void blocks, per the spec parser. */ if ( $has_void_flag ) { - $this->type = self::VOID; - $this->next_stack_op = 'void'; + $this->type = self::VOID; } elseif ( $has_closer ) { - $this->type = self::CLOSER; - $this->next_stack_op = 'pop'; + $this->type = self::CLOSER; /* * @todo Check if the name matches and bail according to the spec parser. * The default parser doesn’t examine the names. */ } else { - $this->type = self::OPENER; - $this->next_stack_op = 'push'; + $this->type = self::OPENER; } $this->has_closing_flag = $has_closer; - // HTML spans are visited before the delimiter that follows them. - if ( $comment_opening_at > $after_prev_delimiter ) { - $this->state = self::HTML_SPAN; - $this->open_blocks_at[] = $after_prev_delimiter; - $this->open_blocks_length[] = 0; - $this->was_void = true; - + /* + * HTML spans are visited before the delimiter that follows them, + * and the stack of open blocks is updated once the processor + * advances onto that delimiter. + */ + if ( $comment_opening_at > $after_prev_delimiter && ! $this->skip_html_spans ) { + $this->state = self::HTML_SPAN; return true; } - // If there were no HTML spans then flush the enqueued stack operations immediately. - switch ( $this->next_stack_op ) { - case 'void': - $this->was_void = true; - $this->open_blocks_at[] = $namespace_at; - $this->open_blocks_length[] = $name_at + $name_length - $namespace_at; - break; - - case 'push': - $this->open_blocks_at[] = $namespace_at; - $this->open_blocks_length[] = $name_at + $name_length - $namespace_at; - break; - - case 'pop': - array_pop( $this->open_blocks_at ); - array_pop( $this->open_blocks_length ); - break; + if ( self::OPENER === $this->type ) { + $this->open_blocks_at[] = $namespace_at; + } elseif ( self::CLOSER === $this->type ) { + array_pop( $this->open_blocks_at ); } - $this->next_stack_op = null; - return true; incomplete: @@ -1181,7 +1170,7 @@ public function next_token(): bool { * // Freeform HTML content is an HTML span. * $processor = new WP_Block_Processor( 'Just text' ); * $processor->next_token(); - * array( '#text' ) === $processor->get_breadcrumbs(); + * array( '#html' ) === $processor->get_breadcrumbs(); * * $processor = new WP_Block_Processor( '' ); * $processor->next_token(); @@ -1208,20 +1197,18 @@ public function next_token(): bool { * @return string[] */ public function get_breadcrumbs(): array { - $breadcrumbs = array_fill( 0, count( $this->open_blocks_at ), null ); + $breadcrumbs = array(); - /* - * Since HTML spans can only be at the very end, set the normalized block name for - * each open element and then work backwards after creating the array. This allows - * for the elimination of a conditional on each iteration of the loop. - */ - foreach ( $this->open_blocks_at as $i => $at ) { - $block_type = substr( $this->source_text, $at, $this->open_blocks_length[ $i ] ); - $breadcrumbs[ $i ] = self::normalize_block_type( $block_type ); + foreach ( $this->open_blocks_at as $at ) { + // A block type is always followed by whitespace, which ends this span. + $length = strspn( $this->source_text, 'abcdefghijklmnopqrstuvwxyz0123456789-_/', $at ); + $breadcrumbs[] = self::normalize_block_type( substr( $this->source_text, $at, $length ) ); } - if ( isset( $i ) && 0 === $this->open_blocks_length[ $i ] ) { - $breadcrumbs[ $i ] = '#html'; + if ( self::HTML_SPAN === $this->state ) { + $breadcrumbs[] = '#html'; + } elseif ( self::MATCHED === $this->state && self::VOID === $this->type ) { + $breadcrumbs[] = $this->get_block_type(); } return $breadcrumbs; @@ -1238,7 +1225,14 @@ public function get_breadcrumbs(): array { * @return int */ public function get_depth(): int { - return count( $this->open_blocks_at ); + $depth = count( $this->open_blocks_at ); + + // Void blocks and HTML spans are open only while visiting them. + if ( self::HTML_SPAN === $this->state || ( self::MATCHED === $this->state && self::VOID === $this->type ) ) { + ++$depth; + } + + return $depth; } /** @@ -1292,9 +1286,11 @@ public function get_depth(): int { * inner blocks were found. * } * } + * + * @phpstan-impure */ public function extract_full_block_and_advance(): ?array { - if ( $this->is_html() ) { + if ( self::HTML_SPAN === $this->state ) { $chunk = $this->get_html_content(); return array( @@ -1315,9 +1311,16 @@ public function extract_full_block_and_advance(): ?array { ); $depth = $this->get_depth(); - while ( $this->next_token() && $this->get_depth() > $depth ) { - if ( $this->is_html() ) { - $chunk = $this->get_html_content(); + while ( $this->next_token() ) { + $is_html = self::HTML_SPAN === $this->state; + + // Equivalent to `$this->get_depth() <= $depth`. + if ( count( $this->open_blocks_at ) + ( $is_html || self::VOID === $this->type ? 1 : 0 ) <= $depth ) { + break; + } + + if ( $is_html ) { + $chunk = substr( $this->source_text, $this->after_previous_delimiter, $this->matched_delimiter_at - $this->after_previous_delimiter ); $block['innerHTML'] .= $chunk; $block['innerContent'][] = $chunk; continue; @@ -1329,7 +1332,7 @@ public function extract_full_block_and_advance(): ?array { * @todo This is a decent place to call {@link \render_block()} * @todo Use iteration instead of recursion, or at least refactor to tail-call form. */ - if ( $this->opens_block() ) { + if ( self::CLOSER !== $this->type ) { $inner_block = $this->extract_full_block_and_advance(); $block['innerBlocks'][] = $inner_block; $block['innerContent'][] = null; @@ -1340,8 +1343,8 @@ public function extract_full_block_and_advance(): ?array { * may be matched on an HTML span. This needs to be processed before * moving on to the next token at the start of the next loop iteration. */ - if ( $this->is_html() ) { - $chunk = $this->get_html_content(); + if ( self::HTML_SPAN === $this->state ) { + $chunk = substr( $this->source_text, $this->after_previous_delimiter, $this->matched_delimiter_at - $this->after_previous_delimiter ); $block['innerHTML'] .= $chunk; $block['innerContent'][] = $chunk; } @@ -1492,9 +1495,9 @@ public function is_block_type( string $block_type ): bool { return true; } - if ( $this->is_html() ) { + if ( self::HTML_SPAN === $this->state ) { // This is a core/freeform text block, it’s special. - if ( 0 === ( $this->open_blocks_length[0] ?? null ) ) { + if ( array() === $this->open_blocks_at ) { return ( 'core/freeform' === $block_type || 'freeform' === $block_type @@ -1621,7 +1624,7 @@ public static function are_equal_block_types( string $a_text, int $a_at, int $a_ */ public function opens_block( string ...$block_type ): bool { // HTML spans only open implicit freeform content at the top level. - if ( self::HTML_SPAN === $this->state && 1 !== count( $this->open_blocks_at ) ) { + if ( self::HTML_SPAN === $this->state && array() !== $this->open_blocks_at ) { return false; } @@ -1631,7 +1634,7 @@ public function opens_block( string ...$block_type ): bool { * following delimiter. Therefore the HTML case is handled by checking * the state and depth of the stack of open block. */ - if ( self::CLOSER === $this->type && ! $this->is_html() ) { + if ( self::CLOSER === $this->type && self::HTML_SPAN !== $this->state ) { return false; } @@ -1673,7 +1676,7 @@ public function is_html(): bool { * span containing non-whitespace text. */ public function is_non_whitespace_html(): bool { - if ( ! $this->is_html() ) { + if ( self::HTML_SPAN !== $this->state ) { return false; } @@ -1697,7 +1700,7 @@ public function is_non_whitespace_html(): bool { * @return string|null Raw HTML content, or `null` if not currently matched on HTML. */ public function get_html_content(): ?string { - if ( ! $this->is_html() ) { + if ( self::HTML_SPAN !== $this->state ) { return null; } @@ -1746,21 +1749,14 @@ public function get_html_content(): ?string { * if matched on an explicit delimiter, otherwise `null`. */ public function get_block_type(): ?string { - if ( - self::READY === $this->state || - self::COMPLETE === $this->state || - self::INCOMPLETE_INPUT === $this->state - ) { - return null; - } - - // This is a core/freeform text block, it’s special. - if ( $this->is_html() ) { + if ( self::MATCHED !== $this->state ) { return null; } $block_type = substr( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length ); - return self::normalize_block_type( $block_type ); + + // The name starts where the namespace would when the namespace is the implicit “core”. + return $this->namespace_at === $this->name_at ? "core/{$block_type}" : $block_type; } /** @@ -1800,23 +1796,14 @@ public function get_block_type(): ?string { * if matched on an explicit delimiter or freeform block, otherwise `null`. */ public function get_printable_block_type(): ?string { - if ( - self::READY === $this->state || - self::COMPLETE === $this->state || - self::INCOMPLETE_INPUT === $this->state - ) { - return null; - } - // This is a core/freeform text block, it’s special. - if ( $this->is_html() ) { - return 1 === count( $this->open_blocks_at ) + if ( self::HTML_SPAN === $this->state ) { + return array() === $this->open_blocks_at ? 'core/freeform' : '#innerHTML'; } - $block_type = substr( $this->source_text, $this->namespace_at, $this->name_at - $this->namespace_at + $this->name_length ); - return self::normalize_block_type( $block_type ); + return $this->get_block_type(); } /** @@ -1911,7 +1898,7 @@ public function get_attributes() { public function allocate_and_return_parsed_attributes(): ?array { $this->last_json_error = JSON_ERROR_NONE; - if ( self::CLOSER === $this->type || $this->is_html() || 0 === $this->json_length ) { + if ( self::CLOSER === $this->type || self::HTML_SPAN === $this->state || 0 === $this->json_length ) { return null; } diff --git a/tests/phpstan/baselines/if.alwaysFalse.neon b/tests/phpstan/baselines/if.alwaysFalse.neon index f88dfabfa5893..15f01b77b4cf4 100644 --- a/tests/phpstan/baselines/if.alwaysFalse.neon +++ b/tests/phpstan/baselines/if.alwaysFalse.neon @@ -23,11 +23,6 @@ parameters: identifier: if.alwaysFalse count: 1 path: ../../../src/wp-admin/install.php - - - message: '#^If condition is always false\.$#' - identifier: if.alwaysFalse - count: 2 - path: ../../../src/wp-includes/class-wp-block-processor.php - message: '#^If condition is always false\.$#' identifier: if.alwaysFalse diff --git a/tests/phpstan/baselines/variable.undefined.neon b/tests/phpstan/baselines/variable.undefined.neon index 1a7f497b923aa..d39091d1c42df 100644 --- a/tests/phpstan/baselines/variable.undefined.neon +++ b/tests/phpstan/baselines/variable.undefined.neon @@ -691,17 +691,17 @@ parameters: - message: '#^Variable \$name_at might not be defined\.$#' identifier: variable.undefined - count: 3 + count: 1 path: ../../../src/wp-includes/class-wp-block-processor.php - message: '#^Variable \$name_length might not be defined\.$#' identifier: variable.undefined - count: 3 + count: 1 path: ../../../src/wp-includes/class-wp-block-processor.php - message: '#^Variable \$namespace_at might not be defined\.$#' identifier: variable.undefined - count: 5 + count: 2 path: ../../../src/wp-includes/class-wp-block-processor.php - message: '#^Variable \$filter_id might not be defined\.$#' diff --git a/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php b/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php index 6f3e657d3e024..fd6383fd450fb 100644 --- a/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php +++ b/tests/phpunit/tests/block-processor/wpBlockProcessor-BlockProcessing.php @@ -35,6 +35,111 @@ public function test_get_breadcrumbs() { ); } + /** + * Verifies that void blocks and HTML spans appear in the breadcrumbs + * and depth only while the processor is paused on them. + * + * @ticket 66138 + * + * @covers ::get_breadcrumbs + * @covers ::get_depth + */ + public function test_breadcrumbs_and_depth_for_void_blocks_and_html_spans(): void { + $processor = new WP_Block_Processor( 'xyz' ); + + $expected = array( + array( 'core/a' ), + array( 'core/a', '#html' ), + array( 'core/a', 'core/b' ), + array( 'core/a', 'core/c' ), + array( 'core/a', '#html' ), + array( 'core/a', 'core/d' ), + array( 'core/a' ), + array(), + array( '#html' ), + array( 'core/e' ), + ); + + foreach ( $expected as $i => $breadcrumbs ) { + $this->assertTrue( + $processor->next_token(), + "Should have found token #{$i}: check test setup." + ); + + $this->assertSame( + $breadcrumbs, + $processor->get_breadcrumbs(), + "Should have reported the proper breadcrumbs for token #{$i}." + ); + + $this->assertSame( + count( $breadcrumbs ), + $processor->get_depth(), + "Should have reported the proper depth for token #{$i}." + ); + } + + $this->assertFalse( + $processor->next_token(), + 'Should have found no more tokens: check test setup.' + ); + + $this->assertSame( + array(), + $processor->get_breadcrumbs(), + 'Should have reported no open blocks after the last void block.' + ); + + $this->assertSame( + 0, + $processor->get_depth(), + 'Should have reported no depth after the last void block.' + ); + } + + /** + * Verifies that blocks left open at the end of a document remain on the stack + * after the trailing HTML span is visited. + * + * @ticket 66138 + * + * @covers ::get_breadcrumbs + * @covers ::get_depth + */ + public function test_breadcrumbs_and_depth_for_unclosed_blocks(): void { + $processor = new WP_Block_Processor( 'inner' ); + + $processor->next_token(); + $processor->next_token(); + $this->assertTrue( + $processor->next_token(), + 'Should have found the trailing inner HTML: check test setup.' + ); + + $this->assertSame( + array( 'core/a', 'my/b', '#html' ), + $processor->get_breadcrumbs(), + 'Should have reported the inner HTML inside both open blocks.' + ); + + $this->assertFalse( + $processor->next_token(), + 'Should have found no more tokens: check test setup.' + ); + + $this->assertSame( + array( 'core/a', 'my/b' ), + $processor->get_breadcrumbs(), + 'Should have left the unclosed blocks open at the end of the document.' + ); + + $this->assertSame( + 2, + $processor->get_depth(), + 'Should have reported the depth of the unclosed blocks.' + ); + } + public function test_get_depth() { // Create a deeply-nested stack of blocks. $html = ''; diff --git a/tests/phpunit/tests/block-processor/wpBlockProcessor.php b/tests/phpunit/tests/block-processor/wpBlockProcessor.php index 0b24a1b752176..9471e576a4134 100644 --- a/tests/phpunit/tests/block-processor/wpBlockProcessor.php +++ b/tests/phpunit/tests/block-processor/wpBlockProcessor.php @@ -483,6 +483,57 @@ public static function data_documents_ending_in_a_partial_delimiter(): array { ); } + /** + * Verifies that a `!--` which does not follow a `<` opens no comment. + * + * @ticket 66138 + * + * @dataProvider data_exclamation_marks_outside_of_comment_openers + * + * @covers ::next_token() + * + * @param string $html Input document. + * @param string[] $block_types Printable block type of every delimiter in the document, in order. + */ + public function test_finds_delimiters_around_exclamation_marks( string $html, array $block_types ): void { + $processor = new WP_Block_Processor( $html ); + + $found = array(); + while ( $processor->next_delimiter() ) { + $found[] = $processor->get_printable_block_type(); + } + + $this->assertSame( + $block_types, + $found, + 'Should have found every delimiter in the document.' + ); + + $this->assertNull( + $processor->get_last_error(), + 'Should have reached the end of the document without an error.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_exclamation_marks_outside_of_comment_openers(): array { + return array( + 'Document starts with !--' => array( '!--', array( 'core/a' ) ), + '!-- in text before a block' => array( 'Wow!-- ', array( 'core/a' ) ), + '!-- inside of a block' => array( 'Hi!!--!', array( 'core/a', 'core/a' ) ), + ' array( '', array( 'core/a' ) ), + 'Doctype before a block' => array( '', array( 'core/a' ) ), + '!-- right after a delimiter' => array( '!--', array( 'core/a', 'core/b' ) ), + '< right after a delimiter' => array( '', array( 'core/a', 'core/b' ) ), + 'Only exclamation marks' => array( '!!--!-- !', array() ), + '!-- at the end of a document' => array( 'x!--', array( 'core/a' ) ), + ); + } + /** * Verifies that block delimiters are matched even with malformed * JSON attributes as long as they start and end with curly brackets. @@ -671,6 +722,18 @@ public static function data_delimiters_and_comment_endings(): array { 'Dashes before the closer' => array( '', array( 'core/a' ) ), 'Dash run before a space closer' => array( '', array() ), 'Delimiter after a closed comment' => array( '', array( 'core/a' ) ), + + // Only a `>` after `--` or `--!` ends the comment. + 'Greater-than in the attributes' => array( '', array( 'core/a' ) ), + 'Dash, greater-than in attributes' => array( '', array( 'core/a' ) ), + 'Bang, greater-than in attributes' => array( '', array( 'core/a' ) ), + 'Comment ends in the attributes' => array( '"} -->', array() ), + + // Dashes in the block type do not end the comment. + 'Dash run inside the block name' => array( '', array( 'core/a--b' ) ), + 'Block type ends in dash runs' => array( '', array( 'my--ns/a--' ) ), + 'Comment ends after the name' => array( '', array( 'core/b' ) ), + 'Bang ending after the name' => array( '', array( 'core/b' ) ), ); } @@ -828,6 +891,51 @@ public function test_reports_no_delimiter_type_after_an_error() { ); } + /** + * Verifies that no block type is reported unless matched on a token. + * + * @ticket 66138 + * + * @dataProvider data_unmatched_processor_positions + * + * @covers ::get_block_type + * @covers ::get_printable_block_type + * + * @param string $html Input document. + * @param int $token_count How many times to call next_token() before checking. + */ + public function test_reports_no_block_type_when_not_matched( string $html, int $token_count ): void { + $processor = new WP_Block_Processor( $html ); + + for ( $i = 0; $i < $token_count; $i++ ) { + $processor->next_token(); + } + + $this->assertNull( + $processor->get_block_type(), + 'Should not have returned a block type.' + ); + + $this->assertNull( + $processor->get_printable_block_type(), + 'Should not have returned a printable block type.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_unmatched_processor_positions(): array { + return array( + 'Before scanning' => array( '', 0 ), + 'After the last delimiter' => array( '', 2 ), + 'After the last HTML span' => array( 'text', 3 ), + 'After an incomplete delimiter' => array( '

Text

', + 'Nested blocks' => "\n\n
\n

Text

\n
\n\ntrailing", + 'HTML comments' => 'tail', + 'Unclosed block' => 'unclosed', + 'Ends in 'text 'text<', + 'Ends in delimiter' => 'text', + 'Explicit namespaces' => '', + ); + + $block_types = array( null, 'paragraph', 'core/paragraph', 'core/group', 'my/block', 'my/paragraph', '*', 'freeform', 'core/freeform', 'core/', 'core/my/paragraph' ); + + $data = array(); + foreach ( $documents as $document_name => $html ) { + foreach ( $block_types as $block_type ) { + $block_type_name = $block_type ?? 'any delimiter'; + + $data[ "{$document_name}: {$block_type_name}" ] = array( $html, $block_type ); + } + } + + return $data; + } + /** * Ensures that block extraction matches the behavior of the default block parser. *