From ccc2ac081dc2e03795316741d513895a3fac54b0 Mon Sep 17 00:00:00 2001 From: David Levine Date: Thu, 12 Sep 2024 11:15:32 +0000 Subject: [PATCH] dev: refactor ContentBlocksResolver and deprecate `TraverseHelpers` --- .phpcs.xml.dist | 2 +- includes/Data/ContentBlocksResolver.php | 195 ++++++++++++++++++------ includes/Utilities/TraverseHelpers.php | 20 +++ tests/unit/TraverseHelpersTest.php | 85 ----------- 4 files changed, 168 insertions(+), 134 deletions(-) delete mode 100644 tests/unit/TraverseHelpersTest.php diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 192f47fe..4cdec649 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -40,7 +40,7 @@ Tests for WordPress version compatibility. https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/wiki/Customizable-sniff-properties --> - + diff --git a/includes/Data/ContentBlocksResolver.php b/includes/Data/ContentBlocksResolver.php index 3655362d..a7493b3e 100644 --- a/includes/Data/ContentBlocksResolver.php +++ b/includes/Data/ContentBlocksResolver.php @@ -7,7 +7,6 @@ namespace WPGraphQL\ContentBlocks\Data; -use WPGraphQL\ContentBlocks\Utilities\TraverseHelpers; use WPGraphQL\Model\Post; /** @@ -17,9 +16,9 @@ final class ContentBlocksResolver { /** * Retrieves a list of content blocks * - * @param mixed $node The node we are resolving. - * @param array $args GraphQL query args to pass to the connection resolver. - * @param array $allowed_block_names The list of allowed block names to filter. + * @param \WPGraphQL\Model\Model|mixed $node The node we are resolving. + * @param array $args GraphQL query args to pass to the connection resolver. + * @param string[] $allowed_block_names The list of allowed block names to filter. */ public static function resolve_content_blocks( $node, $args, $allowed_block_names = [] ): array { /** @@ -64,57 +63,18 @@ public static function resolve_content_blocks( $node, $args, $allowed_block_name } // Parse the blocks from HTML comments to an array of blocks - $parsed_blocks = parse_blocks( $content ); + $parsed_blocks = self::parse_blocks( $content ); if ( empty( $parsed_blocks ) ) { return []; } - // 1st Level filtering of blocks that are empty - $parsed_blocks = array_filter( - $parsed_blocks, - static function ( $parsed_block ) { - if ( ! empty( $parsed_block['blockName'] ) ) { - return true; - } - - // Strip empty comments and spaces - $stripped = preg_replace( '//Uis', '', render_block( $parsed_block ) ); - return ! empty( trim( $stripped ?? '' ) ); - }, - ARRAY_FILTER_USE_BOTH - ); - - // 2nd Level assigning of unique id's and missing blockNames - $parsed_blocks = array_map( - static function ( $parsed_block ) { - $parsed_block['clientId'] = uniqid(); - // Since Gutenberg assigns an empty blockName for Classic block - // we define the name here - if ( empty( $parsed_block['blockName'] ) ) { - $parsed_block['blockName'] = 'core/freeform'; - } - return $parsed_block; - }, - $parsed_blocks - ); - - // Resolve reusable blocks - replaces "core/block" with the corresponding block(s) from the reusable ref ID - TraverseHelpers::traverse_blocks( $parsed_blocks, [ TraverseHelpers::class, 'replace_reusable_blocks' ], 0, PHP_INT_MAX ); // Flatten block list here if requested or if 'flat' value is not selected (default) if ( ! isset( $args['flat'] ) || 'true' == $args['flat'] ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual $parsed_blocks = self::flatten_block_list( $parsed_blocks ); } // Final level of filtering out blocks not in the allowed list - if ( ! empty( $allowed_block_names ) ) { - $parsed_blocks = array_filter( - $parsed_blocks, - static function ( $parsed_block ) use ( $allowed_block_names ) { - return in_array( $parsed_block['blockName'], $allowed_block_names, true ); - }, - ARRAY_FILTER_USE_BOTH - ); - } + $parsed_blocks = self::filter_allowed_blocks( $parsed_blocks, $allowed_block_names ); /** * Filters the content blocks after they have been resolved. @@ -129,6 +89,120 @@ static function ( $parsed_block ) use ( $allowed_block_names ) { return is_array( $parsed_blocks ) ? $parsed_blocks : []; } + /** + * Get blocks from html string. + * + * @param string $content Content to parse. + * + * @return array List of blocks. + */ + private static function parse_blocks( $content ): array { + $blocks = parse_blocks( $content ); + + return self::handle_do_blocks( $blocks ); + } + + /** + * Recursively process blocks. + * + * This mirrors the `do_blocks` function in WordPress which is responsible for hydrating certain block attributes and supports, but without the forced rendering. + * + * @param array[] $blocks Blocks data. + * + * @return array[] The processed blocks. + */ + private static function handle_do_blocks( array $blocks ): array { + $parsed = []; + foreach ( $blocks as $block ) { + $block_data = self::handle_do_block( $block ); + + if ( $block_data ) { + $parsed[] = $block_data; + } + } + + // Remove empty blocks. + return array_filter( $parsed ); + } + + /** + * Process a block, getting all extra fields. + * + * @param array $block Block data. + * + * @return ?array The processed block. + */ + private static function handle_do_block( array $block ): ?array { + if ( self::is_block_empty( $block ) ) { + return null; + } + + // Since Gutenberg assigns an empty blockName for Classic block, we define it here. + if ( empty( $block['blockName'] ) ) { + $block['blockName'] = 'core/freeform'; + } + + // Assign a unique clientId to the block. + $block['clientId'] = uniqid(); + + // @todo apply more hydrations. + + $block = self::populate_reusable_blocks( $block ); + + // Prepare innerBlocks. + if ( ! empty( $block['innerBlocks'] ) ) { + $block['innerBlocks'] = self::handle_do_blocks( $block['innerBlocks'] ); + } + + return $block; + } + + /** + * Checks whether a block is really empty, and not just a `core/freeform`. + * + * @param array $block The block to check. + */ + private static function is_block_empty( array $block ): bool { + // If we have a blockName, no need to check further. + if ( ! empty( $block['blockName'] ) ) { + return false; + } + + // @todo add more checks and avoid using render_block(). + + // Strip empty comments and spaces + $stripped = preg_replace( '//Uis', '', render_block( $block ) ); + + return empty( trim( $stripped ?? '' ) ); + } + + /** + * Populates reusable blocks with the blocks from the reusable ref ID. + * + * @param array $block The block to populate. + * + * @return array The populated block. + */ + private static function populate_reusable_blocks( array $block ): array { + if ( 'core/block' !== $block['blockName'] || ! isset( $block['attrs']['ref'] ) ) { + return $block; + } + + $reusable_block = get_post( $block['attrs']['ref'] ); + + if ( ! $reusable_block ) { + return $block; + } + + $parsed_blocks = ! empty( $reusable_block->post_content ) ? self::parse_blocks( $reusable_block->post_content ) : null; + + if ( empty( $parsed_blocks ) ) { + return $block; + } + + return array_merge( ...$parsed_blocks ); + } + /** * Flattens a list blocks into a single array * @@ -145,16 +219,41 @@ private static function flatten_block_list( $blocks ): array { /** * Flattens a block and its inner blocks into a single while attaching unique clientId's * - * @param mixed $block A block. + * @param array $block A parsed block. */ private static function flatten_inner_blocks( $block ): array { - $result = []; + $result = []; + + // Assign a unique clientId to the block if it doesn't already have one. $block['clientId'] = isset( $block['clientId'] ) ? $block['clientId'] : uniqid(); array_push( $result, $block ); + foreach ( $block['innerBlocks'] as $child ) { $child['parentClientId'] = $block['clientId']; - $result = array_merge( $result, self::flatten_inner_blocks( $child ) ); + + // Flatten the child, and merge with the result. + $result = array_merge( $result, self::flatten_inner_blocks( $child ) ); } + return $result; } + + /** + * Filters out disallowed blocks from the list of blocks + * + * @param array $blocks A list of blocks to filter. + * @param string[] $allowed_block_names The list of allowed block names to filter. + */ + private static function filter_allowed_blocks( array $blocks, array $allowed_block_names ): array { + if ( empty( $allowed_block_names ) ) { + return $blocks; + } + + return array_filter( + $blocks, + static function ( $block ) use ( $allowed_block_names ) { + return in_array( $block['blockName'], $allowed_block_names, true ); + } + ); + } } diff --git a/includes/Utilities/TraverseHelpers.php b/includes/Utilities/TraverseHelpers.php index 2a4064cc..bc2ed91d 100644 --- a/includes/Utilities/TraverseHelpers.php +++ b/includes/Utilities/TraverseHelpers.php @@ -7,21 +7,37 @@ namespace WPGraphQL\ContentBlocks\Utilities; +use WPGraphQL\ContentBlocks\Data\ContentBlocksResolver; + /** * Class TraverseHelpers * * Provides utility functions to traverse and manipulate blocks. + * + * @deprecated @todo Blocks should be manipulated directly inside ContentBlocksResolver::handle_do_block() */ final class TraverseHelpers { /** * Traverse blocks and apply a callback with optional depth limit. * + * @deprecated @todo Blocks should be manipulated directly inside ContentBlocksResolver::handle_do_block() + * * @param array &$blocks The blocks to traverse. * @param callable $callback The callback function to apply to each block. * @param int $depth The current depth of traversal. * @param int $max_depth The maximum depth to traverse. */ public static function traverse_blocks( &$blocks, $callback, $depth = 0, $max_depth = PHP_INT_MAX ): void { + _deprecated_function( + __METHOD__, + '@todo', + sprintf( + // translators: %s: class name + esc_html__( 'Manipulate blocks directly inside %s::handle_do_block', 'wp-graphql-content-blocks' ), + ContentBlocksResolver::class + ) + ); + foreach ( $blocks as &$block ) { $callback( $block ); if ( ! empty( $block['innerBlocks'] ) && $depth < $max_depth ) { @@ -33,9 +49,13 @@ public static function traverse_blocks( &$blocks, $callback, $depth = 0, $max_de /** * Example callback function to replace reusable blocks. * + * @deprecated @todo Blocks should be manipulated directly inside ContentBlocksResolver::handle_do_block() + * * @param array $block The block to potentially replace. */ public static function replace_reusable_blocks( &$block ): void { + _deprecated_function( __METHOD__, '@todo', ContentBlocksResolver::class . '::populate_reusable_blocks' ); + if ( 'core/block' === $block['blockName'] && isset( $block['attrs']['ref'] ) ) { $post = get_post( $block['attrs']['ref'] ); $reusable_blocks = ! empty( $post->post_content ) ? parse_blocks( $post->post_content ) : null; diff --git a/tests/unit/TraverseHelpersTest.php b/tests/unit/TraverseHelpersTest.php deleted file mode 100644 index ec710462..00000000 --- a/tests/unit/TraverseHelpersTest.php +++ /dev/null @@ -1,85 +0,0 @@ -post_id = wp_insert_post( - array( - 'post_title' => 'Post Title', - 'post_content' => preg_replace( - '/\s+/', - ' ', - trim( - ' - -

Test

- ' - ) - ), - 'post_status' => 'publish', - ) - ); - } - - public function tearDown(): void { - // your tear down methods here - parent::tearDown(); - wp_delete_post( $this->post_id, true ); - } - public function testTraverseBlocks() { - // Sample blocks data - $blocks = [ - [ - 'blockName' => 'core/group', - 'attrs' => [], - 'innerBlocks' => [ - [ - 'blockName' => 'core/block', - 'attrs' => [ 'ref' => $this->post_id ], - 'innerBlocks' => [] - ] - ] - ], - [ - 'blockName' => 'core/block', - 'attrs' => [ 'ref' => $this->post_id ], - 'innerBlocks' => [] - ] - ]; - - // Expected result after replacing reusable blocks - $expected = [ - [ - 'blockName' => 'core/group', - 'attrs' => [], - 'innerBlocks' => [ - [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - 'innerBlocks' => [], - 'innerHTML' => '

Test

', - 'innerContent' => [ 0 => '

Test

'] - ] - ] - ], - [ - 'blockName' => 'core/paragraph', - 'attrs' => [], - 'innerBlocks' => [], - 'innerHTML' => '

Test

', - 'innerContent' => [ 0 => '

Test

'] - ] - ]; - - TraverseHelpers::traverse_blocks( $blocks, [ TraverseHelpers::class, 'replace_reusable_blocks' ], 0, PHP_INT_MAX ); - $this->assertEquals( $expected, $blocks ); - } -} \ No newline at end of file