From a2577aec19854fa0b692e00f4151ef8cacb4a6ea Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 23 Sep 2026 15:20:40 -0500 Subject: [PATCH 1/2] WP_Block_Parser: Rely on WP_Block_Processor for parsing. This patch unifies parsing between `WP_Block_Parser` and `WP_Block_Processor`, which brings the additional benefit of the optimizations in `WP_Block_Processor`. --- src/wp-includes/class-wp-block-parser.php | 190 ++++++++++------------ 1 file changed, 88 insertions(+), 102 deletions(-) diff --git a/src/wp-includes/class-wp-block-parser.php b/src/wp-includes/class-wp-block-parser.php index 3090c238eb4e8..5087e9da93ef8 100644 --- a/src/wp-includes/class-wp-block-parser.php +++ b/src/wp-includes/class-wp-block-parser.php @@ -49,6 +49,15 @@ class WP_Block_Parser { */ public $stack; + /** + * Internal block processor for parsing and scanning input document. + * + * @since 7.2.0 + * + * @var WP_Block_Processor|null + */ + private $block_processor = null; + /** * Parses a document and returns a list of block structures * @@ -67,6 +76,8 @@ public function parse( $document ) { $this->output = array(); $this->stack = array(); + $this->block_processor = new WP_Block_Processor( $document ); + while ( $this->proceed() ) { continue; } @@ -90,47 +101,58 @@ public function parse( $document ) { * @return bool */ public function proceed() { - $next_token = $this->next_token(); - list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token; + // Ensure this is initialized, because some legacy code calls this method directly. + $this->block_processor ??= new WP_Block_Processor( $this->document ); + + $processor = $this->block_processor; $stack_depth = count( $this->stack ); + // Handle the cases when no more tokens exist. + if ( ! $processor->next_delimiter() ) { + // if not in a block then flush output. + if ( 0 === $stack_depth ) { + $this->add_freeform(); + return false; + } + + /* + * Otherwise we have a problem + * This is an error + * + * we have options + * - treat it all as freeform text + * - assume an implicit closer (easiest when not nesting) + */ + + // for the easy case we'll assume an implicit closer. + if ( 1 === $stack_depth ) { + $this->add_block_from_stack(); + return false; + } + + /* + * for the nested case where it's more difficult we'll + * have to assume that multiple closers are missing + * and so we'll collapse the whole stack piecewise + */ + while ( 0 < count( $this->stack ) ) { + $this->add_block_from_stack(); + } + return false; + } + + $token_type = $processor->get_delimiter_type(); + $block_name = $processor->get_block_type(); + $attrs = $processor->allocate_and_return_parsed_attributes() ?? array(); + $span = $processor->get_span(); + $start_offset = $span->start; + $token_length = $span->length; + // we may have some HTML soup before the next block. $leading_html_start = $start_offset > $this->offset ? $this->offset : null; switch ( $token_type ) { - case 'no-more-tokens': - // if not in a block then flush output. - if ( 0 === $stack_depth ) { - $this->add_freeform(); - return false; - } - - /* - * Otherwise we have a problem - * This is an error - * - * we have options - * - treat it all as freeform text - * - assume an implicit closer (easiest when not nesting) - */ - - // for the easy case we'll assume an implicit closer. - if ( 1 === $stack_depth ) { - $this->add_block_from_stack(); - return false; - } - - /* - * for the nested case where it's more difficult we'll - * have to assume that multiple closers are missing - * and so we'll collapse the whole stack piecewise - */ - while ( 0 < count( $this->stack ) ) { - $this->add_block_from_stack(); - } - return false; - - case 'void-block': + case WP_Block_Processor::VOID: /* * easy case is if we stumbled upon a void block * in the top-level of the document @@ -160,22 +182,19 @@ public function proceed() { $this->offset = $start_offset + $token_length; return true; - case 'block-opener': + case WP_Block_Processor::OPENER: // track all newly-opened blocks on the stack. - array_push( - $this->stack, - new WP_Block_Parser_Frame( - new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), - $start_offset, - $token_length, - $start_offset + $token_length, - $leading_html_start - ) + $this->stack[] = new WP_Block_Parser_Frame( + new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), + $start_offset, + $token_length, + $start_offset + $token_length, + $leading_html_start ); - $this->offset = $start_offset + $token_length; + $this->offset = $start_offset + $token_length; return true; - case 'block-closer': + case WP_Block_Processor::CLOSER: /* * if we're missing an opener we're in trouble * This is an error @@ -234,72 +253,39 @@ public function proceed() { * @since 5.0.0 * @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments * + * @deprecated 7.2.0 Use {@see WP_Block_Processor} instead. + * * @return array */ public function next_token() { - $matches = null; - - /* - * aye the magic - * we're using a single RegExp to tokenize the block comment delimiters - * we're also using a trick here because the only difference between a - * block opener and a block closer is the leading `/` before `wp:` (and - * a closer has no attributes). we can trap them both and process the - * match back in PHP to see which one it was. - */ - $has_match = preg_match( - '/).)*+)?}\s+)?(?P\/)?-->/s', - $this->document, - $matches, - PREG_OFFSET_CAPTURE, - $this->offset - ); - - // if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE. - if ( false === $has_match ) { - return array( 'no-more-tokens', null, null, null, null ); - } + // Ensure this is initialized, because some legacy code calls this method directly. + $this->block_processor ??= new WP_Block_Processor( $this->document ); + + $processor = $this->block_processor; - // we have no more tokens. - if ( 0 === $has_match ) { + if ( ! $processor->next_delimiter() ) { return array( 'no-more-tokens', null, null, null, null ); } - list( $match, $started_at ) = $matches[0]; - - $length = strlen( $match ); - $is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1]; - $is_void = isset( $matches['void'] ) && -1 !== $matches['void'][1]; - $namespace = $matches['namespace']; - $namespace = ( -1 !== $namespace[1] ) ? $namespace[0] : 'core/'; - $name = $namespace . $matches['name'][0]; - $has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1]; - - /* - * Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays - * are associative arrays. If we use `array()` we get a JSON `[]` - */ - $attrs = $has_attrs - ? json_decode( $matches['attrs'][0], /* as-associative */ true ) - : array(); - - /* - * This state isn't allowed - * This is an error - */ - if ( $is_closer && ( $is_void || $has_attrs ) ) { - // we can ignore them since they don't hurt anything. - } + $span = $processor->get_span(); + $started_at = $span->start; + $length = $span->length; + $name = $processor->get_block_type(); + $attrs = $processor->allocate_and_return_parsed_attributes(); - if ( $is_void ) { - return array( 'void-block', $name, $attrs, $started_at, $length ); - } + switch ( $processor->get_delimiter_type() ) { + case WP_Block_Processor::VOID: + return array( 'void-block', $name, $attrs, $started_at, $length ); + + case WP_Block_Processor::CLOSER: + return array( 'block-closer', $name, null, $started_at, $length ); - if ( $is_closer ) { - return array( 'block-closer', $name, null, $started_at, $length ); + case WP_Block_Processor::OPENER: + return array( 'block-opener', $name, $attrs, $started_at, $length ); } - return array( 'block-opener', $name, $attrs, $started_at, $length ); + // This should never be reachable. + return $this->next_token(); } /** From 236659fa61496eea01a7b14413b574d9c7475744 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 24 Sep 2026 11:41:14 -0500 Subject: [PATCH 2/2] Block Parser: Fix implicit block closing error. See wordpress/gutenberg#74427. When reaching the end of a document during block parsing, and blocks remain open, existing open blocks should implicitly close. Previously, there was a mistake in the closing code which duplicated content in the implicitly-closed blocks. In this patch, the semantic actions of closing blocks is extracted for easier identification of the logic, and the defect is resolved. --- src/wp-includes/class-wp-block-parser.php | 80 +++++++++++++++++------ 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/src/wp-includes/class-wp-block-parser.php b/src/wp-includes/class-wp-block-parser.php index 5087e9da93ef8..b1ca24add1649 100644 --- a/src/wp-includes/class-wp-block-parser.php +++ b/src/wp-includes/class-wp-block-parser.php @@ -109,35 +109,32 @@ public function proceed() { // Handle the cases when no more tokens exist. if ( ! $processor->next_delimiter() ) { - // if not in a block then flush output. + /* + * Most documents will end with all blocks properly closed. + * In these cases, all that’s necessary is to flush the output. + * There is probably a terminating newline. + */ if ( 0 === $stack_depth ) { $this->add_freeform(); return false; } /* - * Otherwise we have a problem - * This is an error + * However, if blocks remain open it means there were missing + * closing block delimiters. The post may have been truncated, + * for example, but some amount of corruption produced a malformed + * document. * - * we have options - * - treat it all as freeform text - * - assume an implicit closer (easiest when not nesting) - */ - - // for the easy case we'll assume an implicit closer. - if ( 1 === $stack_depth ) { - $this->add_block_from_stack(); - return false; - } - - /* - * for the nested case where it's more difficult we'll - * have to assume that multiple closers are missing - * and so we'll collapse the whole stack piecewise + * The spec parser considers this a parse error and returns a long + * freeform block containing the full span of text from the input + * document from the start of the un-closed block. + * + * This parser, however, is making a different pragmatic choice: it + * will implicitly close any remaining open blocks, as if the closers + * were provided. This ensures recovery of as much of the provided + * block structure as is possible. */ - while ( 0 < count( $this->stack ) ) { - $this->add_block_from_stack(); - } + $this->implicitly_close_and_add_all_open_blocks(); return false; } @@ -379,6 +376,47 @@ public function add_block_from_stack( $end_offset = null ) { $this->output[] = (array) $stack_top->block; } + + /** + * Closes all open blocks and adds remaining blocks and freeform content to the output list. + * + * @internal + * @since 7.2.0 + */ + public function implicitly_close_and_add_all_open_blocks() { + $implicitly_closed = null; + $last_freeform = null; + + while ( null !== ( $stack_top = array_pop( $this->stack ) ) ) { + if ( isset( $last_freeform ) ) { + $html = substr( $this->document, $stack_top->prev_offset, $last_freeform[1] - $stack_top->prev_offset ); + } else { + $html = substr( $this->document, $stack_top->prev_offset ); + } + + $stack_top->block->innerHTML .= $html; + $stack_top->block->innerContent[] = $html; + + // Trap potential leading freeform content for the final output. + $last_freeform = array( $stack_top->leading_html_start ?? 0, $stack_top->token_start ); + + if ( isset( $implicitly_closed ) ) { + $stack_top->block->innerContent[] = null; + $stack_top->block->innerBlocks[] = $implicitly_closed; + } + + $implicitly_closed = $stack_top->block; + } + + if ( isset( $last_freeform ) && $last_freeform[1] > $last_freeform[0] ) { + $html = substr( $this->document, $last_freeform[0], $last_freeform[1] - $last_freeform[0] ); + $this->output[] = (array) $this->freeform( $html ); + } + + if ( isset( $implicitly_closed ) ) { + $this->output[] = $implicitly_closed; + } + } } /**