blocks = $tree->parse_markup( $content ); return $tree; } /** * Parses block markup into blocks that can be inserted into this tree. * * @param string $markup Block markup. * * @return array */ public function parse_markup( string $markup ): array { return $this->prepare( parse_blocks( $markup ), true ); } /** * Removes whitespace between top-level blocks and assigns internal IDs. * * @param array $blocks Parsed blocks. * @param bool $top Whether these are top-level blocks. * * @return array */ private function prepare( array $blocks, bool $top ): array { $prepared = []; foreach ( $blocks as $block ) { if ( $top && null === $block['blockName'] && '' === trim( $block['innerHTML'] ) ) { continue; } $block['innerBlocks'] = $this->prepare( $block['innerBlocks'], false ); $block[ self::ID ] = ++$this->last_id; $prepared[] = $block; } return $prepared; } /** * Serializes the tree to block markup. * * @return string */ public function serialize(): string { return implode( "\n\n", array_map( 'serialize_block', $this->blocks ) ); } /** * Top-level blocks. * * @return array */ public function blocks(): array { return $this->blocks; } /** * Number of blocks, including inner blocks. * * @param array|null $blocks Blocks to count. Defaults to the whole tree. * * @return int */ public function count( ?array $blocks = null ): int { $count = 0; foreach ( $blocks ?? $this->blocks as $block ) { $count += 1 + $this->count( $block['innerBlocks'] ); } return $count; } /** * Parses a path like "2.0". * * @param string $path Path. * * @return int[]|WP_Error */ public static function parse_path( string $path ) { if ( ! preg_match( '/^\d+(\.\d+)*$/', $path ) ) { /* translators: %s: block path */ return Helpers::error( 'invalid_path', sprintf( __( 'Invalid block path "%s". Use the paths returned by post-blocks-get, e.g. "2" or "2.0".', 'seeb-abilities' ), $path ) ); } return array_map( 'intval', explode( '.', $path ) ); } /** * Formats a path. * * @param int[] $path Path. * * @return string */ public static function path_string( array $path ): string { return implode( '.', $path ); } /** * Returns a reference to the block at the path. * * @param int[] $path Path. * * @return array * * @throws OutOfRangeException When there's no block at the path. */ private function &node( array $path ): array { if ( ! $path ) { throw new OutOfRangeException( '' ); } $list = &$this->blocks; $node = null; foreach ( $path as $depth => $index ) { if ( ! isset( $list[ $index ] ) ) { throw new OutOfRangeException( self::path_string( array_slice( $path, 0, $depth + 1 ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal, always caught. } $node = &$list[ $index ]; $list = &$node['innerBlocks']; } return $node; } /** * Returns the block at the path. * * @param int[] $path Path. * * @return array|null */ public function get( array $path ): ?array { try { return $this->node( $path ); } catch ( OutOfRangeException $e ) { return null; } } /** * Returns an error when there's no block at the path. * * @param int[] $path Path. * * @return true|WP_Error */ public function check_path( array $path ) { if ( null !== $this->get( $path ) ) { return true; } /* translators: %s: block path */ return Helpers::not_found( sprintf( __( 'There is no block at path "%s". Call post-blocks-get to see the current paths.', 'seeb-abilities' ), self::path_string( $path ) ) ); } /** * Replaces the block at the path with the result of the callback. * * @param int[] $path Path. * @param callable $callback Receives the block and returns the changed block. * * @return void */ public function update( array $path, callable $callback ) { $node = &$this->node( $path ); $node = $callback( $node ); } /** * Inserts blocks. * * @param array $blocks Blocks from parse_markup(). * @param string $position start, end, before, after, inside_start or inside_end. * @param int[] $path Reference block for all positions except start and end. * * @return string|WP_Error Path of the first inserted block. */ public function insert( array $blocks, string $position, array $path = [] ) { if ( 'start' === $position || 'end' === $position ) { $index = 'start' === $position ? 0 : count( $this->blocks ); $this->insert_into( [], $index, $blocks ); return (string) $index; } $check = $this->check_path( $path ); if ( is_wp_error( $check ) ) { return $check; } if ( 'inside_start' === $position || 'inside_end' === $position ) { $index = 'inside_start' === $position ? 0 : count( $this->get( $path )['innerBlocks'] ); $this->insert_into( $path, $index, $blocks ); return self::path_string( array_merge( $path, [ $index ] ) ); } $parent = array_slice( $path, 0, -1 ); $index = end( $path ) + ( 'after' === $position ? 1 : 0 ); $this->insert_into( $parent, $index, $blocks ); return self::path_string( array_merge( $parent, [ $index ] ) ); } /** * Inserts blocks into the top level or into the inner blocks of a block. * * @param int[] $parent_path Parent block path, empty for the top level. * @param int $index Position among the parent's blocks. * @param array $blocks Blocks to insert. * * @return void */ private function insert_into( array $parent_path, int $index, array $blocks ) { if ( ! $parent_path ) { array_splice( $this->blocks, $index, 0, $blocks ); return; } $parent = &$this->node( $parent_path ); $parent['innerContent'] = self::insert_placeholders( $parent['innerContent'], $index, count( $blocks ) ); array_splice( $parent['innerBlocks'], $index, 0, $blocks ); } /** * Adds null placeholders for new inner blocks to a block's innerContent. * * @param array $content innerContent. * @param int $index Inner block index to insert at. * @param int $count Number of blocks. * * @return array */ private static function insert_placeholders( array $content, int $index, int $count ): array { $placeholders = array_fill( 0, $count, null ); $seen = 0; $last = null; foreach ( $content as $i => $chunk ) { if ( null !== $chunk ) { continue; } if ( $seen === $index ) { array_splice( $content, $i, 0, $placeholders ); return $content; } ++$seen; $last = $i; } if ( null !== $last ) { array_splice( $content, $last + 1, 0, $placeholders ); return $content; } // No inner blocks yet: find where in the block's HTML they belong. $html = implode( '', array_map( 'strval', $content ) ); $position = self::inner_position( $html ); return array_merge( [ substr( $html, 0, $position ) ], $placeholders, [ substr( $html, $position ) ] ); } /** * Finds where inner blocks go in the HTML of a block that has none yet. * * Container blocks save an empty element for their inner blocks: an inner container like * "wp-block-cover__inner-container" or "wp-block-media-text__content", or the wrapper itself. * Without one, the inner blocks go before the wrapper's closing tag. * * @param string $html Block HTML. * * @return int Offset in the HTML. */ private static function inner_position( string $html ): int { $element = '(div|section|main|aside|header|footer|nav|article|ul|ol|figure|blockquote|details)'; // An empty inner container element. if ( preg_match( '#<' . $element . '\b[^>]*class="[^"]*(?:inner-container|__content|__inner)[^"]*"[^>]*>(\s*)#i', $html, $match, PREG_OFFSET_CAPTURE ) ) { return $match[2][1]; } // The last empty element, which is the wrapper itself for blocks like Group. if ( preg_match_all( '#<' . $element . '\b[^>]*>(\s*)#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) { return end( $matches[2] )[1]; } $position = strrpos( $html, 'check_path( $path ); if ( is_wp_error( $check ) ) { return $check; } $index = end( $path ); $parent_path = array_slice( $path, 0, -1 ); if ( ! $parent_path ) { return array_splice( $this->blocks, $index, 1 )[0]; } $parent = &$this->node( $parent_path ); $removed = array_splice( $parent['innerBlocks'], $index, 1 )[0]; $seen = 0; foreach ( $parent['innerContent'] as $i => $chunk ) { if ( null === $chunk && $seen++ === $index ) { unset( $parent['innerContent'][ $i ] ); break; } } $parent['innerContent'] = array_values( $parent['innerContent'] ); return $removed; } /** * Replaces the block at the path with other blocks. * * @param int[] $path Path. * @param array $blocks Blocks from parse_markup(). * * @return true|WP_Error */ public function replace( array $path, array $blocks ) { $check = $this->check_path( $path ); if ( is_wp_error( $check ) ) { return $check; } $index = end( $path ); $parent_path = array_slice( $path, 0, -1 ); if ( ! $parent_path ) { array_splice( $this->blocks, $index, 1, $blocks ); return true; } $parent = &$this->node( $parent_path ); $seen = 0; // Put the new placeholders exactly where the old one was, so wrapper HTML around it is kept. foreach ( $parent['innerContent'] as $i => $chunk ) { if ( null === $chunk && $seen++ === $index ) { array_splice( $parent['innerContent'], $i, 1, array_fill( 0, count( $blocks ), null ) ); break; } } array_splice( $parent['innerBlocks'], $index, 1, $blocks ); return true; } /** * Names of the blocks along a path, outermost first. * * @param int[] $path Path. * * @return string[] */ public function names( array $path ): array { $names = []; $depth = count( $path ); for ( $i = 1; $i <= $depth; $i++ ) { $block = $this->get( array_slice( $path, 0, $i ) ); $names[] = $block['blockName'] ?? 'core/freeform'; } return $names; } /** * Moves a block. * * @param int[] $path Block to move. * @param int[] $target Reference block. * @param string $position before, after, inside_start or inside_end. * * @return string|WP_Error New path of the moved block. */ public function move( array $path, array $target, string $position ) { foreach ( [ $path, $target ] as $check_path ) { $check = $this->check_path( $check_path ); if ( is_wp_error( $check ) ) { return $check; } } if ( array_slice( $target, 0, count( $path ) ) === $path ) { return Helpers::error( 'invalid_move', __( 'A block can\'t be moved relative to itself or into one of its own inner blocks.', 'seeb-abilities' ) ); } $target_id = $this->get( $target )[ self::ID ]; $block = $this->remove( $path ); return $this->insert( [ $block ], $position, $this->find_by_id( $target_id ) ); } /** * Finds the path of a block by its internal ID. * * @param int $id Internal ID. * @param array|null $blocks Blocks to search. * @param int[] $prefix Path of the blocks. * * @return int[] */ private function find_by_id( int $id, ?array $blocks = null, array $prefix = [] ): array { foreach ( $blocks ?? $this->blocks as $i => $block ) { $path = array_merge( $prefix, [ $i ] ); if ( $block[ self::ID ] === $id ) { return $path; } $found = $this->find_by_id( $id, $block['innerBlocks'], $path ); if ( $found ) { return $found; } } return []; } /** * Finds the paths of all blocks that match, in document order. * * @param callable $matches Receives a block and returns whether it matches. * @param array|null $blocks Blocks to search. * @param int[] $prefix Path of the blocks. * * @return int[][] */ public function find( callable $matches, ?array $blocks = null, array $prefix = [] ): array { $found = []; foreach ( $blocks ?? $this->blocks as $i => $block ) { $path = array_merge( $prefix, [ $i ] ); if ( $matches( $block ) ) { $found[] = $path; } $found = array_merge( $found, $this->find( $matches, $block['innerBlocks'], $path ) ); } return $found; } /** * Describes blocks for ability output. * * @param bool $with_html Whether to include each block's own HTML. * @param array|null $blocks Blocks to describe. * @param int[] $prefix Path of the blocks. * * @return array */ public function describe( bool $with_html = true, ?array $blocks = null, array $prefix = [] ): array { $described = []; foreach ( $blocks ?? $this->blocks as $i => $block ) { $path = array_merge( $prefix, [ $i ] ); $item = self::summary( $block, $path, $with_html ); $item['inner_blocks'] = $this->describe( $with_html, $block['innerBlocks'], $path ); $described[] = $item; } return $described; } /** * Describes one block without its inner blocks. * * @param array $block Block. * @param int[] $path Path. * @param bool $with_html Whether to include the block's own HTML. * * @return array */ public static function summary( array $block, array $path, bool $with_html = true ): array { $summary = [ 'path' => self::path_string( $path ), 'name' => $block['blockName'] ?? 'core/freeform', 'attributes' => (object) $block['attrs'], ]; if ( $with_html ) { $summary['html'] = trim( $block['innerHTML'] ); } else { $summary['text'] = wp_html_excerpt( Format::text( serialize_block( $block ) ), 120, '…' ); } return $summary; } }