[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/ -> class-wp-block-parser.php (source)

   1  <?php
   2  /**
   3   * Block Serialization Parser
   4   *
   5   * @package WordPress
   6   */
   7  
   8  /**
   9   * Class WP_Block_Parser
  10   *
  11   * Parses a document and constructs a list of parsed block objects
  12   *
  13   * @since 5.0.0
  14   * @since 4.0.0 returns arrays not objects, all attributes are arrays
  15   */
  16  class WP_Block_Parser {
  17      /**
  18       * Input document being parsed
  19       *
  20       * @example "Pre-text\n<!-- wp:paragraph -->This is inside a block!<!-- /wp:paragraph -->"
  21       *
  22       * @since 5.0.0
  23       * @var string
  24       */
  25      public $document;
  26  
  27      /**
  28       * Tracks parsing progress through document
  29       *
  30       * @since 5.0.0
  31       * @var int
  32       */
  33      public $offset;
  34  
  35      /**
  36       * List of parsed blocks
  37       *
  38       * @since 5.0.0
  39       * @var array[]
  40       */
  41      public $output;
  42  
  43      /**
  44       * Stack of partially-parsed structures in memory during parse
  45       *
  46       * @since 5.0.0
  47       * @var WP_Block_Parser_Frame[]
  48       * @phpstan-var list<WP_Block_Parser_Frame>
  49       */
  50      public $stack;
  51  
  52      /**
  53       * Parses a document and returns a list of block structures
  54       *
  55       * When encountering an invalid parse will return a best-effort
  56       * parse. In contrast to the specification parser this does not
  57       * return an error on invalid inputs.
  58       *
  59       * @since 5.0.0
  60       *
  61       * @param string $document Input document being parsed.
  62       * @return array[]
  63       */
  64  	public function parse( $document ) {
  65          $this->document = $document;
  66          $this->offset   = 0;
  67          $this->output   = array();
  68          $this->stack    = array();
  69  
  70          while ( $this->proceed() ) {
  71              continue;
  72          }
  73  
  74          return $this->output;
  75      }
  76  
  77      /**
  78       * Processes the next token from the input document
  79       * and returns whether to proceed eating more tokens
  80       *
  81       * This is the "next step" function that essentially
  82       * takes a token as its input and decides what to do
  83       * with that token before descending deeper into a
  84       * nested block tree or continuing along the document
  85       * or breaking out of a level of nesting.
  86       *
  87       * @internal
  88       * @since 5.0.0
  89       *
  90       * @return bool
  91       */
  92  	public function proceed() {
  93          $next_token = $this->next_token();
  94          list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token;
  95          $stack_depth = count( $this->stack );
  96  
  97          // we may have some HTML soup before the next block.
  98          $leading_html_start = $start_offset > $this->offset ? $this->offset : null;
  99  
 100          switch ( $token_type ) {
 101              case 'no-more-tokens':
 102                  // if not in a block then flush output.
 103                  if ( 0 === $stack_depth ) {
 104                      $this->add_freeform();
 105                      return false;
 106                  }
 107  
 108                  /*
 109                   * Otherwise we have a problem
 110                   * This is an error
 111                   *
 112                   * we have options
 113                   * - treat it all as freeform text
 114                   * - assume an implicit closer (easiest when not nesting)
 115                   */
 116  
 117                  // for the easy case we'll assume an implicit closer.
 118                  if ( 1 === $stack_depth ) {
 119                      $this->add_block_from_stack();
 120                      return false;
 121                  }
 122  
 123                  /*
 124                   * for the nested case where it's more difficult we'll
 125                   * have to assume that multiple closers are missing
 126                   * and so we'll collapse the whole stack piecewise
 127                   */
 128                  while ( 0 < count( $this->stack ) ) {
 129                      $this->add_block_from_stack();
 130                  }
 131                  return false;
 132  
 133              case 'void-block':
 134                  /*
 135                   * easy case is if we stumbled upon a void block
 136                   * in the top-level of the document
 137                   */
 138                  if ( 0 === $stack_depth ) {
 139                      if ( isset( $leading_html_start ) ) {
 140                          $this->output[] = (array) $this->freeform(
 141                              substr(
 142                                  $this->document,
 143                                  $leading_html_start,
 144                                  $start_offset - $leading_html_start
 145                              )
 146                          );
 147                      }
 148  
 149                      $this->output[] = (array) new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() );
 150                      $this->offset   = $start_offset + $token_length;
 151                      return true;
 152                  }
 153  
 154                  // otherwise we found an inner block.
 155                  $this->add_inner_block(
 156                      new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ),
 157                      $start_offset,
 158                      $token_length
 159                  );
 160                  $this->offset = $start_offset + $token_length;
 161                  return true;
 162  
 163              case 'block-opener':
 164                  // track all newly-opened blocks on the stack.
 165                  array_push(
 166                      $this->stack,
 167                      new WP_Block_Parser_Frame(
 168                          new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ),
 169                          $start_offset,
 170                          $token_length,
 171                          $start_offset + $token_length,
 172                          $leading_html_start
 173                      )
 174                  );
 175                  $this->offset = $start_offset + $token_length;
 176                  return true;
 177  
 178              case 'block-closer':
 179                  /*
 180                   * if we're missing an opener we're in trouble
 181                   * This is an error
 182                   */
 183                  if ( 0 === $stack_depth ) {
 184                      /*
 185                       * we have options
 186                       * - assume an implicit opener
 187                       * - assume _this_ is the opener
 188                       * - give up and close out the document
 189                       */
 190                      $this->add_freeform();
 191                      return false;
 192                  }
 193  
 194                  // if we're not nesting then this is easy - close the block.
 195                  if ( 1 === $stack_depth ) {
 196                      $this->add_block_from_stack( $start_offset );
 197                      $this->offset = $start_offset + $token_length;
 198                      return true;
 199                  }
 200  
 201                  /*
 202                   * otherwise we're nested and we have to close out the current
 203                   * block and add it as a new innerBlock to the parent
 204                   */
 205                  $stack_top                        = array_pop( $this->stack );
 206                  $html                             = substr( $this->document, $stack_top->prev_offset, $start_offset - $stack_top->prev_offset );
 207                  $stack_top->block->innerHTML     .= $html;
 208                  $stack_top->block->innerContent[] = $html;
 209                  $stack_top->prev_offset           = $start_offset + $token_length;
 210  
 211                  $this->add_inner_block(
 212                      $stack_top->block,
 213                      $stack_top->token_start,
 214                      $stack_top->token_length,
 215                      $start_offset + $token_length
 216                  );
 217                  $this->offset = $start_offset + $token_length;
 218                  return true;
 219  
 220              default:
 221                  // This is an error.
 222                  $this->add_freeform();
 223                  return false;
 224          }
 225      }
 226  
 227      /**
 228       * Scans the document from where we last left off
 229       * and finds the next valid token to parse if it exists
 230       *
 231       * Returns the type of the find: kind of find, block information, attributes
 232       *
 233       * @internal
 234       * @since 5.0.0
 235       * @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments
 236       *
 237       * @return array
 238       */
 239  	public function next_token() {
 240          $matches = null;
 241  
 242          /*
 243           * aye the magic
 244           * we're using a single RegExp to tokenize the block comment delimiters
 245           * we're also using a trick here because the only difference between a
 246           * block opener and a block closer is the leading `/` before `wp:` (and
 247           * a closer has no attributes). we can trap them both and process the
 248           * match back in PHP to see which one it was.
 249           */
 250          $has_match = preg_match(
 251              '/<!--\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',
 252              $this->document,
 253              $matches,
 254              PREG_OFFSET_CAPTURE,
 255              $this->offset
 256          );
 257  
 258          // if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE.
 259          if ( false === $has_match ) {
 260              return array( 'no-more-tokens', null, null, null, null );
 261          }
 262  
 263          // we have no more tokens.
 264          if ( 0 === $has_match ) {
 265              return array( 'no-more-tokens', null, null, null, null );
 266          }
 267  
 268          list( $match, $started_at ) = $matches[0];
 269  
 270          $length    = strlen( $match );
 271          $is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1];
 272          $is_void   = isset( $matches['void'] ) && -1 !== $matches['void'][1];
 273          $namespace = $matches['namespace'];
 274          $namespace = ( -1 !== $namespace[1] ) ? $namespace[0] : 'core/';
 275          $name      = $namespace . $matches['name'][0];
 276          $has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1];
 277  
 278          /*
 279           * Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays
 280           * are associative arrays. If we use `array()` we get a JSON `[]`
 281           */
 282          $attrs = $has_attrs
 283              ? json_decode( $matches['attrs'][0], /* as-associative */ true )
 284              : array();
 285  
 286          /*
 287           * This state isn't allowed
 288           * This is an error
 289           */
 290          if ( $is_closer && ( $is_void || $has_attrs ) ) {
 291              // we can ignore them since they don't hurt anything.
 292          }
 293  
 294          if ( $is_void ) {
 295              return array( 'void-block', $name, $attrs, $started_at, $length );
 296          }
 297  
 298          if ( $is_closer ) {
 299              return array( 'block-closer', $name, null, $started_at, $length );
 300          }
 301  
 302          return array( 'block-opener', $name, $attrs, $started_at, $length );
 303      }
 304  
 305      /**
 306       * Returns a new block object for freeform HTML
 307       *
 308       * @internal
 309       * @since 5.0.0
 310       *
 311       * @param string $inner_html HTML content of block.
 312       * @return WP_Block_Parser_Block freeform block object.
 313       */
 314  	public function freeform( $inner_html ) {
 315          return new WP_Block_Parser_Block( null, array(), array(), $inner_html, array( $inner_html ) );
 316      }
 317  
 318      /**
 319       * Pushes a length of text from the input document
 320       * to the output list as a freeform block.
 321       *
 322       * @internal
 323       * @since 5.0.0
 324       *
 325       * @param null|int $length How many bytes of document text to output.
 326       */
 327  	public function add_freeform( $length = null ) {
 328          $length = $length ?? strlen( $this->document ) - $this->offset;
 329  
 330          if ( 0 === $length ) {
 331              return;
 332          }
 333  
 334          $this->output[] = (array) $this->freeform( substr( $this->document, $this->offset, $length ) );
 335      }
 336  
 337      /**
 338       * Given a block structure from memory pushes
 339       * a new block to the output list.
 340       *
 341       * @internal
 342       * @since 5.0.0
 343       *
 344       * @param WP_Block_Parser_Block $block        The block to add to the output.
 345       * @param int                   $token_start  Byte offset into the document where the first token for the block starts.
 346       * @param int                   $token_length Byte length of entire block from start of opening token to end of closing token.
 347       * @param int|null              $last_offset  Last byte offset into document if continuing from earlier output.
 348       */
 349  	public function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) {
 350          $parent                       = $this->stack[ count( $this->stack ) - 1 ];
 351          $parent->block->innerBlocks[] = (array) $block;
 352          $html                         = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset );
 353  
 354          if ( ! empty( $html ) ) {
 355              $parent->block->innerHTML     .= $html;
 356              $parent->block->innerContent[] = $html;
 357          }
 358  
 359          $parent->block->innerContent[] = null;
 360          $parent->prev_offset           = $last_offset ?? $token_start + $token_length;
 361      }
 362  
 363      /**
 364       * Pushes the top block from the parsing stack to the output list.
 365       *
 366       * @internal
 367       * @since 5.0.0
 368       *
 369       * @param int|null $end_offset byte offset into document for where we should stop sending text output as HTML.
 370       */
 371  	public function add_block_from_stack( $end_offset = null ) {
 372          $stack_top   = array_pop( $this->stack );
 373          $prev_offset = $stack_top->prev_offset;
 374  
 375          $html = isset( $end_offset )
 376              ? substr( $this->document, $prev_offset, $end_offset - $prev_offset )
 377              : substr( $this->document, $prev_offset );
 378  
 379          if ( ! empty( $html ) ) {
 380              $stack_top->block->innerHTML     .= $html;
 381              $stack_top->block->innerContent[] = $html;
 382          }
 383  
 384          if ( isset( $stack_top->leading_html_start ) ) {
 385              $this->output[] = (array) $this->freeform(
 386                  substr(
 387                      $this->document,
 388                      $stack_top->leading_html_start,
 389                      $stack_top->token_start - $stack_top->leading_html_start
 390                  )
 391              );
 392          }
 393  
 394          $this->output[] = (array) $stack_top->block;
 395      }
 396  }
 397  
 398  /**
 399   * WP_Block_Parser_Block class.
 400   *
 401   * Required for backward compatibility in WordPress Core.
 402   */
 403  require_once  __DIR__ . '/class-wp-block-parser-block.php';
 404  
 405  /**
 406   * WP_Block_Parser_Frame class.
 407   *
 408   * Required for backward compatibility in WordPress Core.
 409   */
 410  require_once  __DIR__ . '/class-wp-block-parser-frame.php';


Generated : Sun Oct 4 08:20:33 2026 Cross-referenced by PHPXref