Skip to content
Open
Changes from all commits
Commits
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
228 changes: 126 additions & 102 deletions src/wp-includes/class-wp-block-parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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;
}
Expand All @@ -90,47 +101,55 @@ 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() ) {
/*
* 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;
}

/*
* 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.
*
* 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.
*/
$this->implicitly_close_and_add_all_open_blocks();
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
Expand Down Expand Up @@ -160,22 +179,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
Expand Down Expand Up @@ -234,72 +250,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<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/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 );

if ( $is_closer ) {
return array( 'block-closer', $name, null, $started_at, $length );
case WP_Block_Processor::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();
}

/**
Expand Down Expand Up @@ -393,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;
}
}
}

/**
Expand Down
Loading